nixamp 0.28.44 → 0.29.1

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 (89) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +16 -3
  4. package/dist/decoder-policy.d.ts +20 -0
  5. package/dist/decoder-policy.js +34 -0
  6. package/dist/destinations.d.ts +5 -0
  7. package/dist/destinations.js +82 -0
  8. package/dist/library.d.ts +2 -0
  9. package/dist/library.js +4 -1
  10. package/dist/links.d.ts +4 -0
  11. package/dist/links.js +20 -2
  12. package/dist/live-interpreter.d.ts +1 -72
  13. package/dist/live-interpreter.js +1 -269
  14. package/dist/live-recovery.d.ts +1 -6
  15. package/dist/live-recovery.js +1 -11
  16. package/dist/live-voice.d.ts +6 -76
  17. package/dist/live-voice.js +6 -352
  18. package/dist/owner.js +4 -0
  19. package/dist/pcm-wav.d.ts +1 -2
  20. package/dist/pcm-wav.js +1 -27
  21. package/dist/playlist-input.d.ts +46 -0
  22. package/dist/playlist-input.js +158 -54
  23. package/dist/publish-choice.d.ts +21 -0
  24. package/dist/publish-choice.js +17 -0
  25. package/dist/server.js +66 -11
  26. package/dist/share.d.ts +1 -1
  27. package/dist/share.js +5 -4
  28. package/dist/shared-translation.d.ts +6 -53
  29. package/dist/shared-translation.js +7 -336
  30. package/dist/speaker-turns.d.ts +1 -32
  31. package/dist/speaker-turns.js +1 -30
  32. package/dist/speech.d.ts +3 -5
  33. package/dist/speech.js +4 -7
  34. package/dist/translation-passes.d.ts +5 -125
  35. package/dist/translation-passes.js +5 -294
  36. package/dist/upgrade-allowance.d.ts +1 -23
  37. package/dist/upgrade-allowance.js +1 -73
  38. package/dist/voice-profile.d.ts +1 -7
  39. package/dist/voice-profile.js +1 -53
  40. package/dist/voice-provider.d.ts +1 -9
  41. package/dist/voice-provider.js +1 -27
  42. package/package.json +2 -1
  43. package/src/audio.ts +3 -0
  44. package/src/channels.ts +26 -4
  45. package/src/decoder-policy.ts +33 -0
  46. package/src/destinations.ts +78 -0
  47. package/src/library.ts +6 -1
  48. package/src/links.ts +23 -4
  49. package/src/live-interpreter.ts +1 -213
  50. package/src/live-recovery.ts +1 -10
  51. package/src/live-voice.ts +6 -304
  52. package/src/owner.ts +3 -0
  53. package/src/pcm-wav.ts +1 -26
  54. package/src/playlist-input.ts +172 -50
  55. package/src/publish-choice.ts +35 -0
  56. package/src/server.ts +58 -11
  57. package/src/share.ts +5 -4
  58. package/src/shared-translation.ts +7 -222
  59. package/src/speaker-turns.ts +1 -34
  60. package/src/speech.ts +4 -5
  61. package/src/translation-passes.ts +6 -292
  62. package/src/upgrade-allowance.ts +1 -72
  63. package/src/voice-profile.ts +1 -46
  64. package/src/voice-provider.ts +1 -22
  65. package/web/dist/assets/{ar-CLNm1oA7.js → ar-D6_ZFDan.js} +1 -1
  66. package/web/dist/assets/{cs-Cptn8RbB.js → cs-DEPGOGEV.js} +1 -1
  67. package/web/dist/assets/{da-CRbNCFOu.js → da-CQw7SPC9.js} +1 -1
  68. package/web/dist/assets/{de-D4DnC5NQ.js → de-OWnobbah.js} +1 -1
  69. package/web/dist/assets/{es-DAHnD9bs.js → es-qMpyH9bM.js} +1 -1
  70. package/web/dist/assets/{fi-Csbl41Vz.js → fi-DpqqwJTD.js} +1 -1
  71. package/web/dist/assets/{fr-jZYQJed8.js → fr-BiO_ceen.js} +1 -1
  72. package/web/dist/assets/{hi-DjT9u0v_.js → hi-D-q5wZuY.js} +1 -1
  73. package/web/dist/assets/{hls-3VKVEQE3-BjAVnvGY.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
  74. package/web/dist/assets/{hu-CVrU8gC4.js → hu-FIjHyhVC.js} +1 -1
  75. package/web/dist/assets/{id-B_gN24X5.js → id-gmB6lu56.js} +1 -1
  76. package/web/dist/assets/{index-BueBHHcv.css → index-Bo3utcDw.css} +1 -1
  77. package/web/dist/assets/index-UmGvzuun.js +22 -0
  78. package/web/dist/assets/{it-BUC1dMpV.js → it-Dxfo713A.js} +1 -1
  79. package/web/dist/assets/{mpegts-CnqmXTu2.js → mpegts-BT4XGr_E.js} +1 -1
  80. package/web/dist/assets/{mpegts-LO6RVLD6-B4QgG3Xb.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
  81. package/web/dist/assets/{nl-CGi-aIrz.js → nl-mk5eN3Io.js} +1 -1
  82. package/web/dist/assets/{ru-YIbS63cK.js → ru-B2psU0kN.js} +1 -1
  83. package/web/dist/assets/{sv-CuJUpOCk.js → sv-Cg6waoNZ.js} +1 -1
  84. package/web/dist/assets/{uk-DLfG8YhV.js → uk-BcFkukLd.js} +1 -1
  85. package/web/dist/assets/{vi-nv2siY2T.js → vi-CnqIC1Sz.js} +1 -1
  86. package/web/dist/assets/{zh-P5x8w1OW.js → zh-CvnXIFvN.js} +1 -1
  87. package/web/dist/index.html +7 -12
  88. package/web/dist/sw.js +23 -23
  89. package/web/dist/assets/index-c3uxZS8_.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/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";
@@ -7,7 +7,41 @@ export interface PlaylistInput {
7
7
  video: boolean;
8
8
  width?: number;
9
9
  height?: number;
10
+ /**
11
+ * Back to the top when the end is reached, instead of finishing: a
12
+ * station, which plays until somebody stops it.
13
+ */
14
+ repeat?: boolean;
15
+ /**
16
+ * The order to play a list of this length in, asked at the top of every
17
+ * pass: a station-wide shuffle is one order for everybody listening, drawn
18
+ * again each time round. Unset is the list's own order.
19
+ */
20
+ order?: (length: number) => number[];
21
+ /** Read at every entry boundary, so an edit to the list lands on the next track. */
22
+ current?: () => string[];
23
+ /** Entries already known not to play, passed over without being tried. */
24
+ excluded?: (file: string) => boolean;
25
+ /**
26
+ * Pass over an entry that will not play, instead of failing the stream.
27
+ * For a station, where the next track is what a listener should hear. A
28
+ * folder live leaves it unset: there a missing entry is usually a file
29
+ * still arriving, and the channel redials and waits for it.
30
+ */
31
+ passOver?: boolean;
32
+ /** Told when an entry is passed over because it would not play. */
33
+ onFailure?: (event: {
34
+ at: number;
35
+ file: string;
36
+ reason: string;
37
+ }) => void;
10
38
  }
39
+ /**
40
+ * How many entries in a row may fail before the list is given up on. A
41
+ * corrupt file is skipped; a list where nothing plays is an error, said once,
42
+ * not a loop.
43
+ */
44
+ export declare const MAX_FAILURES_IN_A_ROW = 5;
11
45
  /** Decode each file independently; feed one persistent muxer a fixed stream
12
46
  * profile and continuous timestamps. A concat demuxer cannot decode a list
13
47
  * whose input codecs differ. Only one entry is encoded at a time, with pipe
@@ -18,9 +52,21 @@ export declare class PlaylistInputStream {
18
52
  error: Error | null;
19
53
  complete: boolean;
20
54
  constructor(options: PlaylistInput);
55
+ /** Every entry that has started, in the order it started, with where it sits in the stream. */
56
+ get history(): ReadonlyArray<{
57
+ at: number;
58
+ file: string;
59
+ start: number;
60
+ position: number;
61
+ }>;
21
62
  place(seconds: number): {
22
63
  at: number;
23
64
  position: number;
24
65
  };
66
+ /** The decoder for the entry that is on, so skip() can end it early. */
67
+ private stopCurrent;
68
+ private skipping;
69
+ /** End the entry that is on and go to the next. False when nothing is on. */
70
+ skip(): boolean;
25
71
  open(signal: AbortSignal): AsyncGenerator<Buffer>;
26
72
  }