nixamp 0.65.0 → 0.66.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.js +18 -4
- package/dist/discovery.d.ts +6 -3
- package/dist/discovery.js +17 -10
- package/dist/owner.d.ts +2 -0
- package/dist/owner.js +4 -0
- package/dist/server.js +73 -17
- package/dist/translate-jobs.d.ts +5 -2
- package/dist/translate-jobs.js +11 -4
- package/dist/translation-passes.d.ts +55 -3
- package/dist/translation-passes.js +138 -3
- package/package.json +4 -4
- package/src/ai-label.ts +14 -4
- package/src/discovery.ts +16 -9
- package/src/owner.ts +5 -0
- package/src/server.ts +67 -18
- package/src/translate-jobs.ts +13 -4
- package/src/translation-passes.ts +155 -3
- package/web/dist/assets/{ar-T_s9IpDl.js → ar-BQfRAPxT.js} +1 -1
- package/web/dist/assets/{cs-u6VmDGRj.js → cs-CIlyIUyM.js} +1 -1
- package/web/dist/assets/{da-CtKMj9yA.js → da-B73sEH_l.js} +1 -1
- package/web/dist/assets/{de-DUIp6cPq.js → de-CR_qS1kD.js} +1 -1
- package/web/dist/assets/{es-CDKeGB_J.js → es-CWcxhMQ1.js} +1 -1
- package/web/dist/assets/{fi-Bm5mnEJW.js → fi-sw-rgeRG.js} +1 -1
- package/web/dist/assets/{fr-C-wTfG2I.js → fr-DWwCpFG-.js} +1 -1
- package/web/dist/assets/{hi-Cj5DBYQn.js → hi-BDa2YD-x.js} +1 -1
- package/web/dist/assets/{hu-l11hhWja.js → hu-CsYXXp0t.js} +1 -1
- package/web/dist/assets/{id-Y_NtfdKr.js → id-CzrnSbAL.js} +1 -1
- package/web/dist/assets/{it-DPsBnd04.js → it-DT6eQX_8.js} +1 -1
- package/web/dist/assets/{main-wXq7DAXz.js → main-B-WZ8VbE.js} +2 -2
- package/web/dist/assets/{nl-BJnRKhqE.js → nl-C57z9Sux.js} +1 -1
- package/web/dist/assets/{ru-CLRFRTHU.js → ru-CW9If0BU.js} +1 -1
- package/web/dist/assets/{sv-BarkkLkN.js → sv-CiW_iXWQ.js} +1 -1
- package/web/dist/assets/{uk-DlOj9CHi.js → uk-D_agLCru.js} +1 -1
- package/web/dist/assets/{vi-DqXccGG3.js → vi-C4wrS-qx.js} +1 -1
- package/web/dist/assets/{zh-CfnJPLyy.js → zh-TOIwUGfV.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.3",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@profullstack/live-translate": "^0.1.0",
|
|
51
51
|
"@profullstack/notifications": "^0.1.0",
|
|
52
52
|
"@profullstack/synconfig": "^0.1.2",
|
|
53
|
-
"@profullstack/throttle": "0.
|
|
54
|
-
"@profullstack/x402-gateway": "^0.
|
|
53
|
+
"@profullstack/throttle": "^0.3.3",
|
|
54
|
+
"@profullstack/x402-gateway": "^0.11.0",
|
|
55
55
|
"acme-client": "5.4.0",
|
|
56
56
|
"pg": "^8.23.0"
|
|
57
57
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
"overrides": {
|
|
85
85
|
"https-proxy-agent": "7.0.6",
|
|
86
86
|
"tslib": "2.8.1",
|
|
87
|
-
"@profullstack/x402-gateway": "^0.
|
|
87
|
+
"@profullstack/x402-gateway": "^0.11.0"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/ai-label.ts
CHANGED
|
@@ -629,6 +629,9 @@ export async function writeStoryboard(
|
|
|
629
629
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
630
630
|
export class VeoUnavailable extends Error {}
|
|
631
631
|
|
|
632
|
+
/** nixamp.com briefly away (a deploy: 502-504) or the network: worth asking again. */
|
|
633
|
+
const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
|
|
634
|
+
|
|
632
635
|
// Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
|
|
633
636
|
// so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
|
|
634
637
|
// behind, in silhouette or between lines, never a close-up of singing.
|
|
@@ -1378,13 +1381,20 @@ export class AiLabel {
|
|
|
1378
1381
|
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1379
1382
|
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i]!.path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
|
|
1380
1383
|
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
|
|
1381
|
-
|
|
1382
|
-
|
|
1384
|
+
let bytes: Buffer | null = null;
|
|
1385
|
+
for (let attempt = 0; ; attempt++) {
|
|
1386
|
+
try { bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 }); break; } catch (error) {
|
|
1387
|
+
if (attempt >= 2 || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1388
|
+
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
writeFileSync(`${synced}.part`, bytes!);
|
|
1383
1392
|
renameSync(`${synced}.part`, synced);
|
|
1384
1393
|
pinned.push({ path: synced, at, seconds: 8 });
|
|
1385
1394
|
} catch (error) {
|
|
1395
|
+
// nixamp.com away (a deploy) or the month spent: nothing is cut yet; the shots wait for the next try.
|
|
1396
|
+
if (error instanceof VeoUnavailable || TRANSIENT.test((error as Error).message)) throw new Error(`lip sync ${i + 1} must wait: ${(error as Error).message}`);
|
|
1386
1397
|
this.log(`lip sync ${i + 1} for "${song.title}": ${(error as Error).message}; the band plays there instead`);
|
|
1387
|
-
if (error instanceof VeoUnavailable) break;
|
|
1388
1398
|
} finally {
|
|
1389
1399
|
rmSync(shot, { force: true });
|
|
1390
1400
|
rmSync(audio, { force: true });
|
|
@@ -1438,7 +1448,7 @@ export class AiLabel {
|
|
|
1438
1448
|
let bytes: Buffer | null = null;
|
|
1439
1449
|
for (let attempt = 0; ; attempt++) {
|
|
1440
1450
|
try { bytes = await veo({ prompt, seconds: 8, references }); break; } catch (error) {
|
|
1441
|
-
if (attempt >= 2 || error instanceof VeoUnavailable ||
|
|
1451
|
+
if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1442
1452
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1443
1453
|
}
|
|
1444
1454
|
}
|
package/src/discovery.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
12
|
|
|
13
13
|
export const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
14
|
-
const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
|
|
15
14
|
|
|
16
15
|
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
17
16
|
export const DESKTOP_TARGETS: ReadonlyArray<{ target: string; label: string; suffix: string }> = [
|
|
@@ -39,19 +38,27 @@ export function desktopAsset(release: Release | null, target: string): string |
|
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
|
-
* The latest published release,
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* The latest published release, from where github.com/.../releases/latest
|
|
42
|
+
* redirects (the tag), asked at most every ten minutes. Not the REST API: its
|
|
43
|
+
* 60 an hour unauthenticated are shared by every site on the box, and ran out.
|
|
44
|
+
* The installers are named by version (release.yml), and a release is only
|
|
45
|
+
* published once every one of them is attached. A failed ask keeps the last
|
|
46
|
+
* answer; with none, the downloads fall back to the releases page.
|
|
45
47
|
*/
|
|
46
48
|
export function latestRelease(fetcher: typeof fetch = fetch, now: () => number = Date.now): () => Promise<Release | null> {
|
|
47
49
|
let kept: { at: number; release: Release | null } = { at: -Infinity, release: null };
|
|
48
50
|
return async () => {
|
|
49
51
|
if (now() - kept.at < 10 * 60_000) return kept.release;
|
|
50
52
|
try {
|
|
51
|
-
const response = await fetcher(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const response = await fetcher(`${RELEASES}/latest`, { method: "HEAD", redirect: "manual", headers: { "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
|
|
54
|
+
const tag = /\/releases\/tag\/(v\d+\.\d+\.\d+)$/.exec(response.headers.get("location") ?? "")?.[1];
|
|
55
|
+
if (!tag) throw new Error(`no release tag (HTTP ${response.status})`);
|
|
56
|
+
const version = tag.slice(1);
|
|
57
|
+
const assets = DESKTOP_TARGETS.map((one) => {
|
|
58
|
+
const name = `nixamp-${version}${one.suffix}`;
|
|
59
|
+
return { name, browser_download_url: `${RELEASES}/download/${tag}/${name}` };
|
|
60
|
+
});
|
|
61
|
+
kept = { at: now(), release: { tag_name: tag, assets } };
|
|
55
62
|
} catch {
|
|
56
63
|
kept = { at: now(), release: kept.release };
|
|
57
64
|
}
|
|
@@ -94,7 +101,7 @@ export function llmsTxt(site: string): string {
|
|
|
94
101
|
## Desktop app
|
|
95
102
|
Downloads: ${site}/download
|
|
96
103
|
${desktop}
|
|
97
|
-
|
|
104
|
+
Every release: ${RELEASES}
|
|
98
105
|
`;
|
|
99
106
|
}
|
|
100
107
|
|
package/src/owner.ts
CHANGED
|
@@ -50,6 +50,11 @@ export class Owner {
|
|
|
50
50
|
return this.options.ownerId !== "";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** The owner's account id, or "" when nobody has claimed this server. */
|
|
54
|
+
get id(): string {
|
|
55
|
+
return this.options.ownerId;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
54
59
|
async accountFor(token: string): Promise<string> {
|
|
55
60
|
if (!token) return "";
|
package/src/server.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { Readable } from "node:stream";
|
|
|
22
22
|
import { pipeline as pipeStream } from "node:stream/promises";
|
|
23
23
|
import { relative } from "node:path";
|
|
24
24
|
import { SharedTranslations, type SharedEvent } from "./shared-translation.ts";
|
|
25
|
-
import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature } from "./translation-passes.ts";
|
|
25
|
+
import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature, type AiKind } from "./translation-passes.ts";
|
|
26
26
|
import { LiveVoice, LIVE_VOICE_LANGUAGES, LIVE_VOICE_MODEL, type VoiceRequest } from "./live-voice.ts";
|
|
27
27
|
import { Connections, type Kind } from "./connections.ts";
|
|
28
28
|
import {
|
|
@@ -351,7 +351,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
351
351
|
tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
|
|
352
352
|
tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
|
|
353
353
|
x402: false,
|
|
354
|
-
|
|
354
|
+
// The account that administers this server, for one with no key but accounts (nixamp.com).
|
|
355
|
+
owner: process.env["NIXAMP_OWNER"] ?? "",
|
|
355
356
|
ingest: false,
|
|
356
357
|
rtmpIn: 0,
|
|
357
358
|
rtmpStreams: 3,
|
|
@@ -2031,6 +2032,10 @@ export interface HandlerOptions {
|
|
|
2031
2032
|
export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
2032
2033
|
// One per server, so the counters survive between requests and die with it.
|
|
2033
2034
|
const guard = new Guard();
|
|
2035
|
+
// --no-key opens a server to whoever reaches it, unless it has an owner: then
|
|
2036
|
+
// anyone may listen and only the owner (or the control key) administers.
|
|
2037
|
+
// nixamp.com is that server; without this every visitor was its admin.
|
|
2038
|
+
const openToAll = (key: string | null): boolean => key === null && !options.owner?.claimed;
|
|
2034
2039
|
// nixamp.com's /download asks GitHub which release is latest; one answer per ten minutes.
|
|
2035
2040
|
const release = latestRelease();
|
|
2036
2041
|
|
|
@@ -2190,7 +2195,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2190
2195
|
cors: CORS,
|
|
2191
2196
|
watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
|
|
2192
2197
|
controls: async () => {
|
|
2193
|
-
if (key
|
|
2198
|
+
if (openToAll(key)) return true;
|
|
2194
2199
|
if (scopeOf(keyFrom(request, url), key, null) === "control") return true;
|
|
2195
2200
|
if (options.owner) return (await options.owner.check(false, tokenFrom(request.headers))).allowed;
|
|
2196
2201
|
return false;
|
|
@@ -2940,7 +2945,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2940
2945
|
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2941
2946
|
// through the admin gate further down.
|
|
2942
2947
|
const controlOf = async (): Promise<{ allowed: boolean; account: string }> => {
|
|
2943
|
-
if (key
|
|
2948
|
+
if (openToAll(key)) return { allowed: true, account: "" };
|
|
2944
2949
|
if (scopeOf(keyFrom(request, url), key, listenKey) === "control") return { allowed: true, account: "" };
|
|
2945
2950
|
if (options.owner) {
|
|
2946
2951
|
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
@@ -3923,6 +3928,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3923
3928
|
* OpenProfile. Never awaited by the poster: the phone is a side effect
|
|
3924
3929
|
* of the chat, and a slow Telnyx must not slow the box.
|
|
3925
3930
|
*/
|
|
3931
|
+
/**
|
|
3932
|
+
* AI work done for a signed-in account, paid as it goes from their credit:
|
|
3933
|
+
* charged first, refunded if the work fails, a 402 with no credit. Only the
|
|
3934
|
+
* admin (the owner) is never charged; with billing off, only the admin may.
|
|
3935
|
+
*/
|
|
3936
|
+
const payForAi = <T>(by: string, kind: AiKind, units: number, work: () => Promise<T>): Promise<T> => {
|
|
3937
|
+
if (options.translationPasses) return options.translationPasses.spend(by, kind, units, work);
|
|
3938
|
+
if (by !== "" && by === options.owner?.id) return work();
|
|
3939
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
3940
|
+
};
|
|
3941
|
+
|
|
3926
3942
|
const readOnThePhone = (where: { server: string; channel: string }, authorId: string, handle: string, body: string): void => {
|
|
3927
3943
|
const partyLine = options.partyLine;
|
|
3928
3944
|
const listing = (options.directory?.list() ?? []).find((one) => {
|
|
@@ -3940,7 +3956,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3940
3956
|
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
3941
3957
|
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro" as const, female: [], male: [] };
|
|
3942
3958
|
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
3943
|
-
|
|
3959
|
+
const words = spokenLine(handle, body);
|
|
3960
|
+
// Read aloud is AI speech, paid per character from the author's credit (the admin's never is).
|
|
3961
|
+
// No credit: the line stays in the chat and is not read.
|
|
3962
|
+
const say = (): Promise<boolean> => partyLine.say(code, words, spoken.voice, spoken.settings);
|
|
3963
|
+
await payForAi(authorId, "phone_voice", words.length, say);
|
|
3944
3964
|
})().catch(() => undefined);
|
|
3945
3965
|
};
|
|
3946
3966
|
|
|
@@ -4172,13 +4192,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4172
4192
|
json(response, 400, { error: "a room is a server address and a channel" });
|
|
4173
4193
|
return;
|
|
4174
4194
|
}
|
|
4175
|
-
|
|
4195
|
+
// Pay as you go per second of sound, from the WAV's own byte rate (the admin is never charged).
|
|
4196
|
+
const byteRate = bytes.length > 44 ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(28, true) : 0;
|
|
4197
|
+
const heard = await payForAi(who.id, "transcription_local", byteRate > 0 ? (bytes.length - 44) / byteRate : 1, () => speech.transcribe(bytes, {
|
|
4176
4198
|
language: languageOf(url.searchParams.get("language")),
|
|
4177
4199
|
// Pieces with their timing, for a whole file being written down.
|
|
4178
4200
|
timestamps: url.searchParams.get("timestamps") === "1",
|
|
4179
4201
|
by: who.id,
|
|
4180
4202
|
...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}),
|
|
4181
|
-
});
|
|
4203
|
+
}));
|
|
4182
4204
|
// The language travels back: as told, or as the ear guessed it, so
|
|
4183
4205
|
// a captioner can say it next time and a transcript can be kept as it.
|
|
4184
4206
|
const said = {
|
|
@@ -4388,7 +4410,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4388
4410
|
return;
|
|
4389
4411
|
}
|
|
4390
4412
|
try {
|
|
4391
|
-
|
|
4413
|
+
// Pay as you go per character sent (the admin is never charged).
|
|
4414
|
+
const characters = texts.reduce((sum, one) => sum + one.length, 0);
|
|
4415
|
+
json(response, 200, await payForAi(who.id, "translation_local", characters, () => translator.translate(texts, from, to, { by: who.id, ...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}) })));
|
|
4392
4416
|
} catch (error) {
|
|
4393
4417
|
if (error instanceof SpeechError) json(response, error.status, { error: error.message });
|
|
4394
4418
|
else throw error;
|
|
@@ -5239,11 +5263,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5239
5263
|
eventChannelRequest,
|
|
5240
5264
|
);
|
|
5241
5265
|
if (options.owner && needsAdmin(path, request.method ?? "GET") && !eventMediaOperation) {
|
|
5242
|
-
// A server started with --no-key has said that anyone who
|
|
5243
|
-
// port may drive it, and prints exactly that.
|
|
5244
|
-
//
|
|
5266
|
+
// A server started with --no-key and no owner has said that anyone who
|
|
5267
|
+
// can reach the port may drive it, and prints exactly that. With an owner
|
|
5268
|
+
// (nixamp.com), --no-key only means anyone may listen: the owner drives.
|
|
5245
5269
|
const holdsControl =
|
|
5246
|
-
key
|
|
5270
|
+
openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
5247
5271
|
// A session in the query as well as in a header or a cookie: from
|
|
5248
5272
|
// nixamp.com's page a server is another origin, and the cookie stays
|
|
5249
5273
|
// home, so the session travels the way the key does.
|
|
@@ -5401,7 +5425,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5401
5425
|
if (path === "/api/catalogs" && request.method === "GET") {
|
|
5402
5426
|
// Where a list is read from is the administrator's business, not a
|
|
5403
5427
|
// listener's: it can carry a provider's credentials in the URL.
|
|
5404
|
-
const holdsControl = key
|
|
5428
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
5405
5429
|
const admin = options.owner
|
|
5406
5430
|
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
5407
5431
|
: holdsControl;
|
|
@@ -7991,7 +8015,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7991
8015
|
// Account servers meter paid providers by default, including when checkout
|
|
7992
8016
|
// is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
|
|
7993
8017
|
const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
|
|
7994
|
-
|
|
8018
|
+
// Every AI use is paid from this credit; only the admin (--owner / NIXAMP_OWNER) is never charged.
|
|
8019
|
+
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
|
|
7995
8020
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
7996
8021
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
7997
8022
|
// Monthly support: each month a new CoinPay payment and its link by email.
|
|
@@ -8069,6 +8094,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8069
8094
|
const base = peerBase(String(request.headers["x-nixamp-server"] ?? ""));
|
|
8070
8095
|
if (!(await ownsListed(request, base))) return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
8071
8096
|
if (request.method === "GET") return { status: 200, body: await veoStudio.status() };
|
|
8097
|
+
// Pay as you go from the caller's AI credit, per second made; the admin is never charged.
|
|
8098
|
+
const payer = accounts ? (await accounts.whoIs(tokenFrom(request.headers)).catch(() => null))?.id ?? "" : "";
|
|
8099
|
+
const paid = <T>(kind: AiKind, seconds: number, work: () => Promise<T>): Promise<T> => {
|
|
8100
|
+
if (translationPasses) return translationPasses.spend(payer, kind, seconds, work);
|
|
8101
|
+
if (payer !== "" && payer === options.owner) return work();
|
|
8102
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
8103
|
+
};
|
|
8104
|
+
const unpaid = (error: unknown): { status: number; body: unknown } | null =>
|
|
8105
|
+
error instanceof SpeechError ? { status: error.status, body: { error: error.message, code: "credit" } } : null;
|
|
8072
8106
|
if (request.method !== "POST") return { status: 405, body: { error: "GET or POST" } };
|
|
8073
8107
|
let input: { prompt?: unknown; seconds?: unknown; references?: unknown; lipsync?: unknown; video?: unknown; audio?: unknown };
|
|
8074
8108
|
// A lip sync carries an 8 s 1080p shot and its seconds of the song: up to 48 MB as base64.
|
|
@@ -8079,8 +8113,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8079
8113
|
const audio = Buffer.from(String(input.audio ?? ""), "base64");
|
|
8080
8114
|
if (video.length < 10_000 || audio.length < 1_000) return { status: 400, body: { error: "a shot and its audio" } };
|
|
8081
8115
|
try {
|
|
8082
|
-
|
|
8116
|
+
// Charged as veo-studio.ts makes it: 1 to 10 s.
|
|
8117
|
+
const seconds = Math.max(1, Math.min(10, Number(input.seconds ?? 8) || 8));
|
|
8118
|
+
return { status: 200, body: null, video: await paid("lipsync", seconds, () => veoStudio.lipsync({ video, audio, seconds })) };
|
|
8083
8119
|
} catch (error) {
|
|
8120
|
+
const owed = unpaid(error);
|
|
8121
|
+
if (owed) return owed;
|
|
8084
8122
|
if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
8085
8123
|
return { status: 422, body: { error: (error as Error).message, code: "refused" } };
|
|
8086
8124
|
}
|
|
@@ -8089,8 +8127,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8089
8127
|
if (prompt.length < 20) return { status: 400, body: { error: "describe the shot" } };
|
|
8090
8128
|
const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
|
|
8091
8129
|
try {
|
|
8092
|
-
|
|
8130
|
+
// Charged as veo-studio.ts makes it: 4, 6 or 8 s.
|
|
8131
|
+
const seconds = [4, 6, 8].includes(Number(input.seconds)) ? Number(input.seconds) : 8;
|
|
8132
|
+
return { status: 200, body: null, video: await paid("video", seconds, () => veoStudio.clip({ prompt, seconds, references })) };
|
|
8093
8133
|
} catch (error) {
|
|
8134
|
+
const owed = unpaid(error);
|
|
8135
|
+
if (owed) return owed;
|
|
8094
8136
|
if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
8095
8137
|
return { status: 422, body: { error: (error as Error).message, code: "refused" } };
|
|
8096
8138
|
}
|
|
@@ -8147,7 +8189,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8147
8189
|
}
|
|
8148
8190
|
// What was heard, kept: where the accounts are, under the media's identity.
|
|
8149
8191
|
const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
|
|
8150
|
-
|
|
8192
|
+
// Translating a stored transcript is AI: paid per character by whoever asked; only the admin is never charged.
|
|
8193
|
+
const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message), (by, characters, work) => {
|
|
8194
|
+
if (translationPasses) return translationPasses.spend(by, "translation_local", characters, work);
|
|
8195
|
+
if (by !== "" && by === options.owner) return work();
|
|
8196
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
8197
|
+
}) : undefined;
|
|
8151
8198
|
// The record of every file nixamp has met, by its bytes: nixamp.com/hash/<id>.
|
|
8152
8199
|
const records = pool ? new Media(pool, (message) => console.log(message)) : undefined;
|
|
8153
8200
|
|
|
@@ -8922,7 +8969,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8922
8969
|
}
|
|
8923
8970
|
|
|
8924
8971
|
console.log("");
|
|
8925
|
-
if (key === null) {
|
|
8972
|
+
if (key === null && owner.claimed) {
|
|
8973
|
+
console.log(" No key: anyone who can reach this port can hear it; only its owner can drive it.");
|
|
8974
|
+
} else if (key === null) {
|
|
8926
8975
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
8927
8976
|
} else {
|
|
8928
8977
|
console.log(" Open that link once on a phone or a laptop and it stays signed in.");
|
package/src/translate-jobs.ts
CHANGED
|
@@ -26,13 +26,18 @@ interface Job {
|
|
|
26
26
|
progress: Progress;
|
|
27
27
|
finished: Promise<void>;
|
|
28
28
|
error: string;
|
|
29
|
+
/** The status to answer the error with: 402 when the asker's AI credit ran out. */
|
|
30
|
+
status?: 402;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/** Runs some translating for an account, paid from its credit (server.ts payForAi). */
|
|
34
|
+
export type PayForTranslation = <T>(by: string, characters: number, work: () => Promise<T>) => Promise<T>;
|
|
35
|
+
|
|
31
36
|
/** What a request should answer: the status and the body's extra fields. */
|
|
32
37
|
export type Answer =
|
|
33
38
|
| { status: 200; transcript: Transcript }
|
|
34
39
|
| { status: 202; transcript: Transcript | null; translating: Progress }
|
|
35
|
-
| { status: 404 | 409 | 503; error: string };
|
|
40
|
+
| { status: 402 | 404 | 409 | 503; error: string };
|
|
36
41
|
|
|
37
42
|
export class StoredTranslations {
|
|
38
43
|
private readonly jobs = new Map<string, Job>();
|
|
@@ -41,6 +46,8 @@ export class StoredTranslations {
|
|
|
41
46
|
private readonly store: Transcripts,
|
|
42
47
|
private readonly translator: Translator | undefined,
|
|
43
48
|
private readonly onEvent: (message: string) => void = () => {},
|
|
49
|
+
// Pay as you go: each batch is charged to whoever asked, before it is translated.
|
|
50
|
+
private readonly pay: PayForTranslation = (_by, _characters, work) => work(),
|
|
44
51
|
) {}
|
|
45
52
|
|
|
46
53
|
/** The lines of the original that the translation does not yet have. */
|
|
@@ -70,7 +77,7 @@ export class StoredTranslations {
|
|
|
70
77
|
let job = this.jobs.get(key);
|
|
71
78
|
if (job?.error) {
|
|
72
79
|
this.jobs.delete(key);
|
|
73
|
-
return { status: 503, error: job.error };
|
|
80
|
+
return { status: job.status ?? 503, error: job.error };
|
|
74
81
|
}
|
|
75
82
|
if (!job) {
|
|
76
83
|
job = this.start(key, original, translation, missing, language, by);
|
|
@@ -79,7 +86,7 @@ export class StoredTranslations {
|
|
|
79
86
|
await job.finished;
|
|
80
87
|
if (job.error) {
|
|
81
88
|
this.jobs.delete(key);
|
|
82
|
-
return { status: 503, error: job.error };
|
|
89
|
+
return { status: job.status ?? 503, error: job.error };
|
|
83
90
|
}
|
|
84
91
|
const made = await this.store.get(id, language);
|
|
85
92
|
if (made) return { status: 200, transcript: made };
|
|
@@ -93,6 +100,7 @@ export class StoredTranslations {
|
|
|
93
100
|
job.finished = this.run(original, translation, missing, language, by, job)
|
|
94
101
|
.catch((error: unknown) => {
|
|
95
102
|
job.error = error instanceof SpeechError ? error.message : `translating failed: ${(error as Error).message}`;
|
|
103
|
+
if (error instanceof SpeechError && error.status === 402) job.status = 402;
|
|
96
104
|
this.onEvent(` translating ${original.title || original.media} to ${language}: ${job.error}`);
|
|
97
105
|
})
|
|
98
106
|
.finally(() => {
|
|
@@ -111,7 +119,8 @@ export class StoredTranslations {
|
|
|
111
119
|
const lines: TranscriptLine[] = [];
|
|
112
120
|
for (const source of new Set(batch.map((line) => line.language || original.language))) {
|
|
113
121
|
const group = batch.filter((line) => (line.language || original.language) === source);
|
|
114
|
-
const
|
|
122
|
+
const texts = group.map((line) => line.text);
|
|
123
|
+
const done = await this.pay(by, texts.reduce((sum, one) => sum + one.length, 0), () => translator.translate(texts, source, language));
|
|
115
124
|
model = done.model;
|
|
116
125
|
lines.push(...group.map((line, i) => ({ ...line, language, original: line.text, text: done.texts[i] ?? "" })).filter((line) => line.text !== ""));
|
|
117
126
|
}
|
|
@@ -1,8 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Prepaid AI credit, from @profullstack/live-translate, sold as nixamp.
|
|
3
|
+
*
|
|
4
|
+
* Every AI feature on nixamp.com is pay as you go out of this one wallet: a
|
|
5
|
+
* pass adds dollars, each use takes what it costs times the multiplier, and a
|
|
6
|
+
* use that fails is refunded. There is no free allowance. The one exception is
|
|
7
|
+
* the site's admin (NIXAMP_OWNER), whose own use is never charged.
|
|
8
|
+
*/
|
|
9
|
+
import { TranslationPasses as Base, TRANSLATION_MULTIPLIER, type TranslationAccess, type TranslationUsage } from "@profullstack/live-translate";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { SpeechError } from "./speech.ts";
|
|
3
12
|
export { TRANSLATION_MULTIPLIER, TRANSLATION_PLANS, translationCost, verifyTranslationPayment, coinpayKeyFrom, verifyCoinpaySignature, readCoinpayWebhook,
|
|
4
13
|
type TranslationPlan, type TranslationUsage, type TranslationMeter, type TranslationAccess } from "@profullstack/live-translate";
|
|
5
14
|
|
|
15
|
+
type Queryable = ConstructorParameters<typeof Base>[0]["db"];
|
|
16
|
+
|
|
17
|
+
/** The AI work billed here beyond translated audio, and what one unit costs nixamp.com in dollars. */
|
|
18
|
+
export type AiKind = "video" | "lipsync" | "phone_voice" | "transcription_local" | "translation_local";
|
|
19
|
+
const number = (name: string, fallback: number): number => {
|
|
20
|
+
const value = Number(process.env[name]);
|
|
21
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
22
|
+
};
|
|
23
|
+
export const AI_RATES: Readonly<Record<AiKind, { usd: number; per: string }>> = {
|
|
24
|
+
// Veo 3.1 Fast and fal.ai lip sync, per second of video: what veo-studio.ts already budgets with.
|
|
25
|
+
video: { usd: number("NIXAMP_VEO_USD_PER_SECOND", 0.15), per: "second" },
|
|
26
|
+
lipsync: { usd: number("NIXAMP_LIPSYNC_USD_PER_SECOND", 0.1), per: "second" },
|
|
27
|
+
// A chat line read aloud on the party line: the ElevenLabs Flash rate, per character.
|
|
28
|
+
phone_voice: { usd: 0.05 / 1000, per: "character" },
|
|
29
|
+
// Whisper and OPUS-MT run on nixamp.com's own machine; priced like the hosted kind they replace.
|
|
30
|
+
transcription_local: { usd: 0.22 / 3600, per: "second" },
|
|
31
|
+
translation_local: { usd: number("NIXAMP_TRANSLATE_USD_PER_1K", 0.01) / 1000, per: "character" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A charge taken before AI work runs: kept when the work is done, given back when it fails. */
|
|
35
|
+
export interface AiCharge {
|
|
36
|
+
commit(): Promise<void>;
|
|
37
|
+
refund(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
const FREE: AiCharge = { commit: async () => {}, refund: async () => {} };
|
|
40
|
+
|
|
41
|
+
export const ADMIN_USE = "admin:";
|
|
42
|
+
|
|
6
43
|
export class TranslationPasses extends Base {
|
|
7
|
-
|
|
44
|
+
private readonly admins: ReadonlySet<string>;
|
|
45
|
+
private readonly db: Queryable;
|
|
46
|
+
private readonly clock: () => number;
|
|
47
|
+
|
|
48
|
+
constructor(options: ConstructorParameters<typeof Base>[0] & { admins?: string[] }) {
|
|
49
|
+
const { admins = [], ...rest } = options;
|
|
50
|
+
super({ app: "nixamp", label: "Nixamp AI credit", ...rest });
|
|
51
|
+
this.admins = new Set(admins.filter(Boolean));
|
|
52
|
+
this.db = rest.db;
|
|
53
|
+
this.clock = rest.now ?? Date.now;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isAdmin(by: string | undefined): boolean {
|
|
57
|
+
return Boolean(by) && this.admins.has(by!);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async tables(): Promise<void> {
|
|
61
|
+
await (this as unknown as { ensure(): Promise<void> }).ensure();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override async access(by?: string): Promise<TranslationAccess> {
|
|
65
|
+
const access = await super.access(by);
|
|
66
|
+
// No free sessions: the page offers credit, never "start a free session".
|
|
67
|
+
const free = { ...access.free, limit: 0, remaining: 0, activeUntil: null };
|
|
68
|
+
return this.isAdmin(by) ? { ...access, required: false, free } : { ...access, free };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override async begin(by: string, resource: string): Promise<void> {
|
|
72
|
+
await this.require(by, resource);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override async require(by: string, _resource = ""): Promise<void> {
|
|
76
|
+
if (this.isAdmin(by)) return;
|
|
77
|
+
await this.tables();
|
|
78
|
+
const found = await this.db.query("SELECT 1 FROM translation_wallets WHERE by_account = $1 AND expires_at > $2 AND balance_micros > 0", [by, new Date(this.clock())]);
|
|
79
|
+
if (!found.rows.length) throw new SpeechError("Buy AI credit: AI on nixamp is pay as you go.", 402);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override async eligible(accounts: string[], _resource = ""): Promise<string[]> {
|
|
83
|
+
await this.tables();
|
|
84
|
+
const result = await this.db.query("SELECT by_account FROM translation_wallets WHERE by_account = ANY($1::text[]) AND expires_at > $2 AND balance_micros > 0", [accounts, new Date(this.clock())]);
|
|
85
|
+
return [...new Set([...accounts.filter((one) => this.isAdmin(one)), ...result.rows.map((row) => String(row["by_account"]))])];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override async reserve(by: string, kind: TranslationUsage, units: number, _resource = ""): Promise<string> {
|
|
89
|
+
const [taken] = await this.reserveMany([by], kind, units);
|
|
90
|
+
if (!taken) throw new SpeechError("Buy AI credit: yours is used up.", 402);
|
|
91
|
+
return taken.id;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override async reserveMany(accounts: string[], kind: TranslationUsage, units: number, _resource = ""): Promise<{ by: string; id: string }[]> {
|
|
95
|
+
const admins = accounts.filter((one) => this.isAdmin(one)).map((by) => ({ by, id: `${ADMIN_USE}${randomUUID()}` }));
|
|
96
|
+
const paying = accounts.filter((one) => !this.isAdmin(one));
|
|
97
|
+
// No resource: the package's free-session lookup keys on it, and there are no free sessions here.
|
|
98
|
+
return [...admins, ...(paying.length ? await super.reserveMany(paying, kind, units, "") : [])];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override async commit(id: string): Promise<void> {
|
|
102
|
+
if (!id.startsWith(ADMIN_USE)) await super.commit(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
override async refund(id: string): Promise<void> {
|
|
106
|
+
if (!id.startsWith(ADMIN_USE)) await super.refund(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override async commitMany(ids: string[]): Promise<void> {
|
|
110
|
+
const paid = ids.filter((id) => !id.startsWith(ADMIN_USE));
|
|
111
|
+
if (paid.length) await super.commitMany(paid);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override async refundMany(ids: string[]): Promise<void> {
|
|
115
|
+
const paid = ids.filter((id) => !id.startsWith(ADMIN_USE));
|
|
116
|
+
if (paid.length) await super.refundMany(paid);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What `units` of `kind` cost, and what is charged for them, in micro-dollars. */
|
|
120
|
+
static price(kind: AiKind, units: number): { cost: number; charge: number } {
|
|
121
|
+
const cost = Math.max(1, Math.ceil(AI_RATES[kind].usd * units * 1_000_000));
|
|
122
|
+
return { cost, charge: cost * TRANSLATION_MULTIPLIER };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Take the price of some AI work from `by`'s credit before it runs, in one
|
|
127
|
+
* statement, so two tabs cannot both spend the last cent. A 402 when there
|
|
128
|
+
* is not enough. The admin is never charged.
|
|
129
|
+
*/
|
|
130
|
+
async pay(by: string, kind: AiKind, units: number): Promise<AiCharge> {
|
|
131
|
+
if (this.isAdmin(by)) return FREE;
|
|
132
|
+
if (!by) throw new SpeechError("Sign in to use AI on nixamp.", 401);
|
|
133
|
+
if (!Number.isFinite(units) || units <= 0) return FREE;
|
|
134
|
+
await this.tables();
|
|
135
|
+
const { cost, charge } = TranslationPasses.price(kind, Math.ceil(units));
|
|
136
|
+
const now = new Date(this.clock());
|
|
137
|
+
const result = await this.db.query(`WITH debit AS (
|
|
138
|
+
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
139
|
+
WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
|
|
140
|
+
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
141
|
+
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, $2, $3 FROM debit RETURNING id`,
|
|
142
|
+
[by, charge, now, kind, Math.ceil(units), cost]);
|
|
143
|
+
const id = result.rows[0]?.["id"];
|
|
144
|
+
if (!id) throw new SpeechError(`Buy AI credit: AI on nixamp is pay as you go, and this costs $${(charge / 1_000_000).toFixed(4)}.`, 402);
|
|
145
|
+
return { commit: () => super.commit(String(id)), refund: () => super.refund(String(id)) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Pay, do the work, and keep the charge only if the work succeeded. */
|
|
149
|
+
async spend<T>(by: string, kind: AiKind, units: number, work: () => Promise<T>): Promise<T> {
|
|
150
|
+
const charge = await this.pay(by, kind, units);
|
|
151
|
+
try {
|
|
152
|
+
const done = await work();
|
|
153
|
+
await charge.commit();
|
|
154
|
+
return done;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await charge.refund().catch(() => undefined);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
8
160
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-B-WZ8VbE.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|