nixamp 0.28.43 → 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.
Files changed (72) hide show
  1. package/README.md +8 -3
  2. package/dist/accounts.js +9 -1
  3. package/dist/live-interpreter.d.ts +1 -72
  4. package/dist/live-interpreter.js +1 -269
  5. package/dist/live-recovery.d.ts +1 -6
  6. package/dist/live-recovery.js +1 -11
  7. package/dist/live-voice.d.ts +6 -76
  8. package/dist/live-voice.js +6 -352
  9. package/dist/owner.js +4 -0
  10. package/dist/paywall.js +1 -1
  11. package/dist/pcm-wav.d.ts +1 -2
  12. package/dist/pcm-wav.js +1 -27
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.js +77 -6
  15. package/dist/shared-translation.d.ts +6 -53
  16. package/dist/shared-translation.js +7 -336
  17. package/dist/speaker-turns.d.ts +1 -32
  18. package/dist/speaker-turns.js +1 -30
  19. package/dist/speech.d.ts +3 -5
  20. package/dist/speech.js +4 -7
  21. package/dist/tickets.js +1 -1
  22. package/dist/translation-passes.d.ts +5 -96
  23. package/dist/translation-passes.js +5 -236
  24. package/dist/upgrade-allowance.d.ts +1 -23
  25. package/dist/upgrade-allowance.js +1 -73
  26. package/dist/voice-profile.d.ts +1 -7
  27. package/dist/voice-profile.js +1 -53
  28. package/dist/voice-provider.d.ts +1 -9
  29. package/dist/voice-provider.js +1 -27
  30. package/package.json +4 -2
  31. package/src/accounts.ts +9 -1
  32. package/src/live-interpreter.ts +1 -213
  33. package/src/live-recovery.ts +1 -10
  34. package/src/live-voice.ts +6 -304
  35. package/src/owner.ts +3 -0
  36. package/src/paywall.ts +1 -1
  37. package/src/pcm-wav.ts +1 -26
  38. package/src/server.ts +57 -6
  39. package/src/shared-translation.ts +7 -222
  40. package/src/speaker-turns.ts +1 -34
  41. package/src/speech.ts +4 -5
  42. package/src/tickets.ts +1 -1
  43. package/src/translation-passes.ts +6 -239
  44. package/src/types/auth-system.d.ts +12 -3
  45. package/src/upgrade-allowance.ts +1 -72
  46. package/src/voice-profile.ts +1 -46
  47. package/src/voice-provider.ts +1 -22
  48. package/web/dist/.well-known/openwebring.json +6 -0
  49. package/web/dist/assets/{ar-CR2NEW77.js → ar-8fDonUoB.js} +1 -1
  50. package/web/dist/assets/{cs-ivXHTYBK.js → cs-DCClolTy.js} +1 -1
  51. package/web/dist/assets/{da-DQEGAEdk.js → da-C3RPhXUd.js} +1 -1
  52. package/web/dist/assets/{de-B5VaSv3O.js → de-CYBoEomk.js} +1 -1
  53. package/web/dist/assets/{es-DUTtfGnk.js → es-DS3ls4xT.js} +1 -1
  54. package/web/dist/assets/{fi-CzQZtpO5.js → fi-DbCZpO89.js} +1 -1
  55. package/web/dist/assets/{fr-C2g4DUBr.js → fr-D0z3UpdL.js} +1 -1
  56. package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-BTU3sRlQ.js} +1 -1
  57. package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-CQXTVHn_.js} +1 -1
  58. package/web/dist/assets/{hu--oH7WvAR.js → hu-B37Q8ldT.js} +1 -1
  59. package/web/dist/assets/{id-CjlpBIJc.js → id-4pdXNa4G.js} +1 -1
  60. package/web/dist/assets/index-BDBbN5Qy.js +22 -0
  61. package/web/dist/assets/{it-Dbctup0F.js → it-CTe69-UY.js} +1 -1
  62. package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-BBmkDppm.js} +1 -1
  63. package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-CUVdjYFi.js} +1 -1
  64. package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CxrmHrMn.js} +1 -1
  65. package/web/dist/assets/{ru-DFGYCrj-.js → ru-zjVCfIKq.js} +1 -1
  66. package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CEoiT2Lr.js} +1 -1
  67. package/web/dist/assets/{uk-BTXHwsjV.js → uk-ZC6JzQvD.js} +1 -1
  68. package/web/dist/assets/{vi-DJ-vIPLS.js → vi-D9anF-cl.js} +1 -1
  69. package/web/dist/assets/{zh-D3Gju_Y2.js → zh-BeLYdC0I.js} +1 -1
  70. package/web/dist/index.html +6 -5
  71. package/web/dist/sw.js +23 -22
  72. package/web/dist/assets/index-CF04ablL.js +0 -3
