nixamp 0.23.7 → 0.24.0
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 +79 -3
- package/dist/captions.d.ts +57 -10
- package/dist/captions.js +253 -29
- package/dist/main.js +63 -19
- package/dist/mcp.d.ts +7 -1
- package/dist/mcp.js +159 -24
- package/dist/server.d.ts +11 -0
- package/dist/server.js +353 -8
- package/dist/speech.d.ts +21 -3
- package/dist/speech.js +65 -8
- package/dist/transcribe.d.ts +70 -0
- package/dist/transcribe.js +337 -41
- package/dist/transcript-client.d.ts +81 -0
- package/dist/transcript-client.js +76 -0
- package/dist/transcript.d.ts +7 -2
- package/dist/transcript.js +73 -5
- package/dist/transcripts.d.ts +135 -0
- package/dist/transcripts.js +384 -0
- package/dist/translate-cli.d.ts +16 -0
- package/dist/translate-cli.js +97 -0
- package/dist/translate-jobs.d.ts +39 -0
- package/dist/translate-jobs.js +120 -0
- package/dist/translate.d.ts +78 -0
- package/dist/translate.js +241 -0
- package/dist/warm.d.ts +1 -0
- package/dist/warm.js +40 -0
- package/package.json +1 -1
- package/src/captions.ts +276 -29
- package/src/main.ts +63 -19
- package/src/mcp.ts +156 -21
- package/src/server.ts +352 -8
- package/src/speech.ts +103 -13
- package/src/transcribe.ts +374 -41
- package/src/transcript-client.ts +147 -0
- package/src/transcript.ts +76 -4
- package/src/transcripts.ts +462 -0
- package/src/translate-cli.ts +111 -0
- package/src/translate-jobs.ts +132 -0
- package/src/translate.ts +276 -0
- package/src/warm.ts +43 -0
- package/web/dist/assets/{hls-3VKVEQE3-CI1U7kbP.js → hls-3VKVEQE3-Dtl-3mpW.js} +1 -1
- package/web/dist/assets/index-B0h4Nexr.js +1 -0
- package/web/dist/assets/index-BTGV3Pi5.css +1 -0
- package/web/dist/assets/{mpegts-CPOYjgRP.js → mpegts-BJC48bFV.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CE8YPjx1.js → mpegts-LO6RVLD6-CUIAB9k3.js} +1 -1
- package/web/dist/index.html +12 -8
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-46pwGn5-.css +0 -1
- package/web/dist/assets/index-5H3sUGHu.js +0 -1
package/src/server.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
signInFailedPage,
|
|
46
46
|
SignIn,
|
|
47
47
|
} from "./oauth.ts";
|
|
48
|
-
import { AuthorizationServer, clientsFrom } from "./oauth-server.ts";
|
|
48
|
+
import { AuthorizationServer, SCOPE_NAMES, clientsFrom } from "./oauth-server.ts";
|
|
49
49
|
import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts";
|
|
50
50
|
import { WatchParties } from "./watch-party.ts";
|
|
51
51
|
import { needsAdmin, needsMember, Owner } from "./owner.ts";
|
|
@@ -88,6 +88,12 @@ import { Rooms } from "./rooms.ts";
|
|
|
88
88
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
|
|
89
89
|
import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.ts";
|
|
90
90
|
import { Captions } from "./captions.ts";
|
|
91
|
+
import { LANGUAGES, Translator } from "./translate.ts";
|
|
92
|
+
import { StoredTranslations } from "./translate-jobs.ts";
|
|
93
|
+
import {
|
|
94
|
+
Transcripts, fileFingerprint, formatOf, idFrom, languageCode, linesFrom, mediaOfLive, mediaOfUrl, toSrt, toText, toVtt, transcriptIdOf, wire,
|
|
95
|
+
} from "./transcripts.ts";
|
|
96
|
+
import { handleMessage as mcpMessage } from "./mcp.ts";
|
|
91
97
|
import { Outro } from "./outro.ts";
|
|
92
98
|
import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.ts";
|
|
93
99
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
@@ -1735,6 +1741,14 @@ export interface HandlerOptions {
|
|
|
1735
1741
|
trollbox?: Trollbox;
|
|
1736
1742
|
/** Speech to text: a line said out loud, heard here. Needs the optional model. */
|
|
1737
1743
|
speech?: Speech;
|
|
1744
|
+
/** Translation: texts in another language, by a model here. The same optional library. */
|
|
1745
|
+
translator?: Translator;
|
|
1746
|
+
/** The transcript store: what was heard, kept under the media's identity. Where the accounts are. */
|
|
1747
|
+
transcripts?: Transcripts;
|
|
1748
|
+
/** Stored transcripts translated, as jobs. */
|
|
1749
|
+
translations?: StoredTranslations;
|
|
1750
|
+
/** nixamp.com's address, for the tools reached over /mcp to call. */
|
|
1751
|
+
site?: string;
|
|
1738
1752
|
/** Other people's OpenProfiles, for the voice their lines are read in. */
|
|
1739
1753
|
profiles?: Profiles;
|
|
1740
1754
|
/** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
|
|
@@ -2866,18 +2880,30 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2866
2880
|
json(response, 400, { error: "a room is a server address and a channel" });
|
|
2867
2881
|
return;
|
|
2868
2882
|
}
|
|
2869
|
-
const heard = await speech.transcribe(bytes, {
|
|
2883
|
+
const heard = await speech.transcribe(bytes, {
|
|
2884
|
+
language: languageOf(url.searchParams.get("language")),
|
|
2885
|
+
// Pieces with their timing, for a whole file being written down.
|
|
2886
|
+
timestamps: url.searchParams.get("timestamps") === "1",
|
|
2887
|
+
by: who.id,
|
|
2888
|
+
});
|
|
2889
|
+
// The language travels back: as told, or as the ear guessed it, so
|
|
2890
|
+
// a captioner can say it next time and a transcript can be kept as it.
|
|
2891
|
+
const said = {
|
|
2892
|
+
text: heard.text, seconds: heard.seconds, model: speech.model,
|
|
2893
|
+
...(heard.language ? { language: heard.language } : {}),
|
|
2894
|
+
...(heard.segments ? { segments: heard.segments } : {}),
|
|
2895
|
+
};
|
|
2870
2896
|
if (where && options.trollbox && heard.text !== "") {
|
|
2871
2897
|
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2872
2898
|
const line = await options.trollbox.post(where, who.id, handle, heard.text);
|
|
2873
2899
|
readOnThePhone(where, who.id, line.handle, line.body);
|
|
2874
2900
|
json(response, 201, {
|
|
2875
|
-
|
|
2901
|
+
...said,
|
|
2876
2902
|
message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
|
|
2877
2903
|
});
|
|
2878
2904
|
return;
|
|
2879
2905
|
}
|
|
2880
|
-
json(response, 200,
|
|
2906
|
+
json(response, 200, said);
|
|
2881
2907
|
} catch (error) {
|
|
2882
2908
|
if (error instanceof SpeechError || error instanceof TrollboxError) json(response, error.status, { error: error.message });
|
|
2883
2909
|
else throw error;
|
|
@@ -2885,6 +2911,275 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2885
2911
|
return;
|
|
2886
2912
|
}
|
|
2887
2913
|
|
|
2914
|
+
/*
|
|
2915
|
+
* Translation: texts in one language, said in another, by a model on
|
|
2916
|
+
* this CPU (see translate.ts). Signed in only, like the ear. GET says
|
|
2917
|
+
* which languages, and which each can be turned into here.
|
|
2918
|
+
*/
|
|
2919
|
+
if (path === "/api/v1/translate" && options.accounts) {
|
|
2920
|
+
const translator = options.translator;
|
|
2921
|
+
if (request.method === "GET") {
|
|
2922
|
+
json(response, 200, {
|
|
2923
|
+
available: translator !== undefined,
|
|
2924
|
+
languages: Object.entries(LANGUAGES).map(([code, names]) => ({ code, ...names, targets: translator ? translator.targets(code) : [] })),
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
if (request.method !== "POST") {
|
|
2929
|
+
json(response, 405, { error: "POST texts, or GET the languages" });
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
if (!translator) {
|
|
2933
|
+
json(response, 503, { error: "this nixamp cannot translate: the models are not here. nixamp.com can." });
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2937
|
+
if (who === null) {
|
|
2938
|
+
json(response, 401, { error: "sign in to nixamp.com to translate" });
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
let body: { texts?: unknown; text?: unknown; from?: unknown; to?: unknown } = {};
|
|
2942
|
+
try {
|
|
2943
|
+
body = JSON.parse(await readBody(request, 256 * 1024)) as typeof body;
|
|
2944
|
+
} catch {
|
|
2945
|
+
json(response, 400, { error: "bad JSON" });
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const texts = Array.isArray(body.texts)
|
|
2949
|
+
? body.texts.filter((one): one is string => typeof one === "string")
|
|
2950
|
+
: typeof body.text === "string" ? [body.text] : [];
|
|
2951
|
+
if (texts.length === 0) {
|
|
2952
|
+
json(response, 400, { error: "say what: texts, a list of strings" });
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (texts.length > 200) {
|
|
2956
|
+
json(response, 413, { error: "200 texts at a time at most" });
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const from = languageCode(body.from);
|
|
2960
|
+
const to = languageCode(body.to);
|
|
2961
|
+
if (from === null || to === null || to === "") {
|
|
2962
|
+
json(response, 400, { error: "from and to are two-letter language codes, such as en and sv" });
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
if (from === "") {
|
|
2966
|
+
json(response, 400, { error: "say which language the texts are in: from" });
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
try {
|
|
2970
|
+
json(response, 200, await translator.translate(texts, from, to, { by: who.id }));
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
if (error instanceof SpeechError) json(response, error.status, { error: error.message });
|
|
2973
|
+
else throw error;
|
|
2974
|
+
}
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/*
|
|
2979
|
+
* The transcript store: what a file, a link or a live said, kept once
|
|
2980
|
+
* under the media's identity (see transcripts.ts). Signed in to read
|
|
2981
|
+
* and to keep, the way the ear is: a captioning server, the CLI, the
|
|
2982
|
+
* MCP tools. A language asks for a translation, made before answering
|
|
2983
|
+
* when it is short and as a job when it is not; a format asks for it
|
|
2984
|
+
* as subtitles.
|
|
2985
|
+
*/
|
|
2986
|
+
if ((path === "/api/v1/transcripts" || path.startsWith("/api/v1/transcripts/")) && options.transcripts && options.translations && options.accounts) {
|
|
2987
|
+
const store = options.transcripts;
|
|
2988
|
+
const parts = path.slice("/api/v1/transcripts".length).split("/").filter(Boolean);
|
|
2989
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2990
|
+
if (who === null) {
|
|
2991
|
+
json(response, 401, { error: "sign in to nixamp.com to read or keep transcripts" });
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
try {
|
|
2995
|
+
if (parts.length === 0) {
|
|
2996
|
+
if (request.method !== "GET") {
|
|
2997
|
+
json(response, 405, { error: "GET: what you have had written down" });
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
json(response, 200, { transcripts: await store.list(who.id, Number(url.searchParams.get("limit") ?? 50) || 50) });
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
let named = "";
|
|
3004
|
+
try {
|
|
3005
|
+
named = decodeURIComponent(parts[0] as string);
|
|
3006
|
+
} catch {
|
|
3007
|
+
json(response, 400, { error: "bad id" });
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
// By id, or by the media identity itself: both name the same row.
|
|
3011
|
+
const id = idFrom(named);
|
|
3012
|
+
const action = parts[1];
|
|
3013
|
+
if (action === undefined && request.method === "GET") {
|
|
3014
|
+
const language = languageCode(url.searchParams.get("language"));
|
|
3015
|
+
const format = formatOf(url.searchParams.get("format"));
|
|
3016
|
+
if (language === null) {
|
|
3017
|
+
json(response, 400, { error: "language is a two-letter code, or original" });
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (format === null) {
|
|
3021
|
+
json(response, 400, { error: "format is json, srt, vtt or txt" });
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
const answer = await options.translations.get(id, language, who.id);
|
|
3025
|
+
if (answer.status !== 200 && answer.status !== 202) {
|
|
3026
|
+
json(response, answer.status, { error: answer.error });
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
if (answer.status === 202) {
|
|
3030
|
+
json(response, 202, {
|
|
3031
|
+
...(answer.transcript ? wire(answer.transcript, await store.languages(id)) : { id, language, lines: [], languages: await store.languages(id) }),
|
|
3032
|
+
translating: answer.translating,
|
|
3033
|
+
});
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
const transcript = answer.transcript;
|
|
3037
|
+
if (format === "json") {
|
|
3038
|
+
json(response, 200, wire(transcript, await store.languages(id)));
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
const text = format === "srt" ? toSrt(transcript.lines) : format === "vtt" ? toVtt(transcript.lines) : toText(transcript.lines);
|
|
3042
|
+
const name = (transcript.title || id).replace(/[^\w.-]+/g, "_").slice(0, 60) || "transcript";
|
|
3043
|
+
response.writeHead(200, {
|
|
3044
|
+
...CORS,
|
|
3045
|
+
"content-type": `${format === "vtt" ? "text/vtt" : "text/plain"}; charset=utf-8`,
|
|
3046
|
+
"content-length": Buffer.byteLength(text),
|
|
3047
|
+
"cache-control": "no-store",
|
|
3048
|
+
"content-disposition": `inline; filename="${name}${transcript.language ? `.${transcript.language}` : ""}.${format}"`,
|
|
3049
|
+
});
|
|
3050
|
+
response.end(text);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
if (action === "lines" && request.method === "POST") {
|
|
3054
|
+
let body: { media?: unknown; language?: unknown; translatedFrom?: unknown; model?: unknown; title?: unknown; complete?: unknown; lines?: unknown } = {};
|
|
3055
|
+
try {
|
|
3056
|
+
body = JSON.parse(await readBody(request, 4 * 1024 * 1024)) as typeof body;
|
|
3057
|
+
} catch {
|
|
3058
|
+
json(response, 400, { error: "bad JSON, or more than 4 MB of it" });
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
const media = typeof body.media === "string" ? body.media.trim() : "";
|
|
3062
|
+
if (media === "" || media.length > 2048 || !/^(file|url|live):/.test(media)) {
|
|
3063
|
+
json(response, 400, { error: "media is the identity: file:v1:<hash>, url:<address> or live:<server>/<channel>@<started>" });
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (transcriptIdOf(media) !== id) {
|
|
3067
|
+
json(response, 400, { error: "that media is not this transcript's id" });
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
const language = languageCode(body.language);
|
|
3071
|
+
const translatedFrom = body.translatedFrom === undefined || body.translatedFrom === null ? "" : languageCode(body.translatedFrom);
|
|
3072
|
+
if (language === null || translatedFrom === null) {
|
|
3073
|
+
json(response, 400, { error: "language and translatedFrom are two-letter codes" });
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const lines = linesFrom(body.lines);
|
|
3077
|
+
if (lines.length === 0 && body.complete !== true) {
|
|
3078
|
+
json(response, 400, { error: "lines: a list of {start, end, text}, seconds into the media" });
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const saved = await store.save({
|
|
3082
|
+
media,
|
|
3083
|
+
language,
|
|
3084
|
+
translatedFrom: translatedFrom === "" ? null : translatedFrom,
|
|
3085
|
+
model: typeof body.model === "string" ? body.model.slice(0, 100) : "",
|
|
3086
|
+
title: typeof body.title === "string" ? body.title : "",
|
|
3087
|
+
complete: body.complete === true,
|
|
3088
|
+
by: who.id,
|
|
3089
|
+
lines,
|
|
3090
|
+
});
|
|
3091
|
+
json(response, 200, { id, language: saved?.language ?? language, saved: lines.length, seconds: saved?.seconds ?? 0, complete: saved?.complete ?? false });
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
if (action === undefined && request.method === "DELETE") {
|
|
3095
|
+
const gone = await store.forget(id, who.id);
|
|
3096
|
+
json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "nothing of yours under that id" });
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
json(response, 405, { error: "GET a transcript, POST its lines, or DELETE it" });
|
|
3100
|
+
} catch (error) {
|
|
3101
|
+
if (error instanceof SpeechError) json(response, error.status, { error: error.message });
|
|
3102
|
+
else throw error;
|
|
3103
|
+
}
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/*
|
|
3108
|
+
* MCP over HTTP: the tools `nixamp mcp` offers on a pipe, at
|
|
3109
|
+
* nixamp.com/mcp for an agent with no nixamp installed. A JSON-RPC
|
|
3110
|
+
* message, or a batch of them, in a POST; the answers as JSON. The
|
|
3111
|
+
* bearer token says whose tools they are, as on every other route, and
|
|
3112
|
+
* the tools call nixamp.com as that account. No stream is offered on
|
|
3113
|
+
* GET: nothing here has to be pushed, so a client is told so and gets
|
|
3114
|
+
* on with POST.
|
|
3115
|
+
*/
|
|
3116
|
+
if (path === "/mcp" && options.accounts && options.site) {
|
|
3117
|
+
if (request.method === "GET") {
|
|
3118
|
+
response.writeHead(405, { ...CORS, allow: "POST, DELETE" });
|
|
3119
|
+
response.end();
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
if (request.method === "DELETE") {
|
|
3123
|
+
response.writeHead(204, CORS);
|
|
3124
|
+
response.end();
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
if (request.method !== "POST") {
|
|
3128
|
+
json(response, 405, { error: "POST a JSON-RPC message" });
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
const token = tokenFrom(request.headers);
|
|
3132
|
+
const who = await options.accounts.whoIs(token);
|
|
3133
|
+
if (who === null) {
|
|
3134
|
+
const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
|
|
3135
|
+
response.writeHead(401, {
|
|
3136
|
+
...CORS,
|
|
3137
|
+
"content-type": "application/json; charset=utf-8",
|
|
3138
|
+
"content-length": Buffer.byteLength(error),
|
|
3139
|
+
"www-authenticate": `Bearer resource_metadata="${options.site}/.well-known/oauth-protected-resource"`,
|
|
3140
|
+
});
|
|
3141
|
+
response.end(error);
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
let parsed: unknown;
|
|
3145
|
+
try {
|
|
3146
|
+
parsed = JSON.parse(await readBody(request, 1024 * 1024));
|
|
3147
|
+
} catch {
|
|
3148
|
+
json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
3152
|
+
const session = { site: options.site, token };
|
|
3153
|
+
const answers: unknown[] = [];
|
|
3154
|
+
for (const message of messages) {
|
|
3155
|
+
if (!message || typeof message !== "object" || typeof (message as { method?: unknown }).method !== "string") {
|
|
3156
|
+
answers.push({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } });
|
|
3157
|
+
continue;
|
|
3158
|
+
}
|
|
3159
|
+
const answer = await mcpMessage(message as Parameters<typeof mcpMessage>[0], { session, fetcher: fetch });
|
|
3160
|
+
if (answer) answers.push(answer);
|
|
3161
|
+
}
|
|
3162
|
+
if (answers.length === 0) {
|
|
3163
|
+
response.writeHead(202, CORS);
|
|
3164
|
+
response.end();
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
json(response, 200, Array.isArray(parsed) ? answers : answers[0]);
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
/* Where a client of /mcp finds the authorization server (RFC 9728). */
|
|
3172
|
+
if (path === "/.well-known/oauth-protected-resource" && options.site && options.authServer) {
|
|
3173
|
+
json(response, 200, {
|
|
3174
|
+
resource: options.site,
|
|
3175
|
+
authorization_servers: [options.site],
|
|
3176
|
+
bearer_methods_supported: ["header"],
|
|
3177
|
+
scopes_supported: SCOPE_NAMES,
|
|
3178
|
+
resource_name: "nixamp",
|
|
3179
|
+
});
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
|
|
2888
3183
|
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
2889
3184
|
const handles = options.handles;
|
|
2890
3185
|
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -4094,11 +4389,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4094
4389
|
// reconnecting EventSource saw, which the browser sends by itself.
|
|
4095
4390
|
const lastSeen = request.headers["last-event-id"];
|
|
4096
4391
|
const after = Number(url.searchParams.get("after") ?? (Array.isArray(lastSeen) ? lastSeen[0] : lastSeen) ?? 0) || 0;
|
|
4392
|
+
// The lines in another language, translated as they are heard; "" is what was said.
|
|
4393
|
+
const wanted = languageCode(url.searchParams.get("language"));
|
|
4394
|
+
if (wanted === null) {
|
|
4395
|
+
json(response, 400, { error: "language is a two-letter code, such as de or sv" });
|
|
4396
|
+
return;
|
|
4397
|
+
}
|
|
4097
4398
|
if (action === "transcript") {
|
|
4098
4399
|
// Asking keeps the captioner up: it stops a minute after the last ask.
|
|
4099
|
-
captions.subscribe(id, () => undefined)?.();
|
|
4400
|
+
captions.subscribe(id, () => undefined, wanted)?.();
|
|
4100
4401
|
json(response, 200, {
|
|
4101
|
-
channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), ...captions.status(id), lines: captions.recent(id, after),
|
|
4402
|
+
channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), wanted, ...captions.status(id), lines: captions.recent(id, after, wanted),
|
|
4102
4403
|
});
|
|
4103
4404
|
return;
|
|
4104
4405
|
}
|
|
@@ -4112,14 +4413,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4112
4413
|
const write = (event: string, data: unknown, eventId?: number): void => {
|
|
4113
4414
|
response.write(`${eventId === undefined ? "" : `id: ${eventId}\n`}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
4114
4415
|
};
|
|
4115
|
-
const off = captions.subscribe(id, (line) => write("line", line, line.at));
|
|
4416
|
+
const off = captions.subscribe(id, (line) => write("line", line, line.at), wanted);
|
|
4116
4417
|
if (off === null) {
|
|
4117
4418
|
response.end();
|
|
4118
4419
|
return;
|
|
4119
4420
|
}
|
|
4120
4421
|
// How far behind the live edge a newcomer's playback starts, so the
|
|
4121
4422
|
// page can hold each line until its own sound gets there.
|
|
4122
|
-
write("hello", { channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), ...captions.status(id), lines: captions.recent(id, after) });
|
|
4423
|
+
write("hello", { channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), wanted, ...captions.status(id), lines: captions.recent(id, after, wanted) });
|
|
4123
4424
|
const beat = setInterval(() => response.write(": beat\n\n"), 20_000);
|
|
4124
4425
|
beat.unref?.();
|
|
4125
4426
|
const done = (): void => {
|
|
@@ -5498,6 +5799,22 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5498
5799
|
console.error(ready ? `nixamp: hearing with ${speech.model}` : `nixamp: not hearing: ${speech.lastFailure}`);
|
|
5499
5800
|
});
|
|
5500
5801
|
}
|
|
5802
|
+
// Translation lives beside the ear: the same optional library, a model
|
|
5803
|
+
// per pair of languages, loaded when first asked for. NIXAMP_MT_WARM
|
|
5804
|
+
// names pairs to load at boot (en-de,en-sv) so the first line does not
|
|
5805
|
+
// wait; NIXAMP_MT=off turns it off.
|
|
5806
|
+
const translator = accounts && process.env["NIXAMP_MT"] !== "off" ? new Translator() : undefined;
|
|
5807
|
+
if (translator) {
|
|
5808
|
+
const warm = (process.env["NIXAMP_MT_WARM"] ?? "").split(",").map((one) => one.trim()).filter(Boolean);
|
|
5809
|
+
if (warm.length > 0) {
|
|
5810
|
+
void translator.warm(warm).then((ready) => {
|
|
5811
|
+
console.error(ready ? `nixamp: translating (${warm.join(", ")} loaded)` : `nixamp: not translating everything: ${translator.lastFailure}`);
|
|
5812
|
+
});
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
// What was heard, kept: where the accounts are, under the media's identity.
|
|
5816
|
+
const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
|
|
5817
|
+
const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message)) : undefined;
|
|
5501
5818
|
|
|
5502
5819
|
// nixamp as an OAuth 2.1 authorization server, and the watch parties a
|
|
5503
5820
|
// client site bridges through it. Both need the same three things -- a
|
|
@@ -5692,6 +6009,29 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5692
6009
|
listen: (id, listener) => channels.listen(id, listener),
|
|
5693
6010
|
session: () => readSession(),
|
|
5694
6011
|
onEvent: (message) => console.log(` ${message}`),
|
|
6012
|
+
// What a channel is playing, for the transcript store: a film by
|
|
6013
|
+
// its bytes and where it has got to, a link by its address, and
|
|
6014
|
+
// anything live as the broadcast it is.
|
|
6015
|
+
mediaOf: (id) => {
|
|
6016
|
+
const info = channels.info(id);
|
|
6017
|
+
if (!info) return null;
|
|
6018
|
+
const base = { title: info.name, startedAt: info.startedAt, backlog: info.codecs?.video ? BACKLOG_SECONDS : 3 };
|
|
6019
|
+
if (info.via === "pull" && !info.live && typeof info.position === "number" && info.source) {
|
|
6020
|
+
if (/^https?:\/\//.test(info.source)) {
|
|
6021
|
+
// The pasted address while it is still known: it outlives a media URL with a token in it.
|
|
6022
|
+
let pasted = info.source;
|
|
6023
|
+
for (const [link] of links) if (linkChannelId(link) === id) pasted = link;
|
|
6024
|
+
return { ...base, media: mediaOfUrl(pasted), position: info.position };
|
|
6025
|
+
}
|
|
6026
|
+
try {
|
|
6027
|
+
return { ...base, media: fileFingerprint(info.source), position: info.position };
|
|
6028
|
+
} catch {
|
|
6029
|
+
return null;
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
const self = publishable_?.url || options.publicUrl || `${options.host}:${options.port}`;
|
|
6033
|
+
return { ...base, media: mediaOfLive(self, id, info.startedAt) };
|
|
6034
|
+
},
|
|
5695
6035
|
})
|
|
5696
6036
|
: undefined;
|
|
5697
6037
|
const owner = new Owner({
|
|
@@ -5903,6 +6243,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5903
6243
|
...(rooms ? { rooms } : {}),
|
|
5904
6244
|
...(trollbox ? { trollbox } : {}),
|
|
5905
6245
|
...(speech ? { speech } : {}),
|
|
6246
|
+
...(translator ? { translator } : {}),
|
|
6247
|
+
...(transcripts ? { transcripts } : {}),
|
|
6248
|
+
...(translations ? { translations } : {}),
|
|
6249
|
+
site: nixampSite,
|
|
5906
6250
|
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
5907
6251
|
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
5908
6252
|
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
package/src/speech.ts
CHANGED
|
@@ -47,14 +47,31 @@ export class SpeechError extends Error {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A stretch of the clip and what was said in it, seconds from the clip's start. */
|
|
51
|
+
export interface Segment {
|
|
52
|
+
start: number;
|
|
53
|
+
end: number;
|
|
54
|
+
text: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export interface Heard {
|
|
51
58
|
text: string;
|
|
52
59
|
/** How long the audio was, in seconds. */
|
|
53
60
|
seconds: number;
|
|
61
|
+
/** The language heard, as told or as guessed; absent when neither was possible. */
|
|
62
|
+
language?: string;
|
|
63
|
+
/** The clip in pieces with their timing, when asked for. */
|
|
64
|
+
segments?: Segment[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RecognizeOptions {
|
|
68
|
+
language?: string;
|
|
69
|
+
/** Also say when each piece was said. Slower: the ear takes about twice as long. */
|
|
70
|
+
timestamps?: boolean;
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
/** Mono samples in [-1, 1] at RATE -> words. What the loaded model is, to this file. */
|
|
57
|
-
export type Recognizer = (pcm: Float32Array, options:
|
|
74
|
+
export type Recognizer = (pcm: Float32Array, options: RecognizeOptions) => Promise<{ text: string; language?: string; segments?: Segment[] }>;
|
|
58
75
|
|
|
59
76
|
export interface SpeechOptions {
|
|
60
77
|
/** A Hugging Face model id; NIXAMP_STT_MODEL otherwise; whisper-base by default. */
|
|
@@ -205,21 +222,36 @@ export function languageOf(value: unknown): string | undefined {
|
|
|
205
222
|
return /^[a-z]{2}$/.test(code) ? code : undefined;
|
|
206
223
|
}
|
|
207
224
|
|
|
225
|
+
/** What the pipeline answers: the words, and the pieces with their timing when asked. */
|
|
226
|
+
interface AsrOutput {
|
|
227
|
+
text: string;
|
|
228
|
+
chunks?: { timestamp: [number, number | null]; text: string }[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The pipeline, and the parts under it that language detection needs. */
|
|
232
|
+
interface AsrPipeline {
|
|
233
|
+
(audio: Float32Array, options: Record<string, unknown>): Promise<AsrOutput | AsrOutput[]>;
|
|
234
|
+
model: {
|
|
235
|
+
(inputs: Record<string, unknown>): Promise<{ logits: { data: Float32Array | number[] } }>;
|
|
236
|
+
generation_config: { decoder_start_token_id: number; is_multilingual?: boolean; lang_to_id?: Record<string, number> };
|
|
237
|
+
};
|
|
238
|
+
processor: (audio: Float32Array) => Promise<{ input_features: unknown }>;
|
|
239
|
+
}
|
|
240
|
+
|
|
208
241
|
/** The module, as much of it as this file touches. Typed here so the import can be by name. */
|
|
209
242
|
interface Transformers {
|
|
210
243
|
env: { cacheDir?: string; allowLocalModels?: boolean };
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
>;
|
|
244
|
+
Tensor: new (type: string, data: BigInt64Array, dims: number[]) => unknown;
|
|
245
|
+
pipeline(task: "automatic-speech-recognition", model: string, options: { dtype: string }): Promise<AsrPipeline>;
|
|
214
246
|
}
|
|
215
247
|
|
|
216
|
-
|
|
248
|
+
/** Load the library, or say why not with a status. */
|
|
249
|
+
export async function loadTransformers<T>(): Promise<T> {
|
|
217
250
|
// By name held in a variable: an optional dependency that is not on disk
|
|
218
251
|
// must fail here, at the first ask, and not when the file is imported.
|
|
219
252
|
const name = "@huggingface/transformers";
|
|
220
|
-
let transformers: Transformers;
|
|
221
253
|
try {
|
|
222
|
-
|
|
254
|
+
return (await import(name)) as T;
|
|
223
255
|
} catch (error) {
|
|
224
256
|
// The reason travels with the refusal: "not installed" and "installed
|
|
225
257
|
// but its native runtime would not load" need different fixes, and the
|
|
@@ -227,16 +259,63 @@ async function loadWhisper(model: string, cacheDir: string): Promise<Recognizer>
|
|
|
227
259
|
const why = String((error as Error).message ?? error).split("\n")[0] ?? "";
|
|
228
260
|
throw new SpeechError(`this nixamp cannot hear: @huggingface/transformers did not load here (${why}). nixamp.com can.`, 503);
|
|
229
261
|
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Which language a clip is in, the way whisper.cpp asks: the encoder once
|
|
266
|
+
* over the first thirty seconds, one decoder step from the start token,
|
|
267
|
+
* and the loudest of the language tokens wins. Whisper told nothing
|
|
268
|
+
* assumes English, and a Swedish sentence heard as English comes back as
|
|
269
|
+
* the same three English words repeated to the end of the clip; guessing
|
|
270
|
+
* first costs an encoder pass and is what makes a caption in Swedish say
|
|
271
|
+
* something. Undefined for a model that knows one language.
|
|
272
|
+
*/
|
|
273
|
+
async function detectLanguage(transformers: Transformers, asr: AsrPipeline, pcm: Float32Array): Promise<string | undefined> {
|
|
274
|
+
const config = asr.model.generation_config;
|
|
275
|
+
const languages = config.lang_to_id;
|
|
276
|
+
if (!languages || config.is_multilingual === false) return undefined;
|
|
277
|
+
const inputs = await asr.processor(pcm.subarray(0, 30 * RATE));
|
|
278
|
+
const start = new transformers.Tensor("int64", BigInt64Array.from([BigInt(config.decoder_start_token_id)]), [1, 1]);
|
|
279
|
+
const out = await asr.model({ input_features: inputs.input_features, decoder_input_ids: start });
|
|
280
|
+
const logits = out.logits.data;
|
|
281
|
+
let best: string | undefined;
|
|
282
|
+
let loudest = -Infinity;
|
|
283
|
+
for (const [token, id] of Object.entries(languages)) {
|
|
284
|
+
const score = logits[id];
|
|
285
|
+
if (typeof score === "number" && score > loudest) {
|
|
286
|
+
loudest = score;
|
|
287
|
+
best = token;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// The token is written <|sv|>.
|
|
291
|
+
const code = best?.replace(/^<\|/, "").replace(/\|>$/, "");
|
|
292
|
+
return code && /^[a-z]{2,3}$/.test(code) ? code : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function loadWhisper(model: string, cacheDir: string): Promise<Recognizer> {
|
|
296
|
+
const transformers = await loadTransformers<Transformers>();
|
|
230
297
|
transformers.env.cacheDir = cacheDir;
|
|
231
298
|
const recognize = await transformers.pipeline("automatic-speech-recognition", model, { dtype: "q8" });
|
|
232
|
-
return async (pcm, { language }) => {
|
|
299
|
+
return async (pcm, { language, timestamps }) => {
|
|
300
|
+
const spoken = language ?? (await detectLanguage(transformers, recognize, pcm));
|
|
233
301
|
const heard = await recognize(pcm, {
|
|
234
302
|
// Whisper hears thirty seconds at a time; longer is heard in overlapping pieces.
|
|
235
303
|
chunk_length_s: 30,
|
|
236
304
|
stride_length_s: 5,
|
|
237
|
-
...(
|
|
305
|
+
...(spoken ? { language: spoken, task: "transcribe" } : {}),
|
|
306
|
+
...(timestamps ? { return_timestamps: true } : {}),
|
|
238
307
|
});
|
|
239
|
-
|
|
308
|
+
const pieces = Array.isArray(heard) ? heard : [heard];
|
|
309
|
+
const text = pieces.map((piece) => piece.text).join(" ");
|
|
310
|
+
const seconds = pcm.length / RATE;
|
|
311
|
+
const segments = timestamps
|
|
312
|
+
? pieces.flatMap((piece) => piece.chunks ?? []).map((chunk) => ({
|
|
313
|
+
start: Math.max(0, chunk.timestamp[0] ?? 0),
|
|
314
|
+
end: Math.min(seconds, chunk.timestamp[1] ?? seconds),
|
|
315
|
+
text: chunk.text,
|
|
316
|
+
}))
|
|
317
|
+
: undefined;
|
|
318
|
+
return { text, ...(spoken ? { language: spoken } : {}), ...(segments ? { segments } : {}) };
|
|
240
319
|
};
|
|
241
320
|
}
|
|
242
321
|
|
|
@@ -306,7 +385,7 @@ export class Speech {
|
|
|
306
385
|
* The words in a WAV. Refuses what is not a WAV, or is too long, or more
|
|
307
386
|
* than the account may have heard this minute, with a status.
|
|
308
387
|
*/
|
|
309
|
-
async transcribe(bytes: Uint8Array, options: { language?: string; by?: string } = {}): Promise<Heard> {
|
|
388
|
+
async transcribe(bytes: Uint8Array, options: { language?: string; timestamps?: boolean; by?: string } = {}): Promise<Heard> {
|
|
310
389
|
if (bytes.length > MAX_BYTES) throw new SpeechError(`that is too much sound: ${MAX_SECONDS} seconds at most`, 413);
|
|
311
390
|
const wav = decodeWav(bytes);
|
|
312
391
|
const seconds = wav.samples.length / wav.rate;
|
|
@@ -318,13 +397,24 @@ export class Speech {
|
|
|
318
397
|
this.waiting += 1;
|
|
319
398
|
const turn = this.tail.then(async () => {
|
|
320
399
|
const recognize = await this.ear();
|
|
321
|
-
return recognize(pcm,
|
|
400
|
+
return recognize(pcm, {
|
|
401
|
+
...(options.language ? { language: options.language } : {}),
|
|
402
|
+
...(options.timestamps ? { timestamps: true } : {}),
|
|
403
|
+
});
|
|
322
404
|
});
|
|
323
405
|
// The queue moves on whether or not this one was heard.
|
|
324
406
|
this.tail = turn.catch(() => undefined);
|
|
325
407
|
try {
|
|
326
408
|
const heard = await turn;
|
|
327
|
-
|
|
409
|
+
const segments = heard.segments
|
|
410
|
+
?.map((segment) => ({ start: segment.start, end: segment.end, text: tidy(segment.text) }))
|
|
411
|
+
.filter((segment) => segment.text !== "");
|
|
412
|
+
return {
|
|
413
|
+
text: tidy(heard.text),
|
|
414
|
+
seconds,
|
|
415
|
+
...(heard.language ? { language: heard.language } : {}),
|
|
416
|
+
...(segments ? { segments } : {}),
|
|
417
|
+
};
|
|
328
418
|
} finally {
|
|
329
419
|
this.waiting -= 1;
|
|
330
420
|
}
|