nixamp 0.23.3 → 0.23.5

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 CHANGED
@@ -309,8 +309,19 @@ A line can be said rather than typed. The microphone button beside the box
309
309
  is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself,
310
310
  and sends nixamp.com a small WAV with the room's name on it; the ear posts
311
311
  the words to the room in the same request, and the line appears. Said is
312
- sent. A line you would rather see first is typed; a wrong one is taken down
312
+ sent, unless the **Edit before sending** switch under the box is on, in
313
+ which case the words wait in the box for Send. A wrong line is taken down
313
314
  with its ✕.
315
+
316
+ **On the phone, too.** Every live room has a six-digit code on the party
317
+ line (see below), and when anybody is on the phone in a room, each trollbox
318
+ line is read aloud to them: "chovy says: …", in a voice that is theirs as
319
+ far as a machine can manage. The Account panel sets it: a woman's voice, a
320
+ man's, any, or a provider voice id; or an OpenProfile URL, whose `Voice`,
321
+ `Gender` or `Pronouns` decide. Nothing set picks one from the account, so
322
+ the same person is always the same voice. The voices are Telnyx's Kokoro
323
+ ones, open weights, no bill beyond the call; `NIXAMP_VOICE_FEMALE` and
324
+ `NIXAMP_VOICE_MALE` swap in others (an ElevenLabs id, say).
314
325
  The ear is [Whisper](https://github.com/openai/whisper) run through
315
326
  [Transformers.js](https://github.com/huggingface/transformers.js), an
316
327
  Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
@@ -61,8 +61,10 @@ export declare function preferPlus(source: string): string;
61
61
  export declare function entryId(source: string): string;
62
62
  /**
63
63
  * Parse an extended m3u: `#EXTINF:-1 tvg-name="CNN" group-title="News",CNN HD`
64
- * decorates the URL on the next line, and `#EXTGRP:News` names the group for
65
- * lists that write it that way instead.
64
+ * decorates the URL on the next line, `#EXTGRP:News` names the group for
65
+ * lists that write it that way instead, and `#EXTIMG:https://...` is the
66
+ * picture for lists (a podcast's episodes, say) that carry one that way
67
+ * rather than as a tvg-logo attribute.
66
68
  */
67
69
  export declare function parseCatalog(text: string, base: string): CatalogEntry[];
68
70
  export interface Group {
package/dist/catalogs.js CHANGED
Binary file
package/dist/handles.d.ts CHANGED
@@ -11,6 +11,24 @@
11
11
  * field and never travel in the same response.
12
12
  */
13
13
  import type { Queryable } from "./follows.ts";
14
+ /** What the room knows about somebody: the name, how they sound, where the rest is written. */
15
+ export interface Persona {
16
+ handle: string;
17
+ /** female, male, "" for whichever, or a provider voice id they named. */
18
+ voice: string;
19
+ /** The URL of their OpenProfile.md, or "". */
20
+ profile: string;
21
+ }
22
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
23
+ export declare function cleanVoice(value: unknown): {
24
+ voice: string;
25
+ error: string;
26
+ };
27
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
28
+ export declare function cleanProfile(value: unknown): {
29
+ profile: string;
30
+ error: string;
31
+ };
14
32
  /**
15
33
  * What a handle may be.
16
34
  *
@@ -35,6 +53,20 @@ export declare class Handles {
35
53
  constructor(db: Queryable);
36
54
  private ensure;
37
55
  of(userId: string): Promise<string>;
56
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
57
+ persona(userId: string): Promise<Persona>;
58
+ /**
59
+ * How somebody sounds, and where their profile is. Either may be given
60
+ * alone; what is not given is kept. A row exists only once a handle does,
61
+ * so somebody who never picked one is given the fallback handle first.
62
+ */
63
+ describe(userId: string, fallbackHandle: string, wanted: {
64
+ voice?: unknown;
65
+ profile?: unknown;
66
+ }): Promise<{
67
+ persona: Persona | null;
68
+ error: string;
69
+ }>;
38
70
  /**
39
71
  * Handles for a page of rows, in one query.
40
72
  *
package/dist/handles.js CHANGED
@@ -7,7 +7,46 @@ const SCHEMA = `
7
7
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
8
8
  );
9
9
  CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
10
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS voice TEXT NOT NULL DEFAULT '';
11
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT '';
10
12
  `;
13
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
14
+ export function cleanVoice(value) {
15
+ if (value === undefined || value === null)
16
+ return { voice: "", error: "" };
17
+ if (typeof value !== "string")
18
+ return { voice: "", error: "a voice is a word" };
19
+ const word = value.trim();
20
+ if (word === "" || word === "any")
21
+ return { voice: "", error: "" };
22
+ const lower = word.toLowerCase();
23
+ if (lower === "female" || lower === "male")
24
+ return { voice: lower, error: "" };
25
+ if (/^[A-Za-z][A-Za-z0-9_.-]{2,80}$/.test(word) && word.includes("."))
26
+ return { voice: word, error: "" };
27
+ return { voice: "", error: "female, male, any, or a voice id like Telnyx.KokoroTTS.am_adam" };
28
+ }
29
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
30
+ export function cleanProfile(value) {
31
+ if (value === undefined || value === null)
32
+ return { profile: "", error: "" };
33
+ if (typeof value !== "string")
34
+ return { profile: "", error: "a profile is a URL" };
35
+ const text = value.trim();
36
+ if (text === "")
37
+ return { profile: "", error: "" };
38
+ try {
39
+ const url = new URL(text);
40
+ if (url.protocol !== "https:" && url.protocol !== "http:")
41
+ return { profile: "", error: "a profile is an http(s) URL" };
42
+ if (url.href.length > 500)
43
+ return { profile: "", error: "that URL is too long" };
44
+ return { profile: url.href, error: "" };
45
+ }
46
+ catch {
47
+ return { profile: "", error: "a profile is a URL, like https://you.example/.well-known/openprofile.md" };
48
+ }
49
+ }
11
50
  /**
12
51
  * What a handle may be.
13
52
  *
@@ -69,6 +108,36 @@ export class Handles {
69
108
  const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
70
109
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
71
110
  }
111
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
112
+ async persona(userId) {
113
+ await this.ensure();
114
+ const { rows } = await this.db.query(`SELECT handle, voice, profile FROM ${TABLE} WHERE user_id = $1`, [userId]);
115
+ const row = rows[0];
116
+ return {
117
+ handle: row ? String(row["handle"] ?? "") : "",
118
+ voice: row ? String(row["voice"] ?? "") : "",
119
+ profile: row ? String(row["profile"] ?? "") : "",
120
+ };
121
+ }
122
+ /**
123
+ * How somebody sounds, and where their profile is. Either may be given
124
+ * alone; what is not given is kept. A row exists only once a handle does,
125
+ * so somebody who never picked one is given the fallback handle first.
126
+ */
127
+ async describe(userId, fallbackHandle, wanted) {
128
+ const voice = wanted.voice === undefined ? null : cleanVoice(wanted.voice);
129
+ if (voice?.error)
130
+ return { persona: null, error: voice.error };
131
+ const profile = wanted.profile === undefined ? null : cleanProfile(wanted.profile);
132
+ if (profile?.error)
133
+ return { persona: null, error: profile.error };
134
+ await this.ensure();
135
+ const had = await this.persona(userId);
136
+ const handle = had.handle || cleanHandle(fallbackHandle) || fallbackHandle;
137
+ await this.db.query(`INSERT INTO ${TABLE} (user_id, handle, voice, profile) VALUES ($1, $2, $3, $4)
138
+ ON CONFLICT (user_id) DO UPDATE SET voice = EXCLUDED.voice, profile = EXCLUDED.profile, updated_at = NOW()`, [userId, handle, voice ? voice.voice : had.voice, profile ? profile.profile : had.profile]);
139
+ return { persona: await this.persona(userId), error: "" };
140
+ }
72
141
  /**
73
142
  * Handles for a page of rows, in one query.
74
143
  *
package/dist/links.d.ts CHANGED
@@ -68,6 +68,7 @@ export declare const PLAYLIST_FETCH_TIMEOUT_MS = 15000;
68
68
  export declare function playlistFrom(text: string, base: string): {
69
69
  hls: boolean;
70
70
  sources: string[];
71
+ image: string;
71
72
  };
72
73
  /**
73
74
  * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
package/dist/links.js CHANGED
@@ -97,12 +97,14 @@ export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
97
97
  */
98
98
  export function playlistFrom(text, base) {
99
99
  if (/^#EXT-X-/m.test(text))
100
- return { hls: true, sources: [] };
101
- const sources = parseCatalog(text, base)
102
- .map((entry) => entry.source)
103
- .filter((source) => /^(https?|rtmps?):\/\//i.test(source))
100
+ return { hls: true, sources: [], image: "" };
101
+ const entries = parseCatalog(text, base)
102
+ .filter((entry) => /^(https?|rtmps?):\/\//i.test(entry.source))
104
103
  .slice(0, PLAYLIST_MAX_ENTRIES);
105
- return { hls: false, sources };
104
+ // The picture of the list is the first picture in it: a podcast's episodes
105
+ // all wear the show's art, and the newest is first.
106
+ const image = entries.map((entry) => pictureUrl(entry.logo)).find((one) => one !== "") ?? "";
107
+ return { hls: false, sources: entries.map((entry) => entry.source), image };
106
108
  }
107
109
  /**
108
110
  * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
@@ -147,7 +149,7 @@ export async function resolvePlaylist(url, options = {}) {
147
149
  extractor: "playlist",
148
150
  ext: "",
149
151
  page: url,
150
- thumbnail: "",
152
+ thumbnail: list.image,
151
153
  about: "",
152
154
  playlist: list.sources,
153
155
  };
@@ -255,6 +255,15 @@ export declare class PartyLine {
255
255
  * the room do not hear every arrival welcomed.
256
256
  */
257
257
  private greet;
258
+ /**
259
+ * A line said into a room, to everybody on the phone in it. What the
260
+ * trollbox becomes on the party line: the handle and the words, in the
261
+ * voice of whoever typed them. False when nobody is on the phone there,
262
+ * which is most of the time and costs nothing.
263
+ */
264
+ say(code: string, text: string, voice: string): Promise<boolean>;
265
+ /** Whether anybody is on the phone in a room, before a line is worth reading. */
266
+ hasCallers(code: string): boolean;
258
267
  /**
259
268
  * How many people are on the phone for a stream.
260
269
  *
package/dist/partyline.js CHANGED
@@ -583,6 +583,30 @@ export class PartyLine {
583
583
  call_control_ids: [leg],
584
584
  });
585
585
  }
586
+ /**
587
+ * A line said into a room, to everybody on the phone in it. What the
588
+ * trollbox becomes on the party line: the handle and the words, in the
589
+ * voice of whoever typed them. False when nobody is on the phone there,
590
+ * which is most of the time and costs nothing.
591
+ */
592
+ async say(code, text, voice) {
593
+ const room = this.rooms.get(code);
594
+ if (!room || room.conferenceId === null || room.callers === 0)
595
+ return false;
596
+ const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
597
+ if (said === "")
598
+ return false;
599
+ const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
600
+ payload: said,
601
+ voice: voice || this.voice,
602
+ });
603
+ return answer !== null;
604
+ }
605
+ /** Whether anybody is on the phone in a room, before a line is worth reading. */
606
+ hasCallers(code) {
607
+ const room = this.rooms.get(code);
608
+ return room !== undefined && room.conferenceId !== null && room.callers > 0;
609
+ }
586
610
  /**
587
611
  * How many people are on the phone for a stream.
588
612
  *
package/dist/server.d.ts CHANGED
@@ -32,6 +32,7 @@ import { Rooms } from "./rooms.ts";
32
32
  import { Trollbox } from "./trollbox.ts";
33
33
  import { Speech } from "./speech.ts";
34
34
  import { Captions } from "./captions.ts";
35
+ import { Profiles } from "./voices.ts";
35
36
  import { type Codecs } from "./audio.ts";
36
37
  import { type Tools, type Track } from "./audio.ts";
37
38
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
@@ -698,6 +699,8 @@ export interface HandlerOptions {
698
699
  trollbox?: Trollbox;
699
700
  /** Speech to text: a line said out loud, heard here. Needs the optional model. */
700
701
  speech?: Speech;
702
+ /** Other people's OpenProfiles, for the voice their lines are read in. */
703
+ profiles?: Profiles;
701
704
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
702
705
  tickets?: Tickets;
703
706
  /**
package/dist/server.js CHANGED
@@ -67,6 +67,7 @@ import { Rooms } from "./rooms.js";
67
67
  import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
68
68
  import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.js";
69
69
  import { Captions } from "./captions.js";
70
+ import { Profiles, spokenLine, telnyxVoiceFor, voicesFromEnv } from "./voices.js";
70
71
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
71
72
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
72
73
  import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
@@ -2186,6 +2187,35 @@ export function createHandler(engine, options) {
2186
2187
  // Separate from the address on purpose. The address is a credential and
2187
2188
  // a way to reach somebody; publishing it in a directory listing or an
2188
2189
  // invite would be publishing what they log in with.
2190
+ /**
2191
+ * A trollbox line, read aloud to whoever is on the phone in that room.
2192
+ * The room's code comes from the directory (one per channel, one for
2193
+ * the server's own stream), the voice from the author's account or
2194
+ * OpenProfile. Never awaited by the poster: the phone is a side effect
2195
+ * of the chat, and a slow Telnyx must not slow the box.
2196
+ */
2197
+ const readOnThePhone = (where, authorId, handle, body) => {
2198
+ const partyLine = options.partyLine;
2199
+ const listing = (options.directory?.list() ?? []).find((one) => {
2200
+ try {
2201
+ return new URL(one.url).origin === where.server;
2202
+ }
2203
+ catch {
2204
+ return false;
2205
+ }
2206
+ });
2207
+ if (!partyLine || !listing)
2208
+ return;
2209
+ const code = where.channel === "live" || where.channel === "main" ? listing.code : listing.channelCodes[where.channel] ?? "";
2210
+ if (!code || !partyLine.hasCallers(code))
2211
+ return;
2212
+ void (async () => {
2213
+ const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
2214
+ const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
2215
+ const voice = telnyxVoiceFor({ userId: authorId, voice: persona.voice, profile }, voicesFromEnv());
2216
+ await partyLine.say(code, spokenLine(handle, body), voice);
2217
+ })().catch(() => undefined);
2218
+ };
2189
2219
  /*
2190
2220
  * The trollbox: the chat for one live room, keyed by the server and
2191
2221
  * the channel so every viewer of a stream is in the same box whichever
@@ -2235,6 +2265,7 @@ export function createHandler(engine, options) {
2235
2265
  }
2236
2266
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2237
2267
  const line = await trollbox.post(where, who.id, handle, body.body);
2268
+ readOnThePhone(where, who.id, line.handle, line.body);
2238
2269
  json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
2239
2270
  return;
2240
2271
  }
@@ -2306,6 +2337,7 @@ export function createHandler(engine, options) {
2306
2337
  if (where && options.trollbox && heard.text !== "") {
2307
2338
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2308
2339
  const line = await options.trollbox.post(where, who.id, handle, heard.text);
2340
+ readOnThePhone(where, who.id, line.handle, line.body);
2309
2341
  json(response, 201, {
2310
2342
  text: heard.text, seconds: heard.seconds, model: speech.model,
2311
2343
  message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
@@ -2329,8 +2361,12 @@ export function createHandler(engine, options) {
2329
2361
  json(response, 401, { error: "not signed in" });
2330
2362
  return;
2331
2363
  }
2364
+ // The handle, and since 0.23.4 the voice a line is read in on the
2365
+ // phone and the OpenProfile it may be read from. Any of the three may
2366
+ // be sent alone; what is not sent is kept.
2332
2367
  if (request.method === "GET") {
2333
- json(response, 200, { handle: await handles.of(who.id) });
2368
+ const persona = await handles.persona(who.id);
2369
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2334
2370
  return;
2335
2371
  }
2336
2372
  if (request.method === "PUT" || request.method === "POST") {
@@ -2342,12 +2378,22 @@ export function createHandler(engine, options) {
2342
2378
  json(response, 400, { error: "bad JSON" });
2343
2379
  return;
2344
2380
  }
2345
- const claimed = await handles.claim(who.id, body.handle);
2346
- if (claimed.error) {
2347
- json(response, 409, { error: claimed.error });
2348
- return;
2381
+ if (body.handle !== undefined) {
2382
+ const claimed = await handles.claim(who.id, body.handle);
2383
+ if (claimed.error) {
2384
+ json(response, 409, { error: claimed.error });
2385
+ return;
2386
+ }
2349
2387
  }
2350
- json(response, 200, { handle: claimed.handle });
2388
+ if (body.voice !== undefined || body.profile !== undefined) {
2389
+ const described = await handles.describe(who.id, fallbackHandle(who.id), { voice: body.voice, profile: body.profile });
2390
+ if (described.error) {
2391
+ json(response, 422, { error: described.error });
2392
+ return;
2393
+ }
2394
+ }
2395
+ const persona = await handles.persona(who.id);
2396
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2351
2397
  return;
2352
2398
  }
2353
2399
  json(response, 405, { error: "GET or PUT" });
@@ -3117,6 +3163,18 @@ export function createHandler(engine, options) {
3117
3163
  return;
3118
3164
  }
3119
3165
  links.set(link, resolved);
3166
+ // A channel already on -- put back by a restart, which forgets where
3167
+ // it came from -- learns its picture and its line from the link pasted
3168
+ // again, rather than waiting to be taken off and put on.
3169
+ const already = options.channels.info(channelId);
3170
+ if (already) {
3171
+ if (!already.art && resolved.thumbnail)
3172
+ already.art = resolved.thumbnail;
3173
+ if (!already.about && resolved.about)
3174
+ already.about = resolved.about;
3175
+ if (already.art || already.about)
3176
+ options.rememberChannels?.(rememberedNow(options.channels));
3177
+ }
3120
3178
  if (!options.channels.has(channelId)) {
3121
3179
  // Asked of the site first, once, with the headers yt-dlp said to
3122
3180
  // send. A media address that answers nothing is a site refusing
@@ -5166,6 +5224,8 @@ export async function serve(argv, version = "0.1.0") {
5166
5224
  ...(rooms ? { rooms } : {}),
5167
5225
  ...(trollbox ? { trollbox } : {}),
5168
5226
  ...(speech ? { speech } : {}),
5227
+ // Other people's OpenProfiles, read for the voice a line is spoken in.
5228
+ ...(accounts ? { profiles: new Profiles() } : {}),
5169
5229
  ...(tickets ? { tickets } : {}),
5170
5230
  ...(authServer ? { authServer } : {}),
5171
5231
  ...(parties ? { parties } : {}),
@@ -0,0 +1,44 @@
1
+ export type VoiceKind = "female" | "male" | "";
2
+ /** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
3
+ export declare const DEFAULT_VOICES: Record<Exclude<VoiceKind, "">, string>;
4
+ /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
5
+ export declare function voiceKindOf(value: unknown): VoiceKind;
6
+ export interface ProfileVoice {
7
+ /** What the profile said outright, when it did: female, male, or a provider voice id. */
8
+ voice: string;
9
+ gender: string;
10
+ pronouns: string;
11
+ }
12
+ /**
13
+ * The parts of an OpenProfile.md that say how somebody sounds. The identity
14
+ * block is the bullets under the first `#`; `Gender` may also sit under
15
+ * `## Match`, where the spec put it first. Everything else is ignored.
16
+ */
17
+ export declare function readProfileVoice(markdown: string): ProfileVoice;
18
+ /**
19
+ * The Telnyx voice for somebody, from what is known about them. A voice
20
+ * named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
21
+ * as written; a sex picks the configured voice for it; nothing at all
22
+ * picks one by the account id, so it stays the same tomorrow.
23
+ */
24
+ export declare function telnyxVoiceFor(who: {
25
+ userId: string;
26
+ voice?: string;
27
+ profile?: ProfileVoice | null;
28
+ }, voices?: Record<Exclude<VoiceKind, "">, string>): string;
29
+ /** The voices to use per sex, from the environment when it says so. */
30
+ export declare function voicesFromEnv(env?: NodeJS.ProcessEnv): Record<Exclude<VoiceKind, "">, string>;
31
+ /** What a line sounds like read aloud: who said it, then what. */
32
+ export declare function spokenLine(handle: string, body: string): string;
33
+ /**
34
+ * Somebody's OpenProfile, fetched and kept an hour. A profile is read on
35
+ * every line they say, and a line a second is the trollbox's own limit.
36
+ */
37
+ export declare class Profiles {
38
+ private readonly fetcher;
39
+ private readonly now;
40
+ private readonly ttlMs;
41
+ private readonly kept;
42
+ constructor(fetcher?: typeof fetch, now?: () => number, ttlMs?: number);
43
+ voiceOf(url: string): Promise<ProfileVoice | null>;
44
+ }
package/dist/voices.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The voice a person's words are read in, on the phone.
3
+ *
4
+ * A trollbox line is text; the party line is a phone call. Reading one into
5
+ * the other needs a voice, and the voice should be theirs as far as a
6
+ * machine can manage: a man's line in a man's voice, a woman's in a woman's,
7
+ * anybody else's in whichever they asked for. Where that comes from, in
8
+ * order: the voice they set on their nixamp account; their OpenProfile
9
+ * (`Voice`, then `Gender`, then `Pronouns`; see logicsrc.com/openprofile);
10
+ * and failing both, one picked from their id, so the same person is always
11
+ * the same voice even when nobody was told which.
12
+ *
13
+ * The voices are Telnyx's Kokoro ones: an open-weights model, no bill beyond
14
+ * the call. ElevenLabs or anything else Telnyx speaks is a configuration
15
+ * away, per sex, because "better" and "paid" are the same word there.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ /** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
19
+ export const DEFAULT_VOICES = {
20
+ female: "Telnyx.KokoroTTS.af_heart",
21
+ male: "Telnyx.KokoroTTS.am_adam",
22
+ };
23
+ /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
24
+ export function voiceKindOf(value) {
25
+ if (typeof value !== "string")
26
+ return "";
27
+ const word = value.trim().toLowerCase();
28
+ if (word === "")
29
+ return "";
30
+ if (/^(f|female|woman|women|girl|she|her|she\/her|fem|feminine|lady)$/.test(word))
31
+ return "female";
32
+ if (/^(m|male|man|men|boy|he|him|he\/him|masc|masculine|guy)$/.test(word))
33
+ return "male";
34
+ if (/^she\b/.test(word))
35
+ return "female";
36
+ if (/^he\b/.test(word))
37
+ return "male";
38
+ return "";
39
+ }
40
+ /**
41
+ * The parts of an OpenProfile.md that say how somebody sounds. The identity
42
+ * block is the bullets under the first `#`; `Gender` may also sit under
43
+ * `## Match`, where the spec put it first. Everything else is ignored.
44
+ */
45
+ export function readProfileVoice(markdown) {
46
+ const found = { voice: "", gender: "", pronouns: "" };
47
+ let section = "";
48
+ let started = false;
49
+ for (const raw of markdown.split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (line.startsWith("# ") && !started) {
52
+ started = true;
53
+ continue;
54
+ }
55
+ if (line.startsWith("## ")) {
56
+ section = line.slice(3).trim().toLowerCase();
57
+ continue;
58
+ }
59
+ const inIdentity = started && section === "";
60
+ const inMatch = /^(match|dating|matching|partner|looking for)$/.test(section);
61
+ if (!inIdentity && !inMatch)
62
+ continue;
63
+ const item = /^[-*]\s+(?:\*\*)?([A-Za-z ]+?)(?:\*\*)?\s*:\s*(.+)$/.exec(line);
64
+ if (!item)
65
+ continue;
66
+ const key = (item[1] ?? "").trim().toLowerCase();
67
+ const value = (item[2] ?? "").trim();
68
+ if (key === "voice" && !found.voice)
69
+ found.voice = value;
70
+ if (key === "gender" && !found.gender)
71
+ found.gender = value;
72
+ if (key === "pronouns" && !found.pronouns)
73
+ found.pronouns = value;
74
+ }
75
+ return found;
76
+ }
77
+ /**
78
+ * The Telnyx voice for somebody, from what is known about them. A voice
79
+ * named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
80
+ * as written; a sex picks the configured voice for it; nothing at all
81
+ * picks one by the account id, so it stays the same tomorrow.
82
+ */
83
+ export function telnyxVoiceFor(who, voices = DEFAULT_VOICES) {
84
+ const asked = (who.voice ?? "").trim();
85
+ if (asked.includes("."))
86
+ return asked;
87
+ const fromProfile = who.profile?.voice.trim() ?? "";
88
+ if (fromProfile.includes("."))
89
+ return fromProfile;
90
+ const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
91
+ if (kind)
92
+ return voices[kind];
93
+ const digit = createHash("sha1").update(who.userId).digest()[0];
94
+ return digit % 2 === 0 ? voices.female : voices.male;
95
+ }
96
+ /** The voices to use per sex, from the environment when it says so. */
97
+ export function voicesFromEnv(env = process.env) {
98
+ return {
99
+ female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
100
+ male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
101
+ };
102
+ }
103
+ /** What a line sounds like read aloud: who said it, then what. */
104
+ export function spokenLine(handle, body) {
105
+ const said = body.replace(/\s+/g, " ").trim();
106
+ return `${handle.replace(/-/g, " ")} says: ${said}`;
107
+ }
108
+ /**
109
+ * Somebody's OpenProfile, fetched and kept an hour. A profile is read on
110
+ * every line they say, and a line a second is the trollbox's own limit.
111
+ */
112
+ export class Profiles {
113
+ fetcher;
114
+ now;
115
+ ttlMs;
116
+ kept = new Map();
117
+ constructor(fetcher = fetch, now = () => Date.now(), ttlMs = 60 * 60 * 1000) {
118
+ this.fetcher = fetcher;
119
+ this.now = now;
120
+ this.ttlMs = ttlMs;
121
+ }
122
+ async voiceOf(url) {
123
+ let where;
124
+ try {
125
+ where = new URL(url);
126
+ if (where.protocol !== "https:" && where.protocol !== "http:")
127
+ return null;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ const had = this.kept.get(where.href);
133
+ if (had && this.now() - had.at < this.ttlMs)
134
+ return had.voice;
135
+ try {
136
+ const response = await this.fetcher(where.href, {
137
+ headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
138
+ signal: AbortSignal.timeout(5000),
139
+ });
140
+ if (!response.ok)
141
+ return had?.voice ?? null;
142
+ const text = (await response.text()).slice(0, 64 * 1024);
143
+ const voice = readProfileVoice(text);
144
+ this.kept.set(where.href, { at: this.now(), voice });
145
+ if (this.kept.size > 5000) {
146
+ for (const [key, one] of this.kept)
147
+ if (this.now() - one.at > this.ttlMs)
148
+ this.kept.delete(key);
149
+ }
150
+ return voice;
151
+ }
152
+ catch {
153
+ return had?.voice ?? null;
154
+ }
155
+ }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.23.3",
3
+ "version": "0.23.5",
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",
package/src/catalogs.ts CHANGED
Binary file