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/src/handles.ts
CHANGED
|
@@ -22,8 +22,47 @@ const SCHEMA = `
|
|
|
22
22
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
23
23
|
);
|
|
24
24
|
CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
|
|
25
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS voice TEXT NOT NULL DEFAULT '';
|
|
26
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT '';
|
|
25
27
|
`;
|
|
26
28
|
|
|
29
|
+
/** What the room knows about somebody: the name, how they sound, where the rest is written. */
|
|
30
|
+
export interface Persona {
|
|
31
|
+
handle: string;
|
|
32
|
+
/** female, male, "" for whichever, or a provider voice id they named. */
|
|
33
|
+
voice: string;
|
|
34
|
+
/** The URL of their OpenProfile.md, or "". */
|
|
35
|
+
profile: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
|
|
39
|
+
export function cleanVoice(value: unknown): { voice: string; error: string } {
|
|
40
|
+
if (value === undefined || value === null) return { voice: "", error: "" };
|
|
41
|
+
if (typeof value !== "string") return { voice: "", error: "a voice is a word" };
|
|
42
|
+
const word = value.trim();
|
|
43
|
+
if (word === "" || word === "any") return { voice: "", error: "" };
|
|
44
|
+
const lower = word.toLowerCase();
|
|
45
|
+
if (lower === "female" || lower === "male") return { voice: lower, error: "" };
|
|
46
|
+
if (/^[A-Za-z][A-Za-z0-9_.-]{2,80}$/.test(word) && word.includes(".")) return { voice: word, error: "" };
|
|
47
|
+
return { voice: "", error: "female, male, any, or a voice id like Telnyx.KokoroTTS.am_adam" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
|
|
51
|
+
export function cleanProfile(value: unknown): { profile: string; error: string } {
|
|
52
|
+
if (value === undefined || value === null) return { profile: "", error: "" };
|
|
53
|
+
if (typeof value !== "string") return { profile: "", error: "a profile is a URL" };
|
|
54
|
+
const text = value.trim();
|
|
55
|
+
if (text === "") return { profile: "", error: "" };
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(text);
|
|
58
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return { profile: "", error: "a profile is an http(s) URL" };
|
|
59
|
+
if (url.href.length > 500) return { profile: "", error: "that URL is too long" };
|
|
60
|
+
return { profile: url.href, error: "" };
|
|
61
|
+
} catch {
|
|
62
|
+
return { profile: "", error: "a profile is a URL, like https://you.example/.well-known/openprofile.md" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/**
|
|
28
67
|
* What a handle may be.
|
|
29
68
|
*
|
|
@@ -87,6 +126,39 @@ export class Handles {
|
|
|
87
126
|
return rows[0] ? String(rows[0]["handle"] ?? "") : "";
|
|
88
127
|
}
|
|
89
128
|
|
|
129
|
+
/** Everything the room knows about somebody. Empty strings for whatever they never said. */
|
|
130
|
+
async persona(userId: string): Promise<Persona> {
|
|
131
|
+
await this.ensure();
|
|
132
|
+
const { rows } = await this.db.query(`SELECT handle, voice, profile FROM ${TABLE} WHERE user_id = $1`, [userId]);
|
|
133
|
+
const row = rows[0];
|
|
134
|
+
return {
|
|
135
|
+
handle: row ? String(row["handle"] ?? "") : "",
|
|
136
|
+
voice: row ? String(row["voice"] ?? "") : "",
|
|
137
|
+
profile: row ? String(row["profile"] ?? "") : "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How somebody sounds, and where their profile is. Either may be given
|
|
143
|
+
* alone; what is not given is kept. A row exists only once a handle does,
|
|
144
|
+
* so somebody who never picked one is given the fallback handle first.
|
|
145
|
+
*/
|
|
146
|
+
async describe(userId: string, fallbackHandle: string, wanted: { voice?: unknown; profile?: unknown }): Promise<{ persona: Persona | null; error: string }> {
|
|
147
|
+
const voice = wanted.voice === undefined ? null : cleanVoice(wanted.voice);
|
|
148
|
+
if (voice?.error) return { persona: null, error: voice.error };
|
|
149
|
+
const profile = wanted.profile === undefined ? null : cleanProfile(wanted.profile);
|
|
150
|
+
if (profile?.error) return { persona: null, error: profile.error };
|
|
151
|
+
await this.ensure();
|
|
152
|
+
const had = await this.persona(userId);
|
|
153
|
+
const handle = had.handle || cleanHandle(fallbackHandle) || fallbackHandle;
|
|
154
|
+
await this.db.query(
|
|
155
|
+
`INSERT INTO ${TABLE} (user_id, handle, voice, profile) VALUES ($1, $2, $3, $4)
|
|
156
|
+
ON CONFLICT (user_id) DO UPDATE SET voice = EXCLUDED.voice, profile = EXCLUDED.profile, updated_at = NOW()`,
|
|
157
|
+
[userId, handle, voice ? voice.voice : had.voice, profile ? profile.profile : had.profile],
|
|
158
|
+
);
|
|
159
|
+
return { persona: await this.persona(userId), error: "" };
|
|
160
|
+
}
|
|
161
|
+
|
|
90
162
|
/**
|
|
91
163
|
* Handles for a page of rows, in one query.
|
|
92
164
|
*
|
package/src/links.ts
CHANGED
|
@@ -145,13 +145,15 @@ export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
|
|
|
145
145
|
* all -- a segment list with #EXT-X- tags -- and those are one stream for
|
|
146
146
|
* ffmpeg to read as it is, not a list of streams.
|
|
147
147
|
*/
|
|
148
|
-
export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[] } {
|
|
149
|
-
if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [] };
|
|
150
|
-
const
|
|
151
|
-
.
|
|
152
|
-
.filter((source) => /^(https?|rtmps?):\/\//i.test(source))
|
|
148
|
+
export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[]; image: string } {
|
|
149
|
+
if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [], image: "" };
|
|
150
|
+
const entries = parseCatalog(text, base)
|
|
151
|
+
.filter((entry) => /^(https?|rtmps?):\/\//i.test(entry.source))
|
|
153
152
|
.slice(0, PLAYLIST_MAX_ENTRIES);
|
|
154
|
-
|
|
153
|
+
// The picture of the list is the first picture in it: a podcast's episodes
|
|
154
|
+
// all wear the show's art, and the newest is first.
|
|
155
|
+
const image = entries.map((entry) => pictureUrl(entry.logo)).find((one) => one !== "") ?? "";
|
|
156
|
+
return { hls: false, sources: entries.map((entry) => entry.source), image };
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
/**
|
|
@@ -195,7 +197,7 @@ export async function resolvePlaylist(
|
|
|
195
197
|
extractor: "playlist",
|
|
196
198
|
ext: "",
|
|
197
199
|
page: url,
|
|
198
|
-
thumbnail:
|
|
200
|
+
thumbnail: list.image,
|
|
199
201
|
about: "",
|
|
200
202
|
playlist: list.sources,
|
|
201
203
|
};
|
package/src/partyline.ts
CHANGED
|
@@ -702,6 +702,30 @@ export class PartyLine {
|
|
|
702
702
|
});
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
/**
|
|
706
|
+
* A line said into a room, to everybody on the phone in it. What the
|
|
707
|
+
* trollbox becomes on the party line: the handle and the words, in the
|
|
708
|
+
* voice of whoever typed them. False when nobody is on the phone there,
|
|
709
|
+
* which is most of the time and costs nothing.
|
|
710
|
+
*/
|
|
711
|
+
async say(code: string, text: string, voice: string): Promise<boolean> {
|
|
712
|
+
const room = this.rooms.get(code);
|
|
713
|
+
if (!room || room.conferenceId === null || room.callers === 0) return false;
|
|
714
|
+
const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
715
|
+
if (said === "") return false;
|
|
716
|
+
const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
|
|
717
|
+
payload: said,
|
|
718
|
+
voice: voice || this.voice,
|
|
719
|
+
});
|
|
720
|
+
return answer !== null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Whether anybody is on the phone in a room, before a line is worth reading. */
|
|
724
|
+
hasCallers(code: string): boolean {
|
|
725
|
+
const room = this.rooms.get(code);
|
|
726
|
+
return room !== undefined && room.conferenceId !== null && room.callers > 0;
|
|
727
|
+
}
|
|
728
|
+
|
|
705
729
|
/**
|
|
706
730
|
* How many people are on the phone for a stream.
|
|
707
731
|
*
|
package/src/server.ts
CHANGED
|
@@ -88,6 +88,7 @@ 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 { Profiles, spokenLine, telnyxVoiceFor, voicesFromEnv } from "./voices.ts";
|
|
91
92
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
92
93
|
import {
|
|
93
94
|
applyRemoteConfig,
|
|
@@ -1733,6 +1734,8 @@ export interface HandlerOptions {
|
|
|
1733
1734
|
trollbox?: Trollbox;
|
|
1734
1735
|
/** Speech to text: a line said out loud, heard here. Needs the optional model. */
|
|
1735
1736
|
speech?: Speech;
|
|
1737
|
+
/** Other people's OpenProfiles, for the voice their lines are read in. */
|
|
1738
|
+
profiles?: Profiles;
|
|
1736
1739
|
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
1737
1740
|
tickets?: Tickets;
|
|
1738
1741
|
/**
|
|
@@ -2703,6 +2706,33 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2703
2706
|
// Separate from the address on purpose. The address is a credential and
|
|
2704
2707
|
// a way to reach somebody; publishing it in a directory listing or an
|
|
2705
2708
|
// invite would be publishing what they log in with.
|
|
2709
|
+
/**
|
|
2710
|
+
* A trollbox line, read aloud to whoever is on the phone in that room.
|
|
2711
|
+
* The room's code comes from the directory (one per channel, one for
|
|
2712
|
+
* the server's own stream), the voice from the author's account or
|
|
2713
|
+
* OpenProfile. Never awaited by the poster: the phone is a side effect
|
|
2714
|
+
* of the chat, and a slow Telnyx must not slow the box.
|
|
2715
|
+
*/
|
|
2716
|
+
const readOnThePhone = (where: { server: string; channel: string }, authorId: string, handle: string, body: string): void => {
|
|
2717
|
+
const partyLine = options.partyLine;
|
|
2718
|
+
const listing = (options.directory?.list() ?? []).find((one) => {
|
|
2719
|
+
try {
|
|
2720
|
+
return new URL(one.url).origin === where.server;
|
|
2721
|
+
} catch {
|
|
2722
|
+
return false;
|
|
2723
|
+
}
|
|
2724
|
+
});
|
|
2725
|
+
if (!partyLine || !listing) return;
|
|
2726
|
+
const code = where.channel === "live" || where.channel === "main" ? listing.code : listing.channelCodes[where.channel] ?? "";
|
|
2727
|
+
if (!code || !partyLine.hasCallers(code)) return;
|
|
2728
|
+
void (async () => {
|
|
2729
|
+
const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
|
|
2730
|
+
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2731
|
+
const voice = telnyxVoiceFor({ userId: authorId, voice: persona.voice, profile }, voicesFromEnv());
|
|
2732
|
+
await partyLine.say(code, spokenLine(handle, body), voice);
|
|
2733
|
+
})().catch(() => undefined);
|
|
2734
|
+
};
|
|
2735
|
+
|
|
2706
2736
|
/*
|
|
2707
2737
|
* The trollbox: the chat for one live room, keyed by the server and
|
|
2708
2738
|
* the channel so every viewer of a stream is in the same box whichever
|
|
@@ -2751,6 +2781,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2751
2781
|
}
|
|
2752
2782
|
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2753
2783
|
const line = await trollbox.post(where, who.id, handle, body.body);
|
|
2784
|
+
readOnThePhone(where, who.id, line.handle, line.body);
|
|
2754
2785
|
json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
|
|
2755
2786
|
return;
|
|
2756
2787
|
}
|
|
@@ -2819,6 +2850,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2819
2850
|
if (where && options.trollbox && heard.text !== "") {
|
|
2820
2851
|
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2821
2852
|
const line = await options.trollbox.post(where, who.id, handle, heard.text);
|
|
2853
|
+
readOnThePhone(where, who.id, line.handle, line.body);
|
|
2822
2854
|
json(response, 201, {
|
|
2823
2855
|
text: heard.text, seconds: heard.seconds, model: speech.model,
|
|
2824
2856
|
message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
|
|
@@ -2841,24 +2873,38 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2841
2873
|
return;
|
|
2842
2874
|
}
|
|
2843
2875
|
|
|
2876
|
+
// The handle, and since 0.23.4 the voice a line is read in on the
|
|
2877
|
+
// phone and the OpenProfile it may be read from. Any of the three may
|
|
2878
|
+
// be sent alone; what is not sent is kept.
|
|
2844
2879
|
if (request.method === "GET") {
|
|
2845
|
-
|
|
2880
|
+
const persona = await handles.persona(who.id);
|
|
2881
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
|
|
2846
2882
|
return;
|
|
2847
2883
|
}
|
|
2848
2884
|
if (request.method === "PUT" || request.method === "POST") {
|
|
2849
|
-
let body: { handle?: unknown };
|
|
2885
|
+
let body: { handle?: unknown; voice?: unknown; profile?: unknown };
|
|
2850
2886
|
try {
|
|
2851
2887
|
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2852
2888
|
} catch {
|
|
2853
2889
|
json(response, 400, { error: "bad JSON" });
|
|
2854
2890
|
return;
|
|
2855
2891
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2892
|
+
if (body.handle !== undefined) {
|
|
2893
|
+
const claimed = await handles.claim(who.id, body.handle);
|
|
2894
|
+
if (claimed.error) {
|
|
2895
|
+
json(response, 409, { error: claimed.error });
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2860
2898
|
}
|
|
2861
|
-
|
|
2899
|
+
if (body.voice !== undefined || body.profile !== undefined) {
|
|
2900
|
+
const described = await handles.describe(who.id, fallbackHandle(who.id), { voice: body.voice, profile: body.profile });
|
|
2901
|
+
if (described.error) {
|
|
2902
|
+
json(response, 422, { error: described.error });
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
const persona = await handles.persona(who.id);
|
|
2907
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
|
|
2862
2908
|
return;
|
|
2863
2909
|
}
|
|
2864
2910
|
json(response, 405, { error: "GET or PUT" });
|
|
@@ -3666,6 +3712,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3666
3712
|
return;
|
|
3667
3713
|
}
|
|
3668
3714
|
links.set(link, resolved);
|
|
3715
|
+
// A channel already on -- put back by a restart, which forgets where
|
|
3716
|
+
// it came from -- learns its picture and its line from the link pasted
|
|
3717
|
+
// again, rather than waiting to be taken off and put on.
|
|
3718
|
+
const already = options.channels.info(channelId);
|
|
3719
|
+
if (already) {
|
|
3720
|
+
if (!already.art && resolved.thumbnail) already.art = resolved.thumbnail;
|
|
3721
|
+
if (!already.about && resolved.about) already.about = resolved.about;
|
|
3722
|
+
if (already.art || already.about) options.rememberChannels?.(rememberedNow(options.channels));
|
|
3723
|
+
}
|
|
3669
3724
|
if (!options.channels.has(channelId)) {
|
|
3670
3725
|
// Asked of the site first, once, with the headers yt-dlp said to
|
|
3671
3726
|
// send. A media address that answers nothing is a site refusing
|
|
@@ -5815,6 +5870,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5815
5870
|
...(rooms ? { rooms } : {}),
|
|
5816
5871
|
...(trollbox ? { trollbox } : {}),
|
|
5817
5872
|
...(speech ? { speech } : {}),
|
|
5873
|
+
// Other people's OpenProfiles, read for the voice a line is spoken in.
|
|
5874
|
+
...(accounts ? { profiles: new Profiles() } : {}),
|
|
5818
5875
|
...(tickets ? { tickets } : {}),
|
|
5819
5876
|
...(authServer ? { authServer } : {}),
|
|
5820
5877
|
...(parties ? { parties } : {}),
|
package/src/voices.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
|
|
19
|
+
export type VoiceKind = "female" | "male" | "";
|
|
20
|
+
|
|
21
|
+
/** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
|
|
22
|
+
export const DEFAULT_VOICES: Record<Exclude<VoiceKind, "">, string> = {
|
|
23
|
+
female: "Telnyx.KokoroTTS.af_heart",
|
|
24
|
+
male: "Telnyx.KokoroTTS.am_adam",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
|
|
28
|
+
export function voiceKindOf(value: unknown): VoiceKind {
|
|
29
|
+
if (typeof value !== "string") return "";
|
|
30
|
+
const word = value.trim().toLowerCase();
|
|
31
|
+
if (word === "") return "";
|
|
32
|
+
if (/^(f|female|woman|women|girl|she|her|she\/her|fem|feminine|lady)$/.test(word)) return "female";
|
|
33
|
+
if (/^(m|male|man|men|boy|he|him|he\/him|masc|masculine|guy)$/.test(word)) return "male";
|
|
34
|
+
if (/^she\b/.test(word)) return "female";
|
|
35
|
+
if (/^he\b/.test(word)) return "male";
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProfileVoice {
|
|
40
|
+
/** What the profile said outright, when it did: female, male, or a provider voice id. */
|
|
41
|
+
voice: string;
|
|
42
|
+
gender: string;
|
|
43
|
+
pronouns: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The parts of an OpenProfile.md that say how somebody sounds. The identity
|
|
48
|
+
* block is the bullets under the first `#`; `Gender` may also sit under
|
|
49
|
+
* `## Match`, where the spec put it first. Everything else is ignored.
|
|
50
|
+
*/
|
|
51
|
+
export function readProfileVoice(markdown: string): ProfileVoice {
|
|
52
|
+
const found: ProfileVoice = { voice: "", gender: "", pronouns: "" };
|
|
53
|
+
let section = "";
|
|
54
|
+
let started = false;
|
|
55
|
+
for (const raw of markdown.split(/\r?\n/)) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (line.startsWith("# ") && !started) {
|
|
58
|
+
started = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (line.startsWith("## ")) {
|
|
62
|
+
section = line.slice(3).trim().toLowerCase();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const inIdentity = started && section === "";
|
|
66
|
+
const inMatch = /^(match|dating|matching|partner|looking for)$/.test(section);
|
|
67
|
+
if (!inIdentity && !inMatch) continue;
|
|
68
|
+
const item = /^[-*]\s+(?:\*\*)?([A-Za-z ]+?)(?:\*\*)?\s*:\s*(.+)$/.exec(line);
|
|
69
|
+
if (!item) continue;
|
|
70
|
+
const key = (item[1] ?? "").trim().toLowerCase();
|
|
71
|
+
const value = (item[2] ?? "").trim();
|
|
72
|
+
if (key === "voice" && !found.voice) found.voice = value;
|
|
73
|
+
if (key === "gender" && !found.gender) found.gender = value;
|
|
74
|
+
if (key === "pronouns" && !found.pronouns) found.pronouns = value;
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Telnyx voice for somebody, from what is known about them. A voice
|
|
81
|
+
* named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
|
|
82
|
+
* as written; a sex picks the configured voice for it; nothing at all
|
|
83
|
+
* picks one by the account id, so it stays the same tomorrow.
|
|
84
|
+
*/
|
|
85
|
+
export function telnyxVoiceFor(
|
|
86
|
+
who: { userId: string; voice?: string; profile?: ProfileVoice | null },
|
|
87
|
+
voices: Record<Exclude<VoiceKind, "">, string> = DEFAULT_VOICES,
|
|
88
|
+
): string {
|
|
89
|
+
const asked = (who.voice ?? "").trim();
|
|
90
|
+
if (asked.includes(".")) return asked;
|
|
91
|
+
const fromProfile = who.profile?.voice.trim() ?? "";
|
|
92
|
+
if (fromProfile.includes(".")) return fromProfile;
|
|
93
|
+
const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
|
|
94
|
+
if (kind) return voices[kind];
|
|
95
|
+
const digit = createHash("sha1").update(who.userId).digest()[0] as number;
|
|
96
|
+
return digit % 2 === 0 ? voices.female : voices.male;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The voices to use per sex, from the environment when it says so. */
|
|
100
|
+
export function voicesFromEnv(env: NodeJS.ProcessEnv = process.env): Record<Exclude<VoiceKind, "">, string> {
|
|
101
|
+
return {
|
|
102
|
+
female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
|
|
103
|
+
male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What a line sounds like read aloud: who said it, then what. */
|
|
108
|
+
export function spokenLine(handle: string, body: string): string {
|
|
109
|
+
const said = body.replace(/\s+/g, " ").trim();
|
|
110
|
+
return `${handle.replace(/-/g, " ")} says: ${said}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Somebody's OpenProfile, fetched and kept an hour. A profile is read on
|
|
115
|
+
* every line they say, and a line a second is the trollbox's own limit.
|
|
116
|
+
*/
|
|
117
|
+
export class Profiles {
|
|
118
|
+
private readonly kept = new Map<string, { at: number; voice: ProfileVoice }>();
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
private readonly fetcher: typeof fetch = fetch,
|
|
122
|
+
private readonly now: () => number = () => Date.now(),
|
|
123
|
+
private readonly ttlMs = 60 * 60 * 1000,
|
|
124
|
+
) {}
|
|
125
|
+
|
|
126
|
+
async voiceOf(url: string): Promise<ProfileVoice | null> {
|
|
127
|
+
let where: URL;
|
|
128
|
+
try {
|
|
129
|
+
where = new URL(url);
|
|
130
|
+
if (where.protocol !== "https:" && where.protocol !== "http:") return null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const had = this.kept.get(where.href);
|
|
135
|
+
if (had && this.now() - had.at < this.ttlMs) return had.voice;
|
|
136
|
+
try {
|
|
137
|
+
const response = await this.fetcher(where.href, {
|
|
138
|
+
headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
|
|
139
|
+
signal: AbortSignal.timeout(5000),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) 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) if (this.now() - one.at > this.ttlMs) this.kept.delete(key);
|
|
147
|
+
}
|
|
148
|
+
return voice;
|
|
149
|
+
} catch {
|
|
150
|
+
return had?.voice ?? null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-zCUbE3U5.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|