@@ -1,353 +1,7 @@
1
- /** ElevenLabs Flash for live captions. The key stays on the account server. */
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { SpeechError, decodeWav, encodeWav, quietSamples } from "./speech.js";
4
- import { speakerTurns } from "./speaker-turns.js";
5
- import { Guard } from "./guard.js";
6
- import { voiceProviderFailure } from "./voice-provider.js";
7
- export const LIVE_VOICE_MODEL = "eleven_flash_v2_5";
8
- export const LIVE_VOICE_RATE = 16_000;
9
- export const LIVE_VOICE_LANGUAGES = new Set("en ja zh de hi fr ko pt it es id nl tr fil pl sv bg ro ar cs el fi hr ms sk da ta uk ru hu no vi".split(" "));
10
- const HEADERS = { "content-type": "audio/pcm", "cache-control": "no-store", "x-audio-sample-rate": String(LIVE_VOICE_RATE) };
11
- const budget = (value, fallback) => Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
12
- export class LiveVoice {
13
- key;
14
- fetcher;
15
- now;
16
- catalog = null;
17
- catalogUntil = 0;
18
- cache = new Map();
19
- pending = new Map();
20
- usage = new Map();
21
- charsPerMinute;
22
- requests;
23
- grants = new Map();
24
- activeBy = new Map();
25
- db;
26
- billing;
27
- schema = null;
28
- dailyChars;
29
- cleanupAt = 0;
30
- hearing = new Set();
31
- dailyAudioSeconds;
32
- userDailyChars;
33
- userDailyAudioSeconds;
34
- providerPause = null;
35
- checkProvider() {
36
- if (this.providerPause && this.providerPause.until > this.now())
37
- throw this.providerPause.error;
38
- }
39
- async providerFailed(response, operation) {
40
- const failure = await voiceProviderFailure(response);
41
- const until = this.now() + failure.cooldownMs;
42
- // Another in-flight response must not shorten an existing billing pause.
43
- if (!this.providerPause || this.providerPause.until < until)
44
- this.providerPause = { until, error: failure.error };
45
- console.error(JSON.stringify({ event: "voice_provider_failure", provider: "elevenlabs", operation, status: response.status, code: failure.code, cooldownMs: failure.cooldownMs }));
46
- throw failure.error;
47
- }
48
- constructor(options = {}) {
49
- this.key = options.apiKey ?? process.env["ELEVENLABS_API_KEY"] ?? "";
50
- this.fetcher = options.fetcher ?? fetch;
51
- this.now = options.now ?? Date.now;
52
- this.charsPerMinute = budget(options.charsPerMinute, 3000);
53
- this.dailyChars = budget(options.dailyChars, 200_000);
54
- this.dailyAudioSeconds = budget(options.dailyAudioSeconds, 86_400);
55
- this.userDailyChars = budget(options.userDailyChars, 120_000);
56
- this.userDailyAudioSeconds = budget(options.userDailyAudioSeconds, 43_200);
57
- this.requests = new Guard(this.now);
58
- this.db = options.db;
59
- this.billing = options.billing;
60
- }
61
- available() { return this.key !== ""; }
62
- /** Optional diarization, billed only while a signed-in listener requests it.
63
- * Rolling audio is bounded to 15 seconds, including overlap. Every second
64
- * submitted (also repeated context) consumes the persistent provider budget. */
65
- async hear(bytes, by, signal, meter = this.billing, resource = "") {
66
- if (!this.available())
67
- throw new SpeechError("speaker voices are unavailable", 503);
68
- await meter?.require(by, resource);
69
- this.checkProvider();
70
- const wav = decodeWav(bytes);
71
- const seconds = wav.samples.length / wav.rate;
72
- if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1)
73
- throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
74
- if (wav.samples.some(sample => !Number.isFinite(sample)))
75
- throw new SpeechError("invalid audio samples", 400);
76
- if (!this.requests.check(`hear:${by}`, { allowed: 36, windowMs: 60_000 }).ok)
77
- throw new SpeechError("too many speaker transcription requests", 429);
78
- if (quietSamples(wav.samples))
79
- return { language: "", seconds, turns: [] };
80
- if (this.hearing.has(by) || this.hearing.size >= 4)
81
- throw new SpeechError("speaker transcription is busy", 429);
82
- this.hearing.add(by);
83
- let reservation;
84
- let accepted = false;
85
- try {
86
- const billed = Math.ceil(seconds);
87
- await this.reserve(`scribe:user:${by}`, billed, 300, 60_000);
88
- await this.reserve("scribe:server", billed, 600, 60_000);
89
- await this.reserve(`scribe:user:${by}`, billed, this.userDailyAudioSeconds, 86_400_000);
90
- await this.reserve("scribe:server", billed, this.dailyAudioSeconds, 86_400_000);
91
- signal?.throwIfAborted();
92
- this.checkProvider();
93
- const form = new FormData();
94
- // Canonical PCM prevents a crafted container from billing more audio
95
- // than the duration we validated, and removes uploaded metadata.
96
- form.set("file", new Blob([new Uint8Array(encodeWav(wav.samples))], { type: "audio/wav" }), "listening.wav");
97
- form.set("model_id", "scribe_v2");
98
- form.set("diarize", "true");
99
- form.set("tag_audio_events", "false");
100
- form.set("timestamps_granularity", "word");
101
- // No language_code: preserve the source language, including Spanish.
102
- reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
103
- const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
104
- method: "POST", headers: { "xi-api-key": this.key }, body: form,
105
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
106
- });
107
- if (!answer.ok)
108
- await this.providerFailed(answer, "transcription");
109
- accepted = true;
110
- console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "transcription", account: createHash("sha256").update(by).digest("hex").slice(0, 16), audioSeconds: seconds }));
111
- if (reservation)
112
- await meter.commit(reservation);
113
- return speakerTurns(await answer.json(), wav);
114
- }
115
- catch (error) {
116
- if (reservation && !accepted)
117
- await meter.refund(reservation);
118
- throw error;
119
- }
120
- finally {
121
- this.hearing.delete(by);
122
- }
123
- }
124
- async voices() {
125
- if (!this.available())
126
- throw new SpeechError("translated audio needs ELEVENLABS_API_KEY on the account server", 503);
127
- this.checkProvider();
128
- if (!this.catalog || this.now() >= this.catalogUntil) {
129
- this.catalogUntil = this.now() + 3600_000;
130
- this.catalog = this.fetcher("https://api.elevenlabs.io/v2/voices?page_size=100&voice_type=default", {
131
- headers: { "xi-api-key": this.key }, signal: AbortSignal.timeout(8000),
132
- }).then(async (response) => {
133
- if (!response.ok)
134
- await this.providerFailed(response, "voices");
135
- const body = await response.json();
136
- return body.voices.filter(voice => /^[a-zA-Z0-9_-]+$/.test(voice.voice_id)).map(voice => ({
137
- id: voice.voice_id, name: voice.name, gender: voice.labels?.["gender"] ?? "neutral", language: voice.labels?.["language"] ?? "",
138
- }));
139
- }).catch(error => { this.catalog = null; throw error; });
140
- }
141
- return this.catalog;
142
- }
143
- /** A 90-second capability for one channel, never the viewer's account credential. */
144
- async grant(by, channel) {
145
- if (!/^[\w-]{1,80}$/.test(channel))
146
- throw new SpeechError("choose a playback session or live channel", 400);
147
- if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok)
148
- throw new SpeechError("too many audio authorization requests", 429);
149
- await this.billing?.require(by, channel);
150
- for (const [token, grant] of this.grants)
151
- if (grant.expires <= this.now())
152
- this.grants.delete(token);
153
- if (this.grants.size >= 5000)
154
- throw new SpeechError("audio authorization is busy", 503);
155
- const token = `nxd_${randomBytes(32).toString("base64url")}`;
156
- const expires = this.now() + 90_000;
157
- if (this.db) {
158
- await this.ensure();
159
- await this.db.query("INSERT INTO live_voice_grants (token_hash, by_account, channel, expires_at, remaining) VALUES ($1, $2, $3, $4, 2000)", [createHash("sha256").update(token).digest("hex"), by, channel, new Date(expires)]);
160
- }
161
- else
162
- this.grants.set(token, { by, channel, expires, remaining: 2000 });
163
- return { token, expires };
164
- }
165
- async authorize(token, channel, chars) {
166
- if (!/^nxd_[A-Za-z0-9_-]{43}$/.test(token))
167
- throw new SpeechError("sign in to enable translated audio", 401);
168
- if (!Number.isFinite(chars) || chars < 1 || chars > 600)
169
- throw new SpeechError("invalid caption length", 400);
170
- if (this.db) {
171
- await this.ensure();
172
- const result = await this.db.query(`UPDATE live_voice_grants SET remaining = remaining - $3
173
- WHERE token_hash = $1 AND channel = $2 AND expires_at > $4 AND remaining >= $3 RETURNING by_account`, [createHash("sha256").update(token).digest("hex"), channel, chars, new Date(this.now())]);
174
- if (!result.rows.length)
175
- throw new SpeechError("audio authorization expired or reached its limit; enable translated audio again", 401);
176
- return String(result.rows[0]?.["by_account"]);
177
- }
178
- const grant = this.grants.get(token);
179
- if (!grant || grant.expires <= this.now() || grant.channel !== channel)
180
- throw new SpeechError("sign in to renew translated audio", 401);
181
- if (grant.remaining < chars)
182
- throw new SpeechError("this audio authorization reached its character limit", 429);
183
- grant.remaining -= chars;
184
- return grant.by;
185
- }
186
- async ensure() {
187
- if (!this.db)
188
- return;
189
- this.schema ??= (async () => {
190
- await this.db.query("CREATE TABLE IF NOT EXISTS live_voice_usage (bucket TEXT PRIMARY KEY, chars BIGINT NOT NULL, expires_at TIMESTAMPTZ NOT NULL)");
191
- await this.db.query("CREATE TABLE IF NOT EXISTS live_voice_grants (token_hash TEXT PRIMARY KEY, by_account TEXT NOT NULL, channel TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, remaining INTEGER NOT NULL)");
192
- })().catch(error => { this.schema = null; throw error; });
193
- await this.schema;
194
- if (this.now() >= this.cleanupAt) {
195
- this.cleanupAt = this.now() + 60_000;
196
- // Keep only live windows; accounting survives restarts and is shared by replicas.
197
- await this.db.query("DELETE FROM live_voice_usage WHERE expires_at <= $1", [new Date(this.now())]);
198
- await this.db.query("DELETE FROM live_voice_grants WHERE expires_at <= $1", [new Date(this.now())]);
199
- }
200
- }
201
- async reserve(bucket, chars, limit, windowMs) {
202
- if (chars > limit)
203
- throw new SpeechError("translated audio reached its character budget; try again later", 429);
204
- const window = Math.floor(this.now() / windowMs);
205
- const key = `${bucket}:${windowMs}:${window}`;
206
- if (this.db) {
207
- await this.ensure();
208
- const result = await this.db.query(`INSERT INTO live_voice_usage (bucket, chars, expires_at) VALUES ($1, $2, $4)
209
- ON CONFLICT (bucket) DO UPDATE SET chars = live_voice_usage.chars + EXCLUDED.chars
210
- WHERE live_voice_usage.chars + EXCLUDED.chars <= $3 RETURNING chars`, [key, chars, limit, new Date((window + 1) * windowMs)]);
211
- if (!result.rows.length)
212
- throw new SpeechError("translated audio reached its character budget; try again later", 429);
213
- return;
214
- }
215
- // Local/single-process installations without a database. Account servers pass their pool.
216
- const record = this.usage.get(key) ?? { minute: (window + 1) * windowMs, chars: 0 };
217
- if (record.chars + chars > limit)
218
- throw new SpeechError("translated audio reached its character budget; try again later", 429);
219
- record.chars += chars;
220
- this.usage.set(key, record);
221
- }
222
- async charge(by, channel, chars) {
223
- for (const [key, record] of this.usage)
224
- if (record.minute <= this.now())
225
- this.usage.delete(key);
226
- await this.reserve(`user:${by}`, chars, this.charsPerMinute, 60_000);
227
- await this.reserve(`channel:${channel}`, chars, 6000, 60_000);
228
- await this.reserve("server", chars, 12_000, 60_000);
229
- await this.reserve(`user:${by}`, chars, this.userDailyChars, 86400_000);
230
- await this.reserve("server", chars, this.dailyChars, 86400_000);
231
- }
232
- checkRequest(by) {
233
- if (!this.requests.check(`audio:${by}`, { allowed: 60, windowMs: 60_000 }).ok)
234
- throw new SpeechError("too many translated audio requests", 429);
235
- }
236
- /** Stream the first request immediately; concurrent listeners share its cached result. */
237
- async stream(ask, by, signal, meter = this.billing) {
238
- this.checkRequest(by);
239
- await meter?.require(by, ask.channel);
240
- const text = typeof ask.text === "string" ? ask.text.trim() : "";
241
- if (!text || text.length > 600)
242
- throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
243
- if (!LIVE_VOICE_LANGUAGES.has(ask.language))
244
- throw new SpeechError("this language is not supported by Flash voices", 400);
245
- const voices = await this.voices();
246
- signal?.throwIfAborted();
247
- // The browser chooses unique voices for speakers. Legacy callers get a
248
- // stable stock voice; pitch is not used to infer gender.
249
- const seed = createHash("sha256").update(`${ask.channel ?? ""}|${ask.speaker ?? ""}`).digest().readUInt32BE(0);
250
- const voice = ask.voice && ask.voice !== "auto"
251
- ? voices.find(voice => voice.id === ask.voice)
252
- : voices[seed % voices.length];
253
- if (!voice)
254
- throw new SpeechError("choose an available voice", 400);
255
- const id = createHash("sha256").update(JSON.stringify([LIVE_VOICE_MODEL, voice.id, ask.language, text])).digest("hex");
256
- for (const [key, item] of this.cache)
257
- if (item.until < this.now())
258
- this.cache.delete(key);
259
- const cached = this.cache.get(id);
260
- const pending = this.pending.get(id);
261
- if (cached || pending) {
262
- const bytes = cached?.bytes ?? await pending;
263
- signal?.throwIfAborted();
264
- const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
265
- if (paid)
266
- await meter.commit(paid);
267
- return new Response(new Uint8Array(bytes), { headers: HEADERS });
268
- }
269
- if (this.pending.size >= 4)
270
- throw new SpeechError("translated audio is busy; waiting for the next caption", 429);
271
- if ((this.activeBy.get(by) ?? 0) >= 2)
272
- throw new SpeechError("two voice requests are already active for this account", 429);
273
- // Register before the first provider await, so identical requests cannot both bill.
274
- let done;
275
- let fail;
276
- const finished = new Promise((resolve, reject) => { done = resolve; fail = reject; });
277
- this.pending.set(id, finished);
278
- this.activeBy.set(by, (this.activeBy.get(by) ?? 0) + 1);
279
- const release = () => { this.pending.delete(id); this.activeBy.set(by, Math.max(0, (this.activeBy.get(by) ?? 1) - 1)); if (!this.activeBy.get(by))
280
- this.activeBy.delete(by); };
281
- void finished.catch(() => undefined);
282
- let reservation;
283
- let accepted = false;
284
- try {
285
- await this.charge(by, ask.channel ?? "direct", text.length);
286
- this.checkProvider();
287
- signal?.throwIfAborted();
288
- reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
289
- const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
290
- method: "POST",
291
- headers: { "xi-api-key": this.key, "content-type": "application/json" },
292
- body: JSON.stringify({ text, model_id: LIVE_VOICE_MODEL, language_code: ask.language }),
293
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
294
- });
295
- if (!response.ok)
296
- await this.providerFailed(response, "synthesis");
297
- if (!response.body)
298
- throw new SpeechError("ElevenLabs returned no audio", 502);
299
- // Once the provider accepts, aborting playback cannot refund heard audio.
300
- accepted = true;
301
- console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "synthesis", account: createHash("sha256").update(by).digest("hex").slice(0, 16), characters: text.length }));
302
- if (reservation)
303
- await meter.commit(reservation);
304
- const [play, keep] = response.body.tee();
305
- void (async () => {
306
- const reader = keep.getReader();
307
- const chunks = [];
308
- let size = 0;
309
- try {
310
- while (true) {
311
- const { done: ended, value } = await reader.read();
312
- if (ended)
313
- break;
314
- size += value.length;
315
- if (size > 2 * 1024 * 1024) {
316
- await reader.cancel();
317
- throw new SpeechError("voice response was too long", 502);
318
- }
319
- chunks.push(value);
320
- }
321
- if (!size || size % 2)
322
- throw new SpeechError("voice response contained incomplete audio", 502);
323
- const bytes = new Uint8Array(size);
324
- let at = 0;
325
- for (const chunk of chunks) {
326
- bytes.set(chunk, at);
327
- at += chunk.length;
328
- }
329
- this.cache.set(id, { until: this.now() + 60_000, bytes });
330
- // At most a few MB for recent lines, never a recording archive.
331
- while (this.cache.size > 32)
332
- this.cache.delete(this.cache.keys().next().value);
333
- done(bytes);
334
- }
335
- catch (error) {
336
- fail(error);
337
- }
338
- finally {
339
- reader.releaseLock();
340
- release();
341
- }
342
- })();
343
- return new Response(play, { headers: HEADERS });
344
- }
345
- catch (error) {
346
- fail(error);
347
- release();
348
- if (reservation && !accepted)
349
- await meter.refund(reservation);
350
- throw error;
351
- }
352
- }
1
+ /** ElevenLabs Flash/Scribe for live captions, from @profullstack/live-translate.
2
+ * nixamp keeps its `nxd_` grant tokens: captions.ts and older pages expect them. */
3
+ import { LiveVoice as Base } from "@profullstack/live-translate";
4
+ export { LIVE_VOICE_MODEL, LIVE_VOICE_RATE, LIVE_VOICE_LANGUAGES } from "@profullstack/live-translate";
5
+ export class LiveVoice extends Base {
6
+ constructor(options = {}) { super({ tokenPrefix: "nxd_", ...options }); }
353
7
  }
