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 +12 -1
- package/dist/catalogs.d.ts +4 -2
- package/dist/catalogs.js +0 -0
- package/dist/handles.d.ts +32 -0
- package/dist/handles.js +69 -0
- package/dist/links.d.ts +1 -0
- package/dist/links.js +8 -6
- package/dist/partyline.d.ts +9 -0
- package/dist/partyline.js +24 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +66 -6
- package/dist/voices.d.ts +44 -0
- package/dist/voices.js +156 -0
- package/package.json +1 -1
- package/src/catalogs.ts +0 -0
- package/src/handles.ts +72 -0
- package/src/links.ts +9 -7
- package/src/partyline.ts +24 -0
- package/src/server.ts +64 -7
- package/src/voices.ts +153 -0
- package/web/dist/assets/{hls-3VKVEQE3-B34ptWVB.js → hls-3VKVEQE3-Dq51v8Gv.js} +1 -1
- package/web/dist/assets/index-zCUbE3U5.js +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-Dwbw4mqA.js → mpegts-LO6RVLD6-Dz2Fz7af.js} +1 -1
- package/web/dist/assets/{mpegts-DAbMVJ3m.js → mpegts-WAX5pHld.js} +1 -1
- package/web/dist/index.html +22 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CjxlA6ia.js +0 -1
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
|
|
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.
|
package/dist/catalogs.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
102
|
-
.
|
|
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
|
-
|
|
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
|
};
|
package/dist/partyline.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
|
|
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 } : {}),
|
package/dist/voices.d.ts
ADDED
|
@@ -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
package/src/catalogs.ts
CHANGED
|
Binary file
|