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.
- package/README.md +8 -3
- package/dist/accounts.js +9 -1
- 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/paywall.js +1 -1
- package/dist/pcm-wav.d.ts +1 -2
- package/dist/pcm-wav.js +1 -27
- package/dist/server.d.ts +16 -0
- package/dist/server.js +77 -6
- 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/tickets.js +1 -1
- package/dist/translation-passes.d.ts +5 -96
- package/dist/translation-passes.js +5 -236
- 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 +4 -2
- package/src/accounts.ts +9 -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/paywall.ts +1 -1
- package/src/pcm-wav.ts +1 -26
- package/src/server.ts +57 -6
- package/src/shared-translation.ts +7 -222
- package/src/speaker-turns.ts +1 -34
- package/src/speech.ts +4 -5
- package/src/tickets.ts +1 -1
- package/src/translation-passes.ts +6 -239
- package/src/types/auth-system.d.ts +12 -3
- package/src/upgrade-allowance.ts +1 -72
- package/src/voice-profile.ts +1 -46
- package/src/voice-provider.ts +1 -22
- package/web/dist/.well-known/openwebring.json +6 -0
- package/web/dist/assets/{ar-CR2NEW77.js → ar-8fDonUoB.js} +1 -1
- package/web/dist/assets/{cs-ivXHTYBK.js → cs-DCClolTy.js} +1 -1
- package/web/dist/assets/{da-DQEGAEdk.js → da-C3RPhXUd.js} +1 -1
- package/web/dist/assets/{de-B5VaSv3O.js → de-CYBoEomk.js} +1 -1
- package/web/dist/assets/{es-DUTtfGnk.js → es-DS3ls4xT.js} +1 -1
- package/web/dist/assets/{fi-CzQZtpO5.js → fi-DbCZpO89.js} +1 -1
- package/web/dist/assets/{fr-C2g4DUBr.js → fr-D0z3UpdL.js} +1 -1
- package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-BTU3sRlQ.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-CQXTVHn_.js} +1 -1
- package/web/dist/assets/{hu--oH7WvAR.js → hu-B37Q8ldT.js} +1 -1
- package/web/dist/assets/{id-CjlpBIJc.js → id-4pdXNa4G.js} +1 -1
- package/web/dist/assets/index-BDBbN5Qy.js +22 -0
- package/web/dist/assets/{it-Dbctup0F.js → it-CTe69-UY.js} +1 -1
- package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-BBmkDppm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-CUVdjYFi.js} +1 -1
- package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CxrmHrMn.js} +1 -1
- package/web/dist/assets/{ru-DFGYCrj-.js → ru-zjVCfIKq.js} +1 -1
- package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CEoiT2Lr.js} +1 -1
- package/web/dist/assets/{uk-BTXHwsjV.js → uk-ZC6JzQvD.js} +1 -1
- package/web/dist/assets/{vi-DJ-vIPLS.js → vi-D9anF-cl.js} +1 -1
- package/web/dist/assets/{zh-D3Gju_Y2.js → zh-BeLYdC0I.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +23 -22
- package/web/dist/assets/index-CF04ablL.js +0 -3
package/src/live-interpreter.ts
CHANGED
|
@@ -1,213 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { encodeWav } from "./pcm-wav.ts";
|
|
3
|
-
import type { SpeakerTranscript, SpeakerTurn } from "./speaker-turns.ts";
|
|
4
|
-
export interface Caption { channel: string; at: number; until: number; text: string; language?: string; original?: string; sourceLanguage?: string; speaker?: string; voiceProfile?: "lower" | "higher" | "unknown"; }
|
|
5
|
-
export interface VoiceChoice { id: string; name: string; gender: string; language: string; }
|
|
6
|
-
export interface AudioWindow { samples: Float32Array; at: number; until: number; freshAt: number; }
|
|
7
|
-
|
|
8
|
-
export interface Speaker { id: string; profile: string; voice: string; }
|
|
9
|
-
/** Match provider labels across overlapping timestamps. A speaker absent from
|
|
10
|
-
* the rolling context gets a new label; pitch alone never identifies someone. */
|
|
11
|
-
export class SpeakerTracker {
|
|
12
|
-
private previous: { start: number; end: number; speaker: string }[] = [];
|
|
13
|
-
private sequence = 0;
|
|
14
|
-
readonly speakers = new Map<string, Speaker>();
|
|
15
|
-
constructor(private readonly random: () => number = Math.random) {}
|
|
16
|
-
reset(): void { this.previous = []; this.sequence = 0; this.speakers.clear(); }
|
|
17
|
-
|
|
18
|
-
reconcile(turns: SpeakerTurn[], at: number, voices: VoiceChoice[]): Map<string, Speaker> {
|
|
19
|
-
const links = new Map<string, Speaker>();
|
|
20
|
-
const used = new Set<string>();
|
|
21
|
-
const candidates: { local: string; global: string; overlap: number }[] = [];
|
|
22
|
-
const totals = new Map<string, number>();
|
|
23
|
-
for (const turn of turns) for (const old of this.previous) {
|
|
24
|
-
const overlap = Math.min(at + turn.end * 1000, old.end) - Math.max(at + turn.start * 1000, old.start);
|
|
25
|
-
if (overlap > 120) { const key = `${turn.speaker}|${old.speaker}`; totals.set(key, (totals.get(key) ?? 0) + overlap); }
|
|
26
|
-
}
|
|
27
|
-
for (const [key, overlap] of totals) { const [local, global] = key.split("|"); candidates.push({ local: local!, global: global!, overlap }); }
|
|
28
|
-
for (const one of candidates.sort((a, b) => b.overlap - a.overlap)) {
|
|
29
|
-
const speaker = this.speakers.get(one.global);
|
|
30
|
-
if (speaker && !links.has(one.local) && !used.has(one.global)) { links.set(one.local, speaker); used.add(one.global); }
|
|
31
|
-
}
|
|
32
|
-
for (const turn of turns) {
|
|
33
|
-
let speaker = links.get(turn.speaker);
|
|
34
|
-
if (!speaker) {
|
|
35
|
-
// Assign contrasting stock voices, without guessing a person's
|
|
36
|
-
// gender from pitch or a noisy, short opening phrase.
|
|
37
|
-
const taken = new Set([...this.speakers.values()].map(one => one.voice));
|
|
38
|
-
const available = voices.filter(voice => !taken.has(voice.id));
|
|
39
|
-
const pool = available.length ? available : voices;
|
|
40
|
-
const voice = pool[Math.floor(this.random() * pool.length)];
|
|
41
|
-
speaker = { id: `speaker-${++this.sequence}`, profile: turn.profile, voice: voice?.id ?? "auto" };
|
|
42
|
-
this.speakers.set(speaker.id, speaker); links.set(turn.speaker, speaker);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
this.previous = turns.map(turn => ({ start: at + turn.start * 1000, end: at + turn.end * 1000, speaker: links.get(turn.speaker)!.id }));
|
|
46
|
-
const present = new Set(this.previous.map(turn => turn.speaker));
|
|
47
|
-
for (const key of this.speakers.keys()) if (this.speakers.size > 64 && !present.has(key)) this.speakers.delete(key);
|
|
48
|
-
return links;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Listener-local interpretation for the playing media. One request in flight and only the latest pending audio window. */
|
|
53
|
-
export class Interpreter {
|
|
54
|
-
readonly tracker = new SpeakerTracker();
|
|
55
|
-
private generation = 0;
|
|
56
|
-
private pending: AudioWindow | null = null;
|
|
57
|
-
private committedUntil: number | null = null;
|
|
58
|
-
private running: number | null = null;
|
|
59
|
-
private controller: AbortController | null = null;
|
|
60
|
-
private translating: number | null = null;
|
|
61
|
-
private translationController: AbortController | null = null;
|
|
62
|
-
private recognitionFailures = 0;
|
|
63
|
-
private translationFailures = 0;
|
|
64
|
-
private translations: { lines: Caption[]; language: string; target: string; until: number }[] = [];
|
|
65
|
-
constructor(private readonly options: {
|
|
66
|
-
language: () => string; speakers: () => boolean; voices: () => VoiceChoice[];
|
|
67
|
-
channel: () => string; lines: (lines: Caption[]) => void;
|
|
68
|
-
status: (text: string) => void; failed: (message: string) => void;
|
|
69
|
-
fetcher?: typeof fetch;
|
|
70
|
-
}) {}
|
|
71
|
-
|
|
72
|
-
reset(): void {
|
|
73
|
-
this.generation++; this.controller?.abort(); this.translationController?.abort();
|
|
74
|
-
this.pending = null; this.running = null; this.translating = null;
|
|
75
|
-
this.recognitionFailures = this.translationFailures = 0;
|
|
76
|
-
this.translations = []; this.committedUntil = null; this.tracker.reset();
|
|
77
|
-
}
|
|
78
|
-
push(window: AudioWindow): void {
|
|
79
|
-
this.committedUntil ??= window.freshAt;
|
|
80
|
-
this.pending = window;
|
|
81
|
-
if (this.running === null) void this.run(this.generation);
|
|
82
|
-
}
|
|
83
|
-
private async json(path: string, init: RequestInit, signal: AbortSignal): Promise<any> {
|
|
84
|
-
const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
|
|
85
|
-
const body = await response.json();
|
|
86
|
-
if (!response.ok) throw audioError(body.error || "Audio translation is unavailable.", response.status);
|
|
87
|
-
return body;
|
|
88
|
-
}
|
|
89
|
-
private async run(generation: number): Promise<void> {
|
|
90
|
-
this.running = generation;
|
|
91
|
-
try {
|
|
92
|
-
while (this.pending && generation === this.generation) {
|
|
93
|
-
const window = this.pending; this.pending = null;
|
|
94
|
-
try {
|
|
95
|
-
if (Date.now() - window.until > 12_000) continue;
|
|
96
|
-
const controller = new AbortController(); this.controller = controller;
|
|
97
|
-
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
|
|
98
|
-
const speakers = this.options.speakers(), target = this.options.language();
|
|
99
|
-
const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
|
|
100
|
-
method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
|
|
101
|
-
}, signal) as SpeakerTranscript & { text?: string };
|
|
102
|
-
if (generation !== this.generation) return;
|
|
103
|
-
this.recognitionFailures = 0;
|
|
104
|
-
const language = heard.language;
|
|
105
|
-
let lines: Caption[] = [];
|
|
106
|
-
if (speakers) {
|
|
107
|
-
const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
|
|
108
|
-
// The watermark follows emitted words, not the newest capture
|
|
109
|
-
// interval: an overlapping window can recover audio that arrived
|
|
110
|
-
// while recognition/translation was busy. Keep unfinished phrases
|
|
111
|
-
// in that overlap so short capture intervals do not split every
|
|
112
|
-
// sentence (especially damaging when German changes word order).
|
|
113
|
-
const cutoff = this.committedUntil ?? window.freshAt;
|
|
114
|
-
const turns = heard.turns ?? [];
|
|
115
|
-
for (const [index, turn] of turns.entries()) {
|
|
116
|
-
const speaker = links.get(turn.speaker)!;
|
|
117
|
-
const words = turn.words.filter(word =>
|
|
118
|
-
window.at + (word.start + word.end) * 500 >= cutoff &&
|
|
119
|
-
(window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
|
|
120
|
-
let phrase: typeof words = [];
|
|
121
|
-
const emit = (): void => {
|
|
122
|
-
if (!phrase.length) return;
|
|
123
|
-
lines.push({ channel: this.options.channel(), at: window.at + phrase[0]!.start * 1000,
|
|
124
|
-
until: window.at + phrase.at(-1)!.end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
|
|
125
|
-
language, speaker: speaker.id, voiceProfile: turn.profile });
|
|
126
|
-
phrase = [];
|
|
127
|
-
};
|
|
128
|
-
for (const word of words) {
|
|
129
|
-
if (phrase.length && word.start - phrase.at(-1)!.end >= 0.45) emit();
|
|
130
|
-
phrase.push(word);
|
|
131
|
-
if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400) emit();
|
|
132
|
-
}
|
|
133
|
-
if (phrase.length && (index < turns.length - 1 ||
|
|
134
|
-
window.until - (window.at + phrase.at(-1)!.end * 1000) >= 350 ||
|
|
135
|
-
window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
|
|
136
|
-
}
|
|
137
|
-
} else if (heard.text) {
|
|
138
|
-
lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
|
|
139
|
-
}
|
|
140
|
-
if (!lines.length) continue;
|
|
141
|
-
this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
|
|
142
|
-
// Drop expired work instead of turning off an otherwise healthy stream.
|
|
143
|
-
this.translations = this.translations.filter(item => Date.now() - item.until <= 12_000).slice(-11);
|
|
144
|
-
this.translations.push({ lines, language, target, until: window.until });
|
|
145
|
-
// Recognition of the next audio window can proceed while the text
|
|
146
|
-
// model translates this phrase. Voice synthesis is a third stage.
|
|
147
|
-
if (this.translating === null) void this.translate(this.generation);
|
|
148
|
-
} catch (error) {
|
|
149
|
-
if (generation !== this.generation) return;
|
|
150
|
-
if (!transientAudioError(error) || ++this.recognitionFailures >= 3) throw error;
|
|
151
|
-
this.options.status("Speech recognition interrupted · listening for the next phrase…");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
|
|
156
|
-
} finally { if (this.running === generation) this.running = null; }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private async translate(generation: number): Promise<void> {
|
|
160
|
-
this.translating = generation;
|
|
161
|
-
try {
|
|
162
|
-
while (this.translations.length && generation === this.generation) {
|
|
163
|
-
const item = this.translations.shift()!;
|
|
164
|
-
try {
|
|
165
|
-
let { lines } = item;
|
|
166
|
-
const { language, target, until } = item;
|
|
167
|
-
if (Date.now() - until > 12_000) continue;
|
|
168
|
-
const controller = new AbortController(); this.translationController = controller;
|
|
169
|
-
if (target && language !== target) {
|
|
170
|
-
if (!language) throw audioError("The audio language could not be detected. Waiting for clearer speech.", 502);
|
|
171
|
-
const translated = await this.json("/api/v1/translate?live=1", {
|
|
172
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
173
|
-
body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
|
|
174
|
-
}, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)])) as { texts: string[] };
|
|
175
|
-
if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim())) throw audioError("Translation omitted a phrase. Waiting for the next phrase.", 502);
|
|
176
|
-
lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i]! }));
|
|
177
|
-
}
|
|
178
|
-
if (generation !== this.generation) return;
|
|
179
|
-
if (Date.now() - until > 12_000) continue;
|
|
180
|
-
this.translationFailures = 0;
|
|
181
|
-
this.options.lines(lines.flatMap(line => splitCaption(line)));
|
|
182
|
-
this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
if (generation !== this.generation) return;
|
|
185
|
-
if (!transientAudioError(error) || ++this.translationFailures >= 3) throw error;
|
|
186
|
-
this.options.status("Translation interrupted · catching up with live speech…");
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
} catch (error) {
|
|
190
|
-
if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
|
|
191
|
-
} finally { if (this.translating === generation) this.translating = null; }
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Preserve all words while respecting the speech API's character limit. */
|
|
196
|
-
export function splitCaption(line: Caption): Caption[] {
|
|
197
|
-
const text = line.text.trim();
|
|
198
|
-
if (!text) return [];
|
|
199
|
-
const parts: string[] = [];
|
|
200
|
-
let rest = text;
|
|
201
|
-
while (rest.length > 600) {
|
|
202
|
-
const space = rest.lastIndexOf(" ", 600);
|
|
203
|
-
const end = space > 300 ? space : 600;
|
|
204
|
-
parts.push(rest.slice(0, end)); rest = rest.slice(end).trimStart();
|
|
205
|
-
}
|
|
206
|
-
if (rest) parts.push(rest);
|
|
207
|
-
let offset = 0;
|
|
208
|
-
return parts.map(part => {
|
|
209
|
-
const at = line.at + (line.until - line.at) * offset / text.length;
|
|
210
|
-
offset += part.length + 1;
|
|
211
|
-
return { ...line, text: part, at, until: Math.min(line.until, line.at + (line.until - line.at) * offset / text.length) };
|
|
212
|
-
});
|
|
213
|
-
}
|
|
1
|
+
export { Interpreter, SpeakerTracker, splitCaption, type Caption, type VoiceChoice, type AudioWindow, type Speaker } from "@profullstack/live-translate/core";
|
package/src/live-recovery.ts
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* and transport failures may recover, with a bounded consecutive failure count. */
|
|
3
|
-
export function transientAudioError(error: unknown): boolean {
|
|
4
|
-
const status = (error as { status?: number } | null)?.status;
|
|
5
|
-
if (typeof status === "number") return [408, 502, 503, 504].includes(status);
|
|
6
|
-
return error instanceof TypeError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name));
|
|
7
|
-
}
|
|
8
|
-
export function audioError(message: string, status: number): Error & { status: number } {
|
|
9
|
-
return Object.assign(new Error(message), { status });
|
|
10
|
-
}
|
|
1
|
+
export { audioError, transientAudioError } from "@profullstack/live-translate/core";
|
package/src/live-voice.ts
CHANGED
|
@@ -1,306 +1,8 @@
|
|
|
1
|
-
/** ElevenLabs Flash for live captions
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import type { VoiceProfile } from "./voice-profile.ts";
|
|
6
|
-
import { Guard } from "./guard.ts";
|
|
7
|
-
import type { TranslationMeter } from "./translation-passes.ts";
|
|
8
|
-
import type { Queryable } from "./follows.ts";
|
|
9
|
-
import { voiceProviderFailure } from "./voice-provider.ts";
|
|
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, type LiveVoiceChoice, type VoiceRequest } from "@profullstack/live-translate";
|
|
10
5
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
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(" "));
|
|
14
|
-
export interface LiveVoiceChoice { id: string; name: string; gender: string; language: string; }
|
|
15
|
-
export interface VoiceRequest { text: string; language: string; voice?: string; profile?: VoiceProfile; channel?: string; speaker?: string; }
|
|
16
|
-
const HEADERS = { "content-type": "audio/pcm", "cache-control": "no-store", "x-audio-sample-rate": String(LIVE_VOICE_RATE) };
|
|
17
|
-
const budget = (value: number | undefined, fallback: number): number => Number.isFinite(value) && value! >= 0 ? Math.floor(value!) : fallback;
|
|
18
|
-
|
|
19
|
-
export class LiveVoice {
|
|
20
|
-
private readonly key: string;
|
|
21
|
-
private readonly fetcher: typeof fetch;
|
|
22
|
-
private readonly now: () => number;
|
|
23
|
-
private catalog: Promise<LiveVoiceChoice[]> | null = null;
|
|
24
|
-
private catalogUntil = 0;
|
|
25
|
-
private readonly cache = new Map<string, { until: number; bytes: Uint8Array }>();
|
|
26
|
-
private readonly pending = new Map<string, Promise<Uint8Array>>();
|
|
27
|
-
private readonly usage = new Map<string, { minute: number; chars: number }>();
|
|
28
|
-
private readonly charsPerMinute: number;
|
|
29
|
-
private readonly requests: Guard;
|
|
30
|
-
private readonly grants = new Map<string, { by: string; channel: string; expires: number; remaining: number }>();
|
|
31
|
-
private readonly activeBy = new Map<string, number>();
|
|
32
|
-
private readonly db?: Queryable;
|
|
33
|
-
private readonly billing?: TranslationMeter;
|
|
34
|
-
private schema: Promise<unknown> | null = null;
|
|
35
|
-
private readonly dailyChars: number;
|
|
36
|
-
private cleanupAt = 0;
|
|
37
|
-
private readonly hearing = new Set<string>();
|
|
38
|
-
private readonly dailyAudioSeconds: number;
|
|
39
|
-
private readonly userDailyChars: number;
|
|
40
|
-
private readonly userDailyAudioSeconds: number;
|
|
41
|
-
private providerPause: { until: number; error: SpeechError } | null = null;
|
|
42
|
-
|
|
43
|
-
private checkProvider(): void {
|
|
44
|
-
if (this.providerPause && this.providerPause.until > this.now()) throw this.providerPause.error;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private async providerFailed(response: Response, operation: string): Promise<never> {
|
|
48
|
-
const failure = await voiceProviderFailure(response);
|
|
49
|
-
const until = this.now() + failure.cooldownMs;
|
|
50
|
-
// Another in-flight response must not shorten an existing billing pause.
|
|
51
|
-
if (!this.providerPause || this.providerPause.until < until) this.providerPause = { until, error: failure.error };
|
|
52
|
-
console.error(JSON.stringify({ event: "voice_provider_failure", provider: "elevenlabs", operation, status: response.status, code: failure.code, cooldownMs: failure.cooldownMs }));
|
|
53
|
-
throw failure.error;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
constructor(options: { apiKey?: string; fetcher?: typeof fetch; now?: () => number; charsPerMinute?: number; dailyChars?: number; dailyAudioSeconds?: number; userDailyChars?: number; userDailyAudioSeconds?: number; db?: Queryable; billing?: TranslationMeter } = {}) {
|
|
57
|
-
this.key = options.apiKey ?? process.env["ELEVENLABS_API_KEY"] ?? "";
|
|
58
|
-
this.fetcher = options.fetcher ?? fetch;
|
|
59
|
-
this.now = options.now ?? Date.now;
|
|
60
|
-
this.charsPerMinute = budget(options.charsPerMinute, 3000);
|
|
61
|
-
this.dailyChars = budget(options.dailyChars, 200_000);
|
|
62
|
-
this.dailyAudioSeconds = budget(options.dailyAudioSeconds, 86_400);
|
|
63
|
-
this.userDailyChars = budget(options.userDailyChars, 120_000);
|
|
64
|
-
this.userDailyAudioSeconds = budget(options.userDailyAudioSeconds, 43_200);
|
|
65
|
-
this.requests = new Guard(this.now);
|
|
66
|
-
this.db = options.db;
|
|
67
|
-
this.billing = options.billing;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
available(): boolean { return this.key !== ""; }
|
|
71
|
-
|
|
72
|
-
/** Optional diarization, billed only while a signed-in listener requests it.
|
|
73
|
-
* Rolling audio is bounded to 15 seconds, including overlap. Every second
|
|
74
|
-
* submitted (also repeated context) consumes the persistent provider budget. */
|
|
75
|
-
async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
|
|
76
|
-
if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
|
|
77
|
-
await meter?.require(by, resource);
|
|
78
|
-
this.checkProvider();
|
|
79
|
-
const wav = decodeWav(bytes);
|
|
80
|
-
const seconds = wav.samples.length / wav.rate;
|
|
81
|
-
if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
|
|
82
|
-
if (wav.samples.some(sample => !Number.isFinite(sample))) throw new SpeechError("invalid audio samples", 400);
|
|
83
|
-
if (!this.requests.check(`hear:${by}`, { allowed: 36, windowMs: 60_000 }).ok) throw new SpeechError("too many speaker transcription requests", 429);
|
|
84
|
-
if (quietSamples(wav.samples)) return { language: "", seconds, turns: [] };
|
|
85
|
-
if (this.hearing.has(by) || this.hearing.size >= 4) throw new SpeechError("speaker transcription is busy", 429);
|
|
86
|
-
this.hearing.add(by);
|
|
87
|
-
let reservation: string | undefined;
|
|
88
|
-
let accepted = false;
|
|
89
|
-
try {
|
|
90
|
-
const billed = Math.ceil(seconds);
|
|
91
|
-
await this.reserve(`scribe:user:${by}`, billed, 300, 60_000);
|
|
92
|
-
await this.reserve("scribe:server", billed, 600, 60_000);
|
|
93
|
-
await this.reserve(`scribe:user:${by}`, billed, this.userDailyAudioSeconds, 86_400_000);
|
|
94
|
-
await this.reserve("scribe:server", billed, this.dailyAudioSeconds, 86_400_000);
|
|
95
|
-
signal?.throwIfAborted();
|
|
96
|
-
this.checkProvider();
|
|
97
|
-
const form = new FormData();
|
|
98
|
-
// Canonical PCM prevents a crafted container from billing more audio
|
|
99
|
-
// than the duration we validated, and removes uploaded metadata.
|
|
100
|
-
form.set("file", new Blob([new Uint8Array(encodeWav(wav.samples))], { type: "audio/wav" }), "listening.wav");
|
|
101
|
-
form.set("model_id", "scribe_v2");
|
|
102
|
-
form.set("diarize", "true");
|
|
103
|
-
form.set("tag_audio_events", "false");
|
|
104
|
-
form.set("timestamps_granularity", "word");
|
|
105
|
-
// No language_code: preserve the source language, including Spanish.
|
|
106
|
-
reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
|
|
107
|
-
const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
|
|
108
|
-
method: "POST", headers: { "xi-api-key": this.key }, body: form,
|
|
109
|
-
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
110
|
-
});
|
|
111
|
-
if (!answer.ok) await this.providerFailed(answer, "transcription");
|
|
112
|
-
accepted = true;
|
|
113
|
-
console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "transcription", account: createHash("sha256").update(by).digest("hex").slice(0, 16), audioSeconds: seconds }));
|
|
114
|
-
if (reservation) await meter!.commit(reservation);
|
|
115
|
-
return speakerTurns(await answer.json() as ScribeResult, wav);
|
|
116
|
-
} catch (error) {
|
|
117
|
-
if (reservation && !accepted) await meter!.refund(reservation);
|
|
118
|
-
throw error;
|
|
119
|
-
} finally { this.hearing.delete(by); }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async voices(): Promise<LiveVoiceChoice[]> {
|
|
123
|
-
if (!this.available()) throw new SpeechError("translated audio needs ELEVENLABS_API_KEY on the account server", 503);
|
|
124
|
-
this.checkProvider();
|
|
125
|
-
if (!this.catalog || this.now() >= this.catalogUntil) {
|
|
126
|
-
this.catalogUntil = this.now() + 3600_000;
|
|
127
|
-
this.catalog = this.fetcher("https://api.elevenlabs.io/v2/voices?page_size=100&voice_type=default", {
|
|
128
|
-
headers: { "xi-api-key": this.key }, signal: AbortSignal.timeout(8000),
|
|
129
|
-
}).then(async response => {
|
|
130
|
-
if (!response.ok) await this.providerFailed(response, "voices");
|
|
131
|
-
const body = await response.json() as { voices: { voice_id: string; name: string; labels?: Record<string, string> }[] };
|
|
132
|
-
return body.voices.filter(voice => /^[a-zA-Z0-9_-]+$/.test(voice.voice_id)).map(voice => ({
|
|
133
|
-
id: voice.voice_id, name: voice.name, gender: voice.labels?.["gender"] ?? "neutral", language: voice.labels?.["language"] ?? "",
|
|
134
|
-
}));
|
|
135
|
-
}).catch(error => { this.catalog = null; throw error; });
|
|
136
|
-
}
|
|
137
|
-
return this.catalog;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** A 90-second capability for one channel, never the viewer's account credential. */
|
|
141
|
-
async grant(by: string, channel: string): Promise<{ token: string; expires: number }> {
|
|
142
|
-
if (!/^[\w-]{1,80}$/.test(channel)) throw new SpeechError("choose a playback session or live channel", 400);
|
|
143
|
-
if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok) throw new SpeechError("too many audio authorization requests", 429);
|
|
144
|
-
await this.billing?.require(by, channel);
|
|
145
|
-
for (const [token, grant] of this.grants) if (grant.expires <= this.now()) this.grants.delete(token);
|
|
146
|
-
if (this.grants.size >= 5000) throw new SpeechError("audio authorization is busy", 503);
|
|
147
|
-
const token = `nxd_${randomBytes(32).toString("base64url")}`;
|
|
148
|
-
const expires = this.now() + 90_000;
|
|
149
|
-
if (this.db) {
|
|
150
|
-
await this.ensure();
|
|
151
|
-
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)]);
|
|
152
|
-
} else this.grants.set(token, { by, channel, expires, remaining: 2000 });
|
|
153
|
-
return { token, expires };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async authorize(token: string, channel: string, chars: number): Promise<string> {
|
|
157
|
-
if (!/^nxd_[A-Za-z0-9_-]{43}$/.test(token)) throw new SpeechError("sign in to enable translated audio", 401);
|
|
158
|
-
if (!Number.isFinite(chars) || chars < 1 || chars > 600) throw new SpeechError("invalid caption length", 400);
|
|
159
|
-
if (this.db) {
|
|
160
|
-
await this.ensure();
|
|
161
|
-
const result = await this.db.query(`UPDATE live_voice_grants SET remaining = remaining - $3
|
|
162
|
-
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())]);
|
|
163
|
-
if (!result.rows.length) throw new SpeechError("audio authorization expired or reached its limit; enable translated audio again", 401);
|
|
164
|
-
return String(result.rows[0]?.["by_account"]);
|
|
165
|
-
}
|
|
166
|
-
const grant = this.grants.get(token);
|
|
167
|
-
if (!grant || grant.expires <= this.now() || grant.channel !== channel) throw new SpeechError("sign in to renew translated audio", 401);
|
|
168
|
-
if (grant.remaining < chars) throw new SpeechError("this audio authorization reached its character limit", 429);
|
|
169
|
-
grant.remaining -= chars;
|
|
170
|
-
return grant.by;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private async ensure(): Promise<void> {
|
|
174
|
-
if (!this.db) return;
|
|
175
|
-
this.schema ??= (async () => {
|
|
176
|
-
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)");
|
|
177
|
-
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)");
|
|
178
|
-
})().catch(error => { this.schema = null; throw error; });
|
|
179
|
-
await this.schema;
|
|
180
|
-
if (this.now() >= this.cleanupAt) {
|
|
181
|
-
this.cleanupAt = this.now() + 60_000;
|
|
182
|
-
// Keep only live windows; accounting survives restarts and is shared by replicas.
|
|
183
|
-
await this.db.query("DELETE FROM live_voice_usage WHERE expires_at <= $1", [new Date(this.now())]);
|
|
184
|
-
await this.db.query("DELETE FROM live_voice_grants WHERE expires_at <= $1", [new Date(this.now())]);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private async reserve(bucket: string, chars: number, limit: number, windowMs: number): Promise<void> {
|
|
189
|
-
if (chars > limit) throw new SpeechError("translated audio reached its character budget; try again later", 429);
|
|
190
|
-
const window = Math.floor(this.now() / windowMs);
|
|
191
|
-
const key = `${bucket}:${windowMs}:${window}`;
|
|
192
|
-
if (this.db) {
|
|
193
|
-
await this.ensure();
|
|
194
|
-
const result = await this.db.query(`INSERT INTO live_voice_usage (bucket, chars, expires_at) VALUES ($1, $2, $4)
|
|
195
|
-
ON CONFLICT (bucket) DO UPDATE SET chars = live_voice_usage.chars + EXCLUDED.chars
|
|
196
|
-
WHERE live_voice_usage.chars + EXCLUDED.chars <= $3 RETURNING chars`, [key, chars, limit, new Date((window + 1) * windowMs)]);
|
|
197
|
-
if (!result.rows.length) throw new SpeechError("translated audio reached its character budget; try again later", 429);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
// Local/single-process installations without a database. Account servers pass their pool.
|
|
201
|
-
const record = this.usage.get(key) ?? { minute: (window + 1) * windowMs, chars: 0 };
|
|
202
|
-
if (record.chars + chars > limit) throw new SpeechError("translated audio reached its character budget; try again later", 429);
|
|
203
|
-
record.chars += chars; this.usage.set(key, record);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private async charge(by: string, channel: string, chars: number): Promise<void> {
|
|
207
|
-
for (const [key, record] of this.usage) if (record.minute <= this.now()) this.usage.delete(key);
|
|
208
|
-
await this.reserve(`user:${by}`, chars, this.charsPerMinute, 60_000);
|
|
209
|
-
await this.reserve(`channel:${channel}`, chars, 6000, 60_000);
|
|
210
|
-
await this.reserve("server", chars, 12_000, 60_000);
|
|
211
|
-
await this.reserve(`user:${by}`, chars, this.userDailyChars, 86400_000);
|
|
212
|
-
await this.reserve("server", chars, this.dailyChars, 86400_000);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private checkRequest(by: string): void {
|
|
216
|
-
if (!this.requests.check(`audio:${by}`, { allowed: 60, windowMs: 60_000 }).ok) throw new SpeechError("too many translated audio requests", 429);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Stream the first request immediately; concurrent listeners share its cached result. */
|
|
220
|
-
async stream(ask: VoiceRequest, by: string, signal?: AbortSignal, meter = this.billing): Promise<Response> {
|
|
221
|
-
this.checkRequest(by);
|
|
222
|
-
await meter?.require(by, ask.channel);
|
|
223
|
-
const text = typeof ask.text === "string" ? ask.text.trim() : "";
|
|
224
|
-
if (!text || text.length > 600) throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
|
|
225
|
-
if (!LIVE_VOICE_LANGUAGES.has(ask.language)) throw new SpeechError("this language is not supported by Flash voices", 400);
|
|
226
|
-
const voices = await this.voices();
|
|
227
|
-
signal?.throwIfAborted();
|
|
228
|
-
// The browser chooses unique voices for speakers. Legacy callers get a
|
|
229
|
-
// stable stock voice; pitch is not used to infer gender.
|
|
230
|
-
const seed = createHash("sha256").update(`${ask.channel ?? ""}|${ask.speaker ?? ""}`).digest().readUInt32BE(0);
|
|
231
|
-
const voice = ask.voice && ask.voice !== "auto"
|
|
232
|
-
? voices.find(voice => voice.id === ask.voice)
|
|
233
|
-
: voices[seed % voices.length];
|
|
234
|
-
if (!voice) throw new SpeechError("choose an available voice", 400);
|
|
235
|
-
const id = createHash("sha256").update(JSON.stringify([LIVE_VOICE_MODEL, voice.id, ask.language, text])).digest("hex");
|
|
236
|
-
for (const [key, item] of this.cache) if (item.until < this.now()) this.cache.delete(key);
|
|
237
|
-
const cached = this.cache.get(id);
|
|
238
|
-
const pending = this.pending.get(id);
|
|
239
|
-
if (cached || pending) {
|
|
240
|
-
const bytes = cached?.bytes ?? await pending!;
|
|
241
|
-
signal?.throwIfAborted();
|
|
242
|
-
const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
243
|
-
if (paid) await meter!.commit(paid);
|
|
244
|
-
return new Response(new Uint8Array(bytes), { headers: HEADERS });
|
|
245
|
-
}
|
|
246
|
-
if (this.pending.size >= 4) throw new SpeechError("translated audio is busy; waiting for the next caption", 429);
|
|
247
|
-
if ((this.activeBy.get(by) ?? 0) >= 2) throw new SpeechError("two voice requests are already active for this account", 429);
|
|
248
|
-
// Register before the first provider await, so identical requests cannot both bill.
|
|
249
|
-
let done!: (bytes: Uint8Array) => void;
|
|
250
|
-
let fail!: (error: unknown) => void;
|
|
251
|
-
const finished = new Promise<Uint8Array>((resolve, reject) => { done = resolve; fail = reject; });
|
|
252
|
-
this.pending.set(id, finished);
|
|
253
|
-
this.activeBy.set(by, (this.activeBy.get(by) ?? 0) + 1);
|
|
254
|
-
const release = (): void => { this.pending.delete(id); this.activeBy.set(by, Math.max(0, (this.activeBy.get(by) ?? 1) - 1)); if (!this.activeBy.get(by)) this.activeBy.delete(by); };
|
|
255
|
-
void finished.catch(() => undefined);
|
|
256
|
-
let reservation: string | undefined;
|
|
257
|
-
let accepted = false;
|
|
258
|
-
try {
|
|
259
|
-
await this.charge(by, ask.channel ?? "direct", text.length);
|
|
260
|
-
this.checkProvider();
|
|
261
|
-
signal?.throwIfAborted();
|
|
262
|
-
reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
263
|
-
const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
|
|
264
|
-
method: "POST",
|
|
265
|
-
headers: { "xi-api-key": this.key, "content-type": "application/json" },
|
|
266
|
-
body: JSON.stringify({ text, model_id: LIVE_VOICE_MODEL, language_code: ask.language }),
|
|
267
|
-
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
268
|
-
});
|
|
269
|
-
if (!response.ok) await this.providerFailed(response, "synthesis");
|
|
270
|
-
if (!response.body) throw new SpeechError("ElevenLabs returned no audio", 502);
|
|
271
|
-
// Once the provider accepts, aborting playback cannot refund heard audio.
|
|
272
|
-
accepted = true;
|
|
273
|
-
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 }));
|
|
274
|
-
if (reservation) await meter!.commit(reservation);
|
|
275
|
-
const [play, keep] = response.body.tee();
|
|
276
|
-
void (async () => {
|
|
277
|
-
const reader = keep.getReader();
|
|
278
|
-
const chunks: Uint8Array[] = [];
|
|
279
|
-
let size = 0;
|
|
280
|
-
try {
|
|
281
|
-
while (true) {
|
|
282
|
-
const { done: ended, value } = await reader.read();
|
|
283
|
-
if (ended) break;
|
|
284
|
-
size += value.length;
|
|
285
|
-
if (size > 2 * 1024 * 1024) { await reader.cancel(); throw new SpeechError("voice response was too long", 502); }
|
|
286
|
-
chunks.push(value);
|
|
287
|
-
}
|
|
288
|
-
if (!size || size % 2) throw new SpeechError("voice response contained incomplete audio", 502);
|
|
289
|
-
const bytes = new Uint8Array(size);
|
|
290
|
-
let at = 0;
|
|
291
|
-
for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
|
|
292
|
-
this.cache.set(id, { until: this.now() + 60_000, bytes });
|
|
293
|
-
// At most a few MB for recent lines, never a recording archive.
|
|
294
|
-
while (this.cache.size > 32) this.cache.delete(this.cache.keys().next().value as string);
|
|
295
|
-
done(bytes);
|
|
296
|
-
} catch (error) { fail(error); }
|
|
297
|
-
finally { reader.releaseLock(); release(); }
|
|
298
|
-
})();
|
|
299
|
-
return new Response(play, { headers: HEADERS });
|
|
300
|
-
} catch (error) {
|
|
301
|
-
fail(error); release();
|
|
302
|
-
if (reservation && !accepted) await meter!.refund(reservation);
|
|
303
|
-
throw error;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
6
|
+
export class LiveVoice extends Base {
|
|
7
|
+
constructor(options: ConstructorParameters<typeof Base>[0] = {}) { super({ tokenPrefix: "nxd_", ...options }); }
|
|
306
8
|
}
|
package/src/owner.ts
CHANGED
|
@@ -130,6 +130,9 @@ export function needsAdmin(path: string, method = "GET"): boolean {
|
|
|
130
130
|
// Going live with a file on this server is the same act as going live
|
|
131
131
|
// with a catalog entry, and the same question.
|
|
132
132
|
if (/^\/api\/tracks\/\d+\/live$/.test(path)) return true;
|
|
133
|
+
// And so is a folder: without this the gate never ran for it, and a
|
|
134
|
+
// listen link alone could put any folder on the air, uncapped.
|
|
135
|
+
if (path === "/api/folders/live") return true;
|
|
133
136
|
return false;
|
|
134
137
|
}
|
|
135
138
|
|
package/src/paywall.ts
CHANGED
|
@@ -168,7 +168,7 @@ export function paywallFromEnv(env: NodeJS.ProcessEnv = process.env): PaywallCon
|
|
|
168
168
|
return {
|
|
169
169
|
enabled: env["NIXAMP_X402"] === "1",
|
|
170
170
|
payTo: env["NIXAMP_PAY_TO"] ?? "",
|
|
171
|
-
coinpayKey: env["COINPAY_X402_KEY"]
|
|
171
|
+
coinpayKey: env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "",
|
|
172
172
|
priceCents: Number.isFinite(price) && price > 0 ? Math.floor(price) : DEFAULT_PAYWALL.priceCents,
|
|
173
173
|
passMinutes:
|
|
174
174
|
Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : DEFAULT_PAYWALL.passMinutes,
|
package/src/pcm-wav.ts
CHANGED
|
@@ -1,26 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export function encodeWav(samples: Float32Array, rate = 16000): Uint8Array {
|
|
3
|
-
const bytes = new Uint8Array(44 + samples.length * 2);
|
|
4
|
-
const view = new DataView(bytes.buffer);
|
|
5
|
-
const ascii = (at: number, text: string): void => {
|
|
6
|
-
for (let i = 0; i < text.length; i++) bytes[at + i] = text.charCodeAt(i);
|
|
7
|
-
};
|
|
8
|
-
ascii(0, "RIFF");
|
|
9
|
-
view.setUint32(4, 36 + samples.length * 2, true);
|
|
10
|
-
ascii(8, "WAVE");
|
|
11
|
-
ascii(12, "fmt ");
|
|
12
|
-
view.setUint32(16, 16, true);
|
|
13
|
-
view.setUint16(20, 1, true);
|
|
14
|
-
view.setUint16(22, 1, true);
|
|
15
|
-
view.setUint32(24, rate, true);
|
|
16
|
-
view.setUint32(28, rate * 2, true);
|
|
17
|
-
view.setUint16(32, 2, true);
|
|
18
|
-
view.setUint16(34, 16, true);
|
|
19
|
-
ascii(36, "data");
|
|
20
|
-
view.setUint32(40, samples.length * 2, true);
|
|
21
|
-
for (let i = 0; i < samples.length; i++) {
|
|
22
|
-
const clipped = Math.max(-1, Math.min(1, samples[i] as number));
|
|
23
|
-
view.setInt16(44 + i * 2, clipped < 0 ? clipped * 32768 : clipped * 32767, true);
|
|
24
|
-
}
|
|
25
|
-
return bytes;
|
|
26
|
-
}
|
|
1
|
+
export { encodeWav } from "@profullstack/live-translate/core";
|