package/dist/owner.js CHANGED
@@ -116,6 +116,10 @@ export function needsAdmin(path, method = "GET") {
116
116
  // with a catalog entry, and the same question.
117
117
  if (/^\/api\/tracks\/\d+\/live$/.test(path))
118
118
  return true;
119
+ // And so is a folder: without this the gate never ran for it, and a
120
+ // listen link alone could put any folder on the air, uncapped.
121
+ if (path === "/api/folders/live")
122
+ return true;
119
123
  return false;
120
124
  }
121
125
  /**
package/dist/paywall.js CHANGED
@@ -134,7 +134,7 @@ export function paywallFromEnv(env = process.env) {
134
134
  return {
135
135
  enabled: env["NIXAMP_X402"] === "1",
136
136
  payTo: env["NIXAMP_PAY_TO"] ?? "",
137
- coinpayKey: env["COINPAY_X402_KEY"] ?? "",
137
+ coinpayKey: env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "",
138
138
  priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
139
139
  passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
140
140
  };
package/dist/pcm-wav.d.ts CHANGED
@@ -1,2 +1 @@
1
- /** 16-bit mono PCM WAV bytes from samples in [-1, 1]. */
2
- export declare function encodeWav(samples: Float32Array, rate?: number): Uint8Array;
1
+ export { encodeWav } from "@profullstack/live-translate/core";
package/dist/pcm-wav.js CHANGED
@@ -1,27 +1 @@
1
- /** 16-bit mono PCM WAV bytes from samples in [-1, 1]. */
2
- export function encodeWav(samples, rate = 16000) {
3
- const bytes = new Uint8Array(44 + samples.length * 2);
4
- const view = new DataView(bytes.buffer);
5
- const ascii = (at, text) => {
6
- for (let i = 0; i < text.length; i++)
7
- bytes[at + i] = text.charCodeAt(i);
8
- };
9
- ascii(0, "RIFF");
10
- view.setUint32(4, 36 + samples.length * 2, true);
11
- ascii(8, "WAVE");
12
- ascii(12, "fmt ");
13
- view.setUint32(16, 16, true);
14
- view.setUint16(20, 1, true);
15
- view.setUint16(22, 1, true);
16
- view.setUint32(24, rate, true);
17
- view.setUint32(28, rate * 2, true);
18
- view.setUint16(32, 2, true);
19
- view.setUint16(34, 16, true);
20
- ascii(36, "data");
21
- view.setUint32(40, samples.length * 2, true);
22
- for (let i = 0; i < samples.length; i++) {
23
- const clipped = Math.max(-1, Math.min(1, samples[i]));
24
- view.setInt16(44 + i * 2, clipped < 0 ? clipped * 32768 : clipped * 32767, true);
25
- }
26
- return bytes;
27
- }
1
+ export { encodeWav } from "@profullstack/live-translate/core";
package/dist/server.d.ts CHANGED
@@ -143,6 +143,20 @@ export interface ServeOptions {
143
143
  */
144
144
  rtmp: string[];
145
145
  }
