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.
Files changed (49) hide show
  1. package/README.md +79 -3
  2. package/dist/captions.d.ts +57 -10
  3. package/dist/captions.js +253 -29
  4. package/dist/main.js +63 -19
  5. package/dist/mcp.d.ts +7 -1
  6. package/dist/mcp.js +159 -24
  7. package/dist/server.d.ts +11 -0
  8. package/dist/server.js +353 -8
  9. package/dist/speech.d.ts +21 -3
  10. package/dist/speech.js +65 -8
  11. package/dist/transcribe.d.ts +70 -0
  12. package/dist/transcribe.js +337 -41
  13. package/dist/transcript-client.d.ts +81 -0
  14. package/dist/transcript-client.js +76 -0
  15. package/dist/transcript.d.ts +7 -2
  16. package/dist/transcript.js +73 -5
  17. package/dist/transcripts.d.ts +135 -0
  18. package/dist/transcripts.js +384 -0
  19. package/dist/translate-cli.d.ts +16 -0
  20. package/dist/translate-cli.js +97 -0
  21. package/dist/translate-jobs.d.ts +39 -0
  22. package/dist/translate-jobs.js +120 -0
  23. package/dist/translate.d.ts +78 -0
  24. package/dist/translate.js +241 -0
  25. package/dist/warm.d.ts +1 -0
  26. package/dist/warm.js +40 -0
  27. package/package.json +1 -1
  28. package/src/captions.ts +276 -29
  29. package/src/main.ts +63 -19
  30. package/src/mcp.ts +156 -21
  31. package/src/server.ts +352 -8
  32. package/src/speech.ts +103 -13
  33. package/src/transcribe.ts +374 -41
  34. package/src/transcript-client.ts +147 -0
  35. package/src/transcript.ts +76 -4
  36. package/src/transcripts.ts +462 -0
  37. package/src/translate-cli.ts +111 -0
  38. package/src/translate-jobs.ts +132 -0
  39. package/src/translate.ts +276 -0
  40. package/src/warm.ts +43 -0
  41. package/web/dist/assets/{hls-3VKVEQE3-CI1U7kbP.js → hls-3VKVEQE3-Dtl-3mpW.js} +1 -1
  42. package/web/dist/assets/index-B0h4Nexr.js +1 -0
  43. package/web/dist/assets/index-BTGV3Pi5.css +1 -0
  44. package/web/dist/assets/{mpegts-CPOYjgRP.js → mpegts-BJC48bFV.js} +1 -1
  45. package/web/dist/assets/{mpegts-LO6RVLD6-CE8YPjx1.js → mpegts-LO6RVLD6-CUIAB9k3.js} +1 -1
  46. package/web/dist/index.html +12 -8
  47. package/web/dist/sw.js +6 -6
  48. package/web/dist/assets/index-46pwGn5-.css +0 -1
  49. 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, { language: languageOf(url.searchParams.get("language")), by: who.id });
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
- text: heard.text, seconds: heard.seconds, model: speech.model,
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, { text: heard.text, seconds: heard.seconds, model: speech.model });
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: { language?: string }) => Promise<{ text: string }>;
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
- pipeline(task: "automatic-speech-recognition", model: string, options: { dtype: string }): Promise<
212
- (audio: Float32Array, options: Record<string, unknown>) => Promise<{ text: string } | { text: string }[]>
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
- async function loadWhisper(model: string, cacheDir: string): Promise<Recognizer> {
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
- transformers = (await import(name)) as Transformers;
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
- ...(language ? { language, task: "transcribe" } : {}),
305
+ ...(spoken ? { language: spoken, task: "transcribe" } : {}),
306
+ ...(timestamps ? { return_timestamps: true } : {}),
238
307
  });
239
- return { text: Array.isArray(heard) ? heard.map((piece) => piece.text).join(" ") : heard.text };
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, options.language ? { language: options.language } : {});
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
- return { text: tidy(heard.text), seconds };
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
  }