@mekuru/voisona 0.1.2 → 0.1.3

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.jp.md CHANGED
@@ -104,7 +104,7 @@ import { Voisona, tanaka } from "@mekuru/voisona";
104
104
  - `len`: 無音を含む全体のフレーム数。既定: 音声の長さを切り上げた値 + `gap`。話速は変えない。
105
105
  - `id`・`at`: 生成する `<dur>` の識別名と開始時刻。[時間の属性](../../docs/reference/Syntax.jp.md#時間の属性)を参照。
106
106
 
107
- ルビ `|表示《よみ》` は「よみ」を読み、タイムラインには「表示」を残します。半角 `|` と `漢字《かんじ》` も使用できます。`<br />` は空白になり、ほかの要素は入れられません。セリフの連続再生は `<seq>`、字幕との同時表示は `<stack>`、口パクは[立ち絵の `Talk`](../portrait/README.jp.md) を使います。
107
+ ルビ `|表示《よみ》` は「よみ」を読み、タイムラインには「表示」を残します。半角 `|` と `漢字《かんじ》` も使用できます。`<br />` は空白になり、ほかの要素は入れられません。セリフの連続再生は `<seq>`、字幕との同時表示は `<stack>`、口パクは[立ち絵の `Talk`](../narrator/README.jp.md) を使います。
108
108
 
109
109
  ### キャッシュとプレビュー
110
110
 
@@ -121,3 +121,5 @@ curl --user "$MEKURU_VOISONA_USER" http://localhost:32766/api/talk/v1/voices
121
121
  ```
122
122
 
123
123
  curl に API パスワードを入力します。HTTP 401 はメールアドレスと API パスワードを確認してください。接続できない場合は起動状態とポートを確認します。Windows のエンジンには [WSL の mirrored ネットワーク](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking)で `127.0.0.1` から接続できます。`localhost` で失敗する場合は `MEKURU_VOISONA_URL=http://127.0.0.1:32766` を指定します。
124
+
125
+ 独自のナレータでは `await Voisona.prepare({ ...声の設定, children })` で音声素材・表示文字・長さをまとめて取得できます。`id`・`at` は配置する部品に付けます。[共有する音声素材](../speech/README.jp.md#共有する音声素材)と[ナレータの組み立て](../narrator/README.jp.md)を参照してください。
package/README.md CHANGED
@@ -104,7 +104,7 @@ Omitted numeric voice parameters use the API defaults. `/api/talk/v1/voices` lis
104
104
  - `len`: Total frames, including the gap. Default: rounded-up audio duration + `gap`. Does not change speaking speed.
105
105
  - `id`, `at`: Identifier and start timing for the generated `<dur>`; see [timing](../../docs/reference/Syntax.md#timing-attributes).
106
106
 
107
- Ruby `|表示《よみ》` reads `よみ` and keeps `表示` in the timeline. ASCII `|` and `漢字《かんじ》` also work. `<br />` becomes a space; other nested elements are rejected. Use `<seq>` for successive lines and `<stack>` for speech with subtitles. For lip sync, wrap a line in a [portrait's `Talk`](../portrait/README.md).
107
+ Ruby `|表示《よみ》` reads `よみ` and keeps `表示` in the timeline. ASCII `|` and `漢字《かんじ》` also work. `<br />` becomes a space; other nested elements are rejected. Use `<seq>` for successive lines and `<stack>` for speech with subtitles. For lip sync, wrap a line in a [portrait's `Talk`](../narrator/README.md).
108
108
 
109
109
  ### Cache and preview
110
110
 
@@ -121,3 +121,5 @@ curl --user "$MEKURU_VOISONA_USER" http://localhost:32766/api/talk/v1/voices
121
121
  ```
122
122
 
123
123
  curl prompts for the API password. For HTTP 401, check the email and API password. For connection errors, check the engine and port. On Windows, use [WSL mirrored networking](https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking) to reach the engine through `127.0.0.1`. Set `MEKURU_VOISONA_URL=http://127.0.0.1:32766` if `localhost` fails.
124
+
125
+ For custom narrators, `await Voisona.prepare({ ...voiceSettings, children })` returns shared audio, display text, and duration. Put `id` and `at` on the components that place it. See [shared audio sources](../speech/README.md#shared-audio-sources) and [composing narrators](../narrator/README.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mekuru/voisona",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "The speech component <Voisona> of Mekuru Movie Maker: lines of speech read by VoiSona Talk, through its REST API",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,13 +11,14 @@
11
11
  ],
12
12
  "sideEffects": false,
13
13
  "peerDependencies": {
14
- "@mekuru/core": "^0.1.2"
14
+ "@mekuru/core": "^0.1.3"
15
15
  },
16
16
  "devDependencies": {
17
- "@mekuru/core": "0.1.2"
17
+ "@mekuru/core": "0.1.3"
18
18
  },
19
19
  "dependencies": {
20
- "@mekuru/speech": "^0.1.2"
20
+ "@mekuru/speech": "^0.1.3",
21
+ "@mekuru/audio": "^0.1.3"
21
22
  },
22
23
  "engines": {
23
24
  "bun": ">=1.4"
package/src/client.ts ADDED
@@ -0,0 +1,180 @@
1
+ import type { SpeechProps } from "@mekuru/speech";
2
+
3
+ import { httpClient, limit, splitText } from "@mekuru/speech/engine";
4
+
5
+ import { joinWavs } from "@mekuru/audio/wav";
6
+
7
+ import type { VoisonaProps } from "./props.ts";
8
+ type VoiceOptions = Omit<VoisonaProps, keyof SpeechProps | "url">;
9
+
10
+ const DEFAULT_URL = "http://localhost:32766";
11
+ const API = "/api/talk/v1";
12
+ const TIMEOUT = 300_000;
13
+
14
+ interface Voice {
15
+ voice_name: string;
16
+ voice_version: string;
17
+ languages: string[];
18
+ display_names?: { language: string; name: string }[];
19
+ }
20
+
21
+ interface VoiceDetail {
22
+ style_names?: string[];
23
+ default_style_weights?: number[];
24
+ }
25
+
26
+ interface Synthesis {
27
+ state: "queued" | "running" | "succeeded" | "failed";
28
+ }
29
+
30
+ const HELP =
31
+ " - start VoiSona Talk (1.3.7 or later), sign in, and turn on 「REST APIを有効にする」 in the API tab of its preferences\n" +
32
+ " - it listens on port 32766; if it is elsewhere (WSL2 without mirrored networking), set `url` on `<Voisona>`\n" +
33
+ " or the MEKURU_VOISONA_URL environment variable";
34
+
35
+ const CREDENTIALS =
36
+ "VoiSona Talk needs the email you sign in with and the API password (set in the API tab of its preferences):\n" +
37
+ " set the MEKURU_VOISONA_USER and MEKURU_VOISONA_PASSWORD environment variables";
38
+
39
+ const names = (v: Voice): string[] => [v.voice_name, ...(v.display_names ?? []).map((d) => d.name)];
40
+
41
+ const newer = (a: string, b: string): number => {
42
+ const x = a.split(".").map(Number);
43
+ const y = b.split(".").map(Number);
44
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
45
+ const d = (y[i] ?? 0) - (x[i] ?? 0);
46
+ if (d) return d;
47
+ }
48
+ return 0;
49
+ };
50
+
51
+ class Server {
52
+ private readonly limited = limit(1);
53
+
54
+ private readonly raw: ReturnType<typeof httpClient>;
55
+
56
+ private voices?: Promise<Voice[]>;
57
+
58
+ private readonly details = new Map<string, Promise<VoiceDetail>>();
59
+
60
+ constructor(url: string) {
61
+ this.raw = httpClient(url, "VoiSona Talk", HELP);
62
+ }
63
+
64
+ private async call(path: string, init: RequestInit = {}): Promise<Response> {
65
+ const user = process.env.MEKURU_VOISONA_USER;
66
+ const password = process.env.MEKURU_VOISONA_PASSWORD;
67
+ if (!user || !password) throw new Error(CREDENTIALS);
68
+ const headers = new Headers(init.headers);
69
+ headers.set("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
70
+ try {
71
+ return await this.raw(API + path, { ...init, headers });
72
+ } catch (e) {
73
+ const message = e instanceof Error ? e.message : String(e);
74
+ throw /: HTTP 401\b/.test(message) ? new Error(`${message}\n${CREDENTIALS}`) : e;
75
+ }
76
+ }
77
+
78
+ private async voice(props: VoiceOptions): Promise<{ voice: Voice; language: string }> {
79
+ this.voices ??= this.call("/voices")
80
+ .then(async (res) => ((await res.json()) as { items: Voice[] }).items)
81
+ .catch((e: unknown) => {
82
+ this.voices = undefined;
83
+ throw e;
84
+ });
85
+ const all = await this.voices;
86
+ const found = all.filter((v) => names(v).includes(props.voice)).sort((a, b) => newer(a.voice_version, b.voice_version));
87
+ if (found.length === 0) {
88
+ const have = [...new Set(all.map((v) => `${v.voice_name} (${names(v).slice(1).join(", ")})`))];
89
+ throw new Error(`VoiSona Talk has no voice \`${props.voice}\` (it has ${have.join(", ") || "none"}; download voices in the app)`);
90
+ }
91
+ if (new Set(found.map((v) => v.voice_name)).size > 1) {
92
+ throw new Error(`VoiSona Talk has several voices named \`${props.voice}\`: write one of ${[...new Set(found.map((v) => v.voice_name))].join(", ")}`);
93
+ }
94
+ const voice = props.version === undefined ? found[0] : found.find((v) => v.voice_version === props.version);
95
+ if (!voice) throw new Error(`VoiSona Talk has no version ${props.version} of \`${props.voice}\` (it has ${found.map((v) => v.voice_version).join(", ")})`);
96
+ const language = props.language ?? voice.languages[0];
97
+ if (!voice.languages.includes(language)) {
98
+ throw new Error(`the VoiSona Talk voice \`${props.voice}\` does not speak ${language} (it speaks ${voice.languages.join(", ")})`);
99
+ }
100
+ return { voice, language };
101
+ }
102
+
103
+ private async styleWeights(voice: Voice, style: Readonly<Record<string, number>>): Promise<number[]> {
104
+ const key = `${voice.voice_name}/${voice.voice_version}`;
105
+ let detail = this.details.get(key);
106
+ if (!detail) {
107
+ detail = this.call(`/voices/${encodeURIComponent(voice.voice_name)}/${encodeURIComponent(voice.voice_version)}`).then(
108
+ async (res) => (await res.json()) as VoiceDetail,
109
+ );
110
+ detail.catch(() => this.details.delete(key));
111
+ this.details.set(key, detail);
112
+ }
113
+ const { style_names: styles = [], default_style_weights: defaults = [] } = await detail;
114
+ const weights = styles.map((_, i) => defaults[i] ?? 0);
115
+ for (const [name, weight] of Object.entries(style)) {
116
+ const i = styles.indexOf(name);
117
+ if (i < 0) throw new Error(`the VoiSona Talk voice \`${voice.voice_name}\` has no style \`${name}\` (it has ${styles.join(", ") || "none"})`);
118
+ weights[i] = weight;
119
+ }
120
+ return weights;
121
+ }
122
+
123
+ private async speak(body: object, text: string): Promise<Buffer> {
124
+ const res = await this.call("/speech-syntheses", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ ...body, text }),
128
+ });
129
+ const { uuid } = (await res.json()) as { uuid: string };
130
+ try {
131
+ const until = Date.now() + TIMEOUT;
132
+ for (;;) {
133
+ const { state } = (await (await this.call(`/speech-syntheses/${uuid}`)).json()) as Synthesis;
134
+ if (state === "succeeded") break;
135
+ if (state === "failed") throw new Error(`VoiSona Talk could not read: ${text.slice(0, 40)}`);
136
+ if (Date.now() > until) throw new Error(`VoiSona Talk did not finish in ${TIMEOUT / 1000}s: ${text.slice(0, 40)}`);
137
+ await Bun.sleep(100);
138
+ }
139
+ return Buffer.from(await (await this.call(`/speech-syntheses/${uuid}/wav`)).arrayBuffer());
140
+ } finally {
141
+ await this.call(`/speech-syntheses/${uuid}`, { method: "DELETE" }).catch(() => {});
142
+ }
143
+ }
144
+
145
+ private async write(props: VoiceOptions, text: string, out: string): Promise<void> {
146
+ const { voice, language } = await this.voice(props);
147
+ const global: Record<string, number | number[]> = {};
148
+ for (const key of ["speed", "pitch", "volume", "alp", "huskiness", "intonation"] as const) {
149
+ const value = props[key];
150
+ if (value !== undefined) global[key] = value;
151
+ }
152
+ if (props.style) global.style_weights = await this.styleWeights(voice, props.style);
153
+ const body = {
154
+ language,
155
+ voice_name: voice.voice_name,
156
+ voice_version: voice.voice_version,
157
+ global_parameters: global,
158
+ destination: "memory",
159
+ force_enqueue: true,
160
+ };
161
+ const wavs: Buffer[] = [];
162
+ for (const part of splitText(text, props["max-chars"] ?? 500)) wavs.push(await this.speak(body, part));
163
+ await Bun.write(out, wavs.length === 1 ? wavs[0] : joinWavs(wavs));
164
+ }
165
+
166
+ synthesize(props: VoiceOptions, text: string, out: string): Promise<void> {
167
+ return this.limited(() => this.write(props, text, out));
168
+ }
169
+ }
170
+
171
+ const servers = new Map<string, Server>();
172
+
173
+ export const server = (url: string | undefined): Server => {
174
+ const at = (url ?? process.env.MEKURU_VOISONA_URL ?? DEFAULT_URL).replace(/\/+$/, "");
175
+ const found = servers.get(at);
176
+ if (found) return found;
177
+ const created = new Server(at);
178
+ servers.set(at, created);
179
+ return created;
180
+ };
package/src/index.tsx CHANGED
@@ -1,197 +1,12 @@
1
- import type { JSX } from "@mekuru/core";
2
- import { httpClient, limit, splitText } from "@mekuru/speech/engine";
3
- import { joinWavs, line, voiceOf, type SpeechProps } from "@mekuru/speech";
4
-
5
- const DEFAULT_URL = "http://localhost:32766";
6
- const API = "/api/talk/v1";
7
- const TIMEOUT = 300_000;
8
-
9
- export interface VoisonaProps extends SpeechProps {
10
- voice: string;
11
- version?: string;
12
- language?: string;
13
- speed?: number;
14
- pitch?: number;
15
- volume?: number;
16
- alp?: number;
17
- huskiness?: number;
18
- intonation?: number;
19
- style?: Readonly<Record<string, number>>;
20
- "max-chars"?: number;
21
- url?: string;
22
- }
23
-
24
- interface Voice {
25
- voice_name: string;
26
- voice_version: string;
27
- languages: string[];
28
- display_names?: { language: string; name: string }[];
29
- }
30
-
31
- interface VoiceDetail {
32
- style_names?: string[];
33
- default_style_weights?: number[];
34
- }
35
-
36
- interface Synthesis {
37
- state: "queued" | "running" | "succeeded" | "failed";
38
- }
39
-
40
- const HELP =
41
- " - start VoiSona Talk (1.3.7 or later), sign in, and turn on 「REST APIを有効にする」 in the API tab of its preferences\n" +
42
- " - it listens on port 32766; if it is elsewhere (WSL2 without mirrored networking), set `url` on `<Voisona>`\n" +
43
- " or the MEKURU_VOISONA_URL environment variable";
44
-
45
- const CREDENTIALS =
46
- "VoiSona Talk needs the email you sign in with and the API password (set in the API tab of its preferences):\n" +
47
- " set the MEKURU_VOISONA_USER and MEKURU_VOISONA_PASSWORD environment variables";
48
-
49
- const names = (v: Voice): string[] => [v.voice_name, ...(v.display_names ?? []).map((d) => d.name)];
50
-
51
- const newer = (a: string, b: string): number => {
52
- const x = a.split(".").map(Number);
53
- const y = b.split(".").map(Number);
54
- for (let i = 0; i < Math.max(x.length, y.length); i++) {
55
- const d = (y[i] ?? 0) - (x[i] ?? 0);
56
- if (d) return d;
57
- }
58
- return 0;
59
- };
60
-
61
- class Server {
62
- private readonly limited = limit(1);
63
-
64
- private readonly raw: ReturnType<typeof httpClient>;
65
-
66
- private voices?: Promise<Voice[]>;
67
-
68
- private readonly details = new Map<string, Promise<VoiceDetail>>();
69
-
70
- constructor(url: string) {
71
- this.raw = httpClient(url, "VoiSona Talk", HELP);
72
- }
73
-
74
- private async call(path: string, init: RequestInit = {}): Promise<Response> {
75
- const user = process.env.MEKURU_VOISONA_USER;
76
- const password = process.env.MEKURU_VOISONA_PASSWORD;
77
- if (!user || !password) throw new Error(CREDENTIALS);
78
- const headers = new Headers(init.headers);
79
- headers.set("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
80
- try {
81
- return await this.raw(API + path, { ...init, headers });
82
- } catch (e) {
83
- const message = e instanceof Error ? e.message : String(e);
84
- throw /: HTTP 401\b/.test(message) ? new Error(`${message}\n${CREDENTIALS}`) : e;
85
- }
86
- }
87
-
88
- private async voice(props: VoisonaProps): Promise<{ voice: Voice; language: string }> {
89
- this.voices ??= this.call("/voices")
90
- .then(async (res) => ((await res.json()) as { items: Voice[] }).items)
91
- .catch((e: unknown) => {
92
- this.voices = undefined;
93
- throw e;
94
- });
95
- const all = await this.voices;
96
- const found = all.filter((v) => names(v).includes(props.voice)).sort((a, b) => newer(a.voice_version, b.voice_version));
97
- if (found.length === 0) {
98
- const have = [...new Set(all.map((v) => `${v.voice_name} (${names(v).slice(1).join(", ")})`))];
99
- throw new Error(`VoiSona Talk has no voice \`${props.voice}\` (it has ${have.join(", ") || "none"}; download voices in the app)`);
100
- }
101
- if (new Set(found.map((v) => v.voice_name)).size > 1) {
102
- throw new Error(`VoiSona Talk has several voices named \`${props.voice}\`: write one of ${[...new Set(found.map((v) => v.voice_name))].join(", ")}`);
103
- }
104
- const voice = props.version === undefined ? found[0] : found.find((v) => v.voice_version === props.version);
105
- if (!voice) throw new Error(`VoiSona Talk has no version ${props.version} of \`${props.voice}\` (it has ${found.map((v) => v.voice_version).join(", ")})`);
106
- const language = props.language ?? voice.languages[0];
107
- if (!voice.languages.includes(language)) {
108
- throw new Error(`the VoiSona Talk voice \`${props.voice}\` does not speak ${language} (it speaks ${voice.languages.join(", ")})`);
109
- }
110
- return { voice, language };
111
- }
112
-
113
- private async styleWeights(voice: Voice, style: Readonly<Record<string, number>>): Promise<number[]> {
114
- const key = `${voice.voice_name}/${voice.voice_version}`;
115
- let detail = this.details.get(key);
116
- if (!detail) {
117
- detail = this.call(`/voices/${encodeURIComponent(voice.voice_name)}/${encodeURIComponent(voice.voice_version)}`).then(
118
- async (res) => (await res.json()) as VoiceDetail,
119
- );
120
- detail.catch(() => this.details.delete(key));
121
- this.details.set(key, detail);
122
- }
123
- const { style_names: styles = [], default_style_weights: defaults = [] } = await detail;
124
- const weights = styles.map((_, i) => defaults[i] ?? 0);
125
- for (const [name, weight] of Object.entries(style)) {
126
- const i = styles.indexOf(name);
127
- if (i < 0) throw new Error(`the VoiSona Talk voice \`${voice.voice_name}\` has no style \`${name}\` (it has ${styles.join(", ") || "none"})`);
128
- weights[i] = weight;
129
- }
130
- return weights;
131
- }
132
-
133
- private async speak(body: object, text: string): Promise<Buffer> {
134
- const res = await this.call("/speech-syntheses", {
135
- method: "POST",
136
- headers: { "Content-Type": "application/json" },
137
- body: JSON.stringify({ ...body, text }),
138
- });
139
- const { uuid } = (await res.json()) as { uuid: string };
140
- try {
141
- const until = Date.now() + TIMEOUT;
142
- for (;;) {
143
- const { state } = (await (await this.call(`/speech-syntheses/${uuid}`)).json()) as Synthesis;
144
- if (state === "succeeded") break;
145
- if (state === "failed") throw new Error(`VoiSona Talk could not read: ${text.slice(0, 40)}`);
146
- if (Date.now() > until) throw new Error(`VoiSona Talk did not finish in ${TIMEOUT / 1000}s: ${text.slice(0, 40)}`);
147
- await Bun.sleep(100);
148
- }
149
- return Buffer.from(await (await this.call(`/speech-syntheses/${uuid}/wav`)).arrayBuffer());
150
- } finally {
151
- await this.call(`/speech-syntheses/${uuid}`, { method: "DELETE" }).catch(() => {});
152
- }
153
- }
154
-
155
- private async write(props: VoisonaProps, text: string, out: string): Promise<void> {
156
- const { voice, language } = await this.voice(props);
157
- const global: Record<string, number | number[]> = {};
158
- for (const key of ["speed", "pitch", "volume", "alp", "huskiness", "intonation"] as const) {
159
- const value = props[key];
160
- if (value !== undefined) global[key] = value;
161
- }
162
- if (props.style) global.style_weights = await this.styleWeights(voice, props.style);
163
- const body = {
164
- language,
165
- voice_name: voice.voice_name,
166
- voice_version: voice.voice_version,
167
- global_parameters: global,
168
- destination: "memory",
169
- force_enqueue: true,
170
- };
171
- const wavs: Buffer[] = [];
172
- for (const part of splitText(text, props["max-chars"] ?? 500)) wavs.push(await this.speak(body, part));
173
- await Bun.write(out, wavs.length === 1 ? wavs[0] : joinWavs(wavs));
174
- }
175
-
176
- synthesize(props: VoisonaProps, text: string, out: string): Promise<void> {
177
- return this.limited(() => this.write(props, text, out));
178
- }
179
- }
180
-
181
- const servers = new Map<string, Server>();
182
-
183
- const server = (url: string | undefined): Server => {
184
- const at = (url ?? process.env.MEKURU_VOISONA_URL ?? DEFAULT_URL).replace(/\/+$/, "");
185
- const found = servers.get(at);
186
- if (found) return found;
187
- const created = new Server(at);
188
- servers.set(at, created);
189
- return created;
190
- };
191
-
192
- export const Voisona = async (props: VoisonaProps): Promise<Awaited<JSX.Element>> => {
1
+ import { defineSpeech, prepare, voiceOf } from "@mekuru/speech";
2
+ import { server } from "./client.ts";
3
+ import type { VoisonaProps } from "./props.ts";
4
+ export type { VoisonaProps } from "./props.ts";
5
+ const prepareLine = async (props: Omit<VoisonaProps, "id" | "at">) => {
193
6
  const { url, ...voice } = voiceOf(props);
194
- return line("voisona", props, props.voice, voice, (say, out) => server(url).synthesize(props, say, out));
7
+ return prepare("voisona", props, props.voice, voice, (say, out) => server(url).synthesize(voice, say, out));
195
8
  };
196
9
 
10
+ export const Voisona = defineSpeech<VoisonaProps>(prepareLine);
11
+
197
12
  export * from "./characters.ts";
package/src/props.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { SpeechProps } from "@mekuru/speech";
2
+ export interface VoisonaProps extends SpeechProps {
3
+ voice: string;
4
+ version?: string;
5
+ language?: string;
6
+ speed?: number;
7
+ pitch?: number;
8
+ volume?: number;
9
+ alp?: number;
10
+ huskiness?: number;
11
+ intonation?: number;
12
+ style?: Readonly<Record<string, number>>;
13
+ "max-chars"?: number;
14
+ url?: string;
15
+ }