146
+ /**
147
+ * Read DATABASE_URL the way libpq does.
148
+ *
149
+ * node-postgres reads `sslmode=require` as verify-full, so a cluster that
150
+ * signs its own certificate (dev2's shared Postgres) refuses every query with
151
+ * "self signed certificate" while the process itself stays up and healthy:
152
+ * pages serve, accounts and follows and events all fail. libpq, and every
153
+ * other client, read `require` as "encrypt, do not verify", and pg honours
154
+ * that reading when the URL carries `uselibpqcompat=true`. Railway's internal
155
+ * URL had no sslmode at all, which is why this never came up there. Only
156
+ * `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
157
+ * has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
158
+ */
159
+ export declare function libpqDatabaseUrl(raw: string | undefined): string | undefined;
146
160
  /**
147
161
  * Flags are parsed by hand: three of them do not justify a dependency, and the
148
162
  * failure mode of a wrong `--port` should be a message rather than NaN.
@@ -723,6 +737,8 @@ export interface HandlerOptions {
723
737
  speech?: Speech;
724
738
  liveVoice?: LiveVoice;
725
739
  translationPasses?: TranslationPasses;
740
+ /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
741
+ coinpayWebhookSecret?: string;
726
742
  sharedTranslations?: SharedTranslations;
727
743
  /** Translation: texts in another language, by a model here. The same optional library. */
728
744
  translator?: Translator;
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ import { Readable } from "node:stream";
20
20
  import { pipeline as pipeStream } from "node:stream/promises";
21
21
  import { relative } from "node:path";
22
22
  import { SharedTranslations } from "./shared-translation.js";
23
- import { TranslationPasses } from "./translation-passes.js";
23
+ import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature } from "./translation-passes.js";
24
24
  import { LiveVoice, LIVE_VOICE_LANGUAGES, LIVE_VOICE_MODEL } from "./live-voice.js";
25
25
  import { Connections } from "./connections.js";
26
26
  import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
@@ -106,6 +106,24 @@ import { emptySnapshot, parseCommand, } from "./protocol.js";
106
106
  const FFT_SIZE = 2048;
107
107
  export const SERVE_BAND_COUNT = 24;
108
108
  export const DEFAULT_PORT = 4321;
109
+ /**
110
+ * Read DATABASE_URL the way libpq does.
111
+ *
112
+ * node-postgres reads `sslmode=require` as verify-full, so a cluster that
113
+ * signs its own certificate (dev2's shared Postgres) refuses every query with
114
+ * "self signed certificate" while the process itself stays up and healthy:
115
+ * pages serve, accounts and follows and events all fail. libpq, and every
116
+ * other client, read `require` as "encrypt, do not verify", and pg honours
117
+ * that reading when the URL carries `uselibpqcompat=true`. Railway's internal
118
+ * URL had no sslmode at all, which is why this never came up there. Only
119
+ * `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
120
+ * has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
121
+ */
122
+ export function libpqDatabaseUrl(raw) {
123
+ if (!raw || !/[?&]sslmode=(require|prefer)\b/.test(raw) || /[?&]uselibpqcompat=/.test(raw))
124
+ return raw;
125
+ return `${raw}${raw.includes("?") ? "&" : "?"}uselibpqcompat=true`;
126
+ }
109
127
  /**
110
128
  * Flags are parsed by hand: three of them do not justify a dependency, and the
111
129
  * failure mode of a wrong `--port` should be a message rather than NaN.
@@ -1906,6 +1924,57 @@ export function createHandler(engine, options) {
1906
1924
  json(response, 404, { error: "no such endpoint" });
1907
1925
  return;
1908
1926
  }
1927
+ // --- CoinPay ----------------------------------------------------------
1928
+ //
1929
+ // Ahead of the share-key check because the caller is CoinPay, which signs
1930
+ // the body instead. The body is only a nudge: settle() asks CoinPay for the
1931
+ // payment itself before anything is credited.
1932
+ if (path === "/api/v1/webhooks/coinpay") {
1933
+ if (request.method !== "POST") {
1934
+ json(response, 405, { error: "POST only" });
1935
+ return;
1936
+ }
1937
+ const secret = options.coinpayWebhookSecret ?? "";
1938
+ if (!secret) {
1939
+ json(response, 503, { error: "CoinPay webhooks are not configured here." });
1940
+ return;
1941
+ }
1942
+ let raw;
1943
+ try {
1944
+ raw = await readBody(request);
1945
+ }
1946
+ catch {
1947
+ json(response, 413, { error: "body too large" });
1948
+ return;
1949
+ }
1950
+ const signature = request.headers["x-coinpay-signature"];
1951
+ if (!verifyCoinpaySignature(raw, Array.isArray(signature) ? signature[0] : signature, secret)) {
1952
+ json(response, 401, { error: "invalid signature" });
1953
+ return;
1954
+ }
1955
+ let event;
1956
+ try {
1957
+ event = readCoinpayWebhook(JSON.parse(raw));
1958
+ }
1959
+ catch {
1960
+ json(response, 400, { error: "invalid JSON" });
1961
+ return;
1962
+ }
1963
+ if (!options.translationPasses) {
1964
+ json(response, 200, { settled: false, reason: "billing is off here" });
1965
+ return;
1966
+ }
1967
+ try {
1968
+ json(response, 200, await options.translationPasses.settle(event.paymentId));
1969
+ }
1970
+ catch (error) {
1971
+ // A 5xx makes CoinPay deliver it again, which is what an unreachable
1972
+ // CoinPay or database needs.
1973
+ console.error(`nixamp: CoinPay webhook for ${event.paymentId} could not settle: ${error instanceof Error ? error.message : String(error)}`);
1974
+ json(response, 500, { error: "could not settle; retry" });
1975
+ }
1976
+ return;
1977
+ }
1909
1978
  // --- the party line ---------------------------------------------------
1910
1979
  //
1911
1980
  // Ahead of the share-key check because the caller is a telephone. Telnyx
@@ -5917,8 +5986,9 @@ export async function serve(argv, version = "0.1.0") {
5917
5986
  // pocket can administer it from anywhere by signing in as the same person.
5918
5987
  // Following outlives every stream, so unlike the rest of this it wants a
5919
5988
  // database. Only where there is one: a nixamp on a laptop has no followers.
5920
- const pool = options.directory && process.env["DATABASE_URL"]
5921
- ? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
5989
+ const databaseUrl = libpqDatabaseUrl(process.env["DATABASE_URL"]);
5990
+ const pool = options.directory && databaseUrl
5991
+ ? new pg.Pool({ connectionString: databaseUrl })
5922
5992
  : undefined;
5923
5993
  const follows = pool ? new Follows(pool) : undefined;
5924
5994
  const favorites = pool ? new Favorites(pool) : undefined;
@@ -5943,9 +6013,9 @@ export async function serve(argv, version = "0.1.0") {
5943
6013
  // the handler options because the authorization server issues its access
5944
6014
  // tokens out of the same table -- an OAuth token IS a nixamp token with a
5945
6015
  // client's name on it, which is why every existing route understands one.
5946
- const accounts = options.directory && process.env["DATABASE_URL"]
6016
+ const accounts = options.directory && databaseUrl
5947
6017
  ? new Accounts({
5948
- connectionString: process.env["DATABASE_URL"],
6018
+ connectionString: databaseUrl,
5949
6019
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
5950
6020
  })
5951
6021
  : undefined;
@@ -5956,7 +6026,7 @@ export async function serve(argv, version = "0.1.0") {
5956
6026
  // Account servers meter paid providers by default, including when checkout
5957
6027
  // is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
5958
6028
  const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
5959
- ? new TranslationPasses({ db: pool, key: process.env["COINPAY_X402_KEY"] ?? "", site: nixampSite }) : undefined;
6029
+ ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
5960
6030
  if (translationPasses)
5961
6031
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
5962
6032
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -6502,6 +6572,7 @@ export async function serve(argv, version = "0.1.0") {
6502
6572
  ...(speech ? { speech } : {}),
6503
6573
  ...(liveVoice ? { liveVoice } : {}),
6504
6574
  ...(translationPasses ? { translationPasses } : {}),
6575
+ ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
6505
6576
  ...(sharedTranslations ? { sharedTranslations } : {}),
6506
6577
  ...(translator ? { translator } : {}),
6507
6578
  ...(transcripts ? { transcripts } : {}),
@@ -1,56 +1,9 @@
1
- import { type Caption } from "./live-interpreter.ts";
2
- import { LiveVoice } from "./live-voice.ts";
3
- import type { Translator } from "./translate.ts";
4
- import type { TranslationMeter } from "./translation-passes.ts";
5
- export declare function publicStreamAddress(address: string): boolean;
1
+ /** Shared live translation, from @profullstack/live-translate, limited to nixamp channels. */
2
+ import { SharedTranslations as Base, type OpenLiveInput } from "@profullstack/live-translate";
3
+ export { publicStreamAddress, type LiveInput, type OpenLiveInput, type SharedEvent } from "@profullstack/live-translate";
4
+ /** Only a live nixamp channel (`/api/channels/<id>`, optionally `?k=`). */
6
5
  export declare function sharedSource(raw: string): URL;
7
- export interface LiveInput {
8
- stop(): void;
9
- }
10
- export type OpenLiveInput = (source: URL, pcm: (bytes: Buffer) => void, ended: () => void, signal: AbortSignal) => Promise<LiveInput>;
11
- /** Resolve once and pin that public address; no redirects or ffmpeg URL fetches. */
12
6
  export declare function liveInput(ffmpeg?: string[]): OpenLiveInput;
13
- export type SharedEvent = {
14
- type: "status";
15
- listeners: number;
16
- } | {
17
- type: "line";
18
- id: string;
19
- line: Caption;
20
- } | {
21
- type: "audio";
22
- id: string;
23
- data: string;
24
- } | {
25
- type: "end";
26
- id: string;
27
- } | {
28
- type: "error";
29
- error: string;
30
- retryable?: boolean;
31
- };
32
- type Member = {
33
- by: string;
34
- send: (event: SharedEvent) => void;
35
- close: () => void;
36
- };
37
- export declare class SharedTranslations {
38
- private readonly options;
39
- private readonly feeds;
40
- constructor(options: {
41
- voice: LiveVoice;
42
- translator: Pick<Translator, "translate">;
43
- billing?: TranslationMeter;
44
- open?: OpenLiveInput;
45
- });
46
- get size(): number;
47
- join(raw: string, language: string, by: string, send: Member["send"], close: () => void): Promise<() => void>;
48
- private leave;
49
- private checkMembers;
50
- private broadcast;
51
- private stop;
52
- private make;
53
- private start;
54
- private speak;
7
+ export declare class SharedTranslations extends Base {
8
+ constructor(options: ConstructorParameters<typeof Base>[0]);
55
9
  }
56
- export {};