@mekuru/voisona 0.0.0-stage → 0.1.2

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 ADDED
@@ -0,0 +1,123 @@
1
+ # @mekuru/voisona
2
+
3
+ [English](README.md)
4
+
5
+ Mekuru Movie Maker のセリフを、VoiSona Talk の REST API で読み上げる `Voisona` 部品です。
6
+
7
+ ## セットアップ
8
+
9
+ Windows・macOS で VoiSona Talk 1.3.7 以降にサインインし、ボイスライブラリを取得します。環境設定の「API」タブで API パスワードを設定し、「REST APIを有効にする」をオンにします(ポート `32766`)。アプリは起動したまま使います。[公式の設定手順](https://manual.voisona.com/ja/talk/pc/2b6e9bc7efb180ea86ccc6c7347e9ca6)。
10
+
11
+ Bun を動かすシェルで、サインインのメールアドレスと「API」タブのパスワードを設定します。
12
+
13
+ ```sh
14
+ export MEKURU_VOISONA_USER='you@example.com'
15
+ export MEKURU_VOISONA_PASSWORD='your-api-password'
16
+ ```
17
+
18
+ 例では `tanaka-san_ja_JP`(田中傘)を使います。取得済みのライブラリを指定し、その利用規約に従ってください。
19
+
20
+ Bun 1.4 以上・libx264 付き ffmpeg・ffprobe・日本語フォントを用意し、プロジェクトに追加します。
21
+
22
+ ```sh
23
+ bun add -d @mekuru/core @mekuru/cli @mekuru/voisona
24
+ ```
25
+
26
+ `tsconfig.json` に `jsx: "react-jsx"` と `jsxImportSource: "@mekuru/core"` を設定します。`bun create @mekuru` では設定済みです。
27
+
28
+ ## 簡単な使い方
29
+
30
+ `src/main.tsx` に保存します。
31
+
32
+ ```tsx
33
+ import { writeXml } from "@mekuru/core";
34
+ import { Voisona } from "@mekuru/voisona";
35
+
36
+ const text = "こんにちは。";
37
+
38
+ await writeXml(
39
+ ".mekuru/main.xml",
40
+ <mekuru size="1280x720" fps={30}>
41
+ <stack>
42
+ <Voisona voice="tanaka-san_ja_JP">{text}</Voisona>
43
+ <text x="50%" y="50%" anchor="center" size={60} color="#FFFFFF">{text}</text>
44
+ </stack>
45
+ </mekuru>,
46
+ );
47
+ ```
48
+
49
+ ```sh
50
+ bun src/main.tsx
51
+ bunx mekuru-server .mekuru/main.xml
52
+ ```
53
+
54
+ `http://localhost:3333` でプレビューします。書き出しは `bunx mekuru-compiler .mekuru/main.xml -o video.mp4`。
55
+
56
+ ## キャラクター
57
+
58
+ `<Voisona>` に渡すキャラクターごとの設定をエクスポートしています。展開して使い、ほかの props も追加できます。
59
+
60
+ ```tsx
61
+ import { Voisona, tanaka } from "@mekuru/voisona";
62
+
63
+ <Voisona {...tanaka}>こんにちは。</Voisona>;
64
+ ```
65
+
66
+ | エクスポート | 名前 | `voice` |
67
+ | --- | --- | --- |
68
+ | `tanaka` | 田中傘 | `"tanaka-san_ja_JP"` |
69
+ | `chisa` | 知声 | `"chis-a_ja_JP"` |
70
+ | `sasara` | さとうささら | `"sato-sasara_ja_JP"` |
71
+ | `tsuzumi` | すずきつづみ | `"suzuki-tsudumi_ja_JP"` |
72
+ | `takahashi` | タカハシ | `"takahashi_ja_JP"` |
73
+ | `rikka` | 小春六花 | `"koharu-rikka_ja_JP"` |
74
+ | `karin` | 夏色花梨 | `"natsuki-karin_ja_JP"` |
75
+ | `chifuyu` | 花隈千冬 | `"hanakuma-chifuyu_ja_JP"` |
76
+ | `minato` | 双葉湊音 | `"futaba-minato_ja_JP"` |
77
+
78
+ ## 詳しい使い方
79
+
80
+ ### 声の設定 (`VoisonaProps`)
81
+
82
+ - `voice`: 必須: `"tanaka-san_ja_JP"` などのボイスライブラリ名、または `"田中傘"` などの表示名。
83
+ - `version`: ボイスライブラリのバージョン。既定は選択した声のインストール済みの最新版。
84
+ - `language`: `"ja_JP"` などの言語コード。既定はその声の一覧の最初の言語。
85
+ - `speed`: API の単位での話す速さ。
86
+ - `pitch`: 声の高さの調整(セント)。
87
+ - `volume`: 合成時の音量(dB)。
88
+ - `alp`: 声質のパラメータ(`alp`)。
89
+ - `huskiness`: 声のかすれ具合。
90
+ - `intonation`: 抑揚の強さ。
91
+ - `style`: スタイル名と強さの辞書。`happy` がある声なら `{ happy: 0.5 }` など。指定しないスタイルはその声の既定の強さを使います。
92
+ - `max-chars`: 1回の合成リクエストの最大文字数。既定は `500`。
93
+ - `url`: API のベース URL。既定は `http://localhost:32766`。`/api/talk/v1` は含めません。
94
+
95
+ 省略した数値設定は API の既定値を使います。`/api/talk/v1/voices` で声・バージョン・言語、`/api/talk/v1/voices/<voice_name>/<voice_version>` で `style_names`・`default_style_weights` を確認できます。表示名が重複する場合は `voice_name` を指定します。長文は句読点の近くで分割して一つの WAV に結合し、`gap` は全体の後に入れます。
96
+
97
+ ### テキストと時間
98
+
99
+ - `children`: セリフ。文字列・数値・フラグメント・`<br />`。空の読み上げはエラー。
100
+ - `name`: タイムラインの話者名。既定: `voice`。
101
+ - `reading`: 読みの辞書。例: `{ Mekuru: "めくる" }`。長いキーを優先し、表示は変えない。
102
+ - `gap`: セリフの後の無音(フレーム)。既定: `Math.round(0.3 * fps)`(30 fps で9)。
103
+ - `gain-db`: 再生音量の調整(dB)。既定: 調整なし。
104
+ - `len`: 無音を含む全体のフレーム数。既定: 音声の長さを切り上げた値 + `gap`。話速は変えない。
105
+ - `id`・`at`: 生成する `<dur>` の識別名と開始時刻。[時間の属性](../../docs/reference/Syntax.jp.md#時間の属性)を参照。
106
+
107
+ ルビ `|表示《よみ》` は「よみ」を読み、タイムラインには「表示」を残します。半角 `|` と `漢字《かんじ》` も使用できます。`<br />` は空白になり、ほかの要素は入れられません。セリフの連続再生は `<seq>`、字幕との同時表示は `<stack>`、口パクは[立ち絵の `Talk`](../portrait/README.jp.md) を使います。
108
+
109
+ ### キャッシュとプレビュー
110
+
111
+ - 音声はエンジン・声の設定・読み上げテキストをキーに `.mekuru/cache/tts/` へ保存。キャッシュ済みならエンジン停止中も使用できます。
112
+ - 接続先や再生設定の変更では再合成しません。同名のモデルを更新した場合などは、該当する WAV を削除して再生成します。
113
+ - `writeXml()` は合成中に無音の下書きを出し、完了後に XML を更新します。動画の書き出し前に `await` してください。
114
+
115
+ ### 接続
116
+
117
+ 接続先は `url` → `MEKURU_VOISONA_URL` → 既定 URL の順で決まります。Bun と同じ環境から確認します。
118
+
119
+ ```sh
120
+ curl --user "$MEKURU_VOISONA_USER" http://localhost:32766/api/talk/v1/voices
121
+ ```
122
+
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` を指定します。
package/README.md CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # @mekuru/voisona
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [日本語](README.jp.md)
4
+
5
+ The `Voisona` component reads Mekuru Movie Maker dialogue through the VoiSona Talk REST API.
6
+
7
+ ## Setup
8
+
9
+ Install VoiSona Talk 1.3.7 or later on Windows or macOS, sign in, and download a voice library. In Preferences → API, set an API password and enable the REST API (port `32766`). Keep the app running. See the [official setup guide](https://manual.voisona.com/en/talk/pc/2b6e9bc7efb18014b922c93fcaa8aac4).
10
+
11
+ Set these variables in the shell running Bun. Use your sign-in email and the password set in the API tab:
12
+
13
+ ```sh
14
+ export MEKURU_VOISONA_USER='you@example.com'
15
+ export MEKURU_VOISONA_PASSWORD='your-api-password'
16
+ ```
17
+
18
+ The example uses `tanaka-san_ja_JP` (田中傘). Use an installed library and follow its terms.
19
+
20
+ Requires Bun 1.4+, ffmpeg with libx264, ffprobe, and Japanese fonts. Install in the project folder:
21
+
22
+ ```sh
23
+ bun add -d @mekuru/core @mekuru/cli @mekuru/voisona
24
+ ```
25
+
26
+ In `tsconfig.json`, set `jsx: "react-jsx"` and `jsxImportSource: "@mekuru/core"` (already set by `bun create @mekuru`).
27
+
28
+ ## Quick start
29
+
30
+ Save as `src/main.tsx`:
31
+
32
+ ```tsx
33
+ import { writeXml } from "@mekuru/core";
34
+ import { Voisona } from "@mekuru/voisona";
35
+
36
+ const text = "こんにちは。";
37
+
38
+ await writeXml(
39
+ ".mekuru/main.xml",
40
+ <mekuru size="1280x720" fps={30}>
41
+ <stack>
42
+ <Voisona voice="tanaka-san_ja_JP">{text}</Voisona>
43
+ <text x="50%" y="50%" anchor="center" size={60} color="#FFFFFF">{text}</text>
44
+ </stack>
45
+ </mekuru>,
46
+ );
47
+ ```
48
+
49
+ ```sh
50
+ bun src/main.tsx
51
+ bunx mekuru-server .mekuru/main.xml
52
+ ```
53
+
54
+ Preview: `http://localhost:3333`. Export with `bunx mekuru-compiler .mekuru/main.xml -o video.mp4`.
55
+
56
+ ## Characters
57
+
58
+ The package exports each character's settings for `<Voisona>`. Spread one and add other props as needed.
59
+
60
+ ```tsx
61
+ import { Voisona, tanaka } from "@mekuru/voisona";
62
+
63
+ <Voisona {...tanaka}>こんにちは。</Voisona>;
64
+ ```
65
+
66
+ | Export | Name | `voice` |
67
+ | --- | --- | --- |
68
+ | `tanaka` | 田中傘 | `"tanaka-san_ja_JP"` |
69
+ | `chisa` | 知声 | `"chis-a_ja_JP"` |
70
+ | `sasara` | さとうささら | `"sato-sasara_ja_JP"` |
71
+ | `tsuzumi` | すずきつづみ | `"suzuki-tsudumi_ja_JP"` |
72
+ | `takahashi` | タカハシ | `"takahashi_ja_JP"` |
73
+ | `rikka` | 小春六花 | `"koharu-rikka_ja_JP"` |
74
+ | `karin` | 夏色花梨 | `"natsuki-karin_ja_JP"` |
75
+ | `chifuyu` | 花隈千冬 | `"hanakuma-chifuyu_ja_JP"` |
76
+ | `minato` | 双葉湊音 | `"futaba-minato_ja_JP"` |
77
+
78
+ ## Detailed usage
79
+
80
+ ### Voice (`VoisonaProps`)
81
+
82
+ - `voice`: Required. Voice library name, e.g. `"tanaka-san_ja_JP"`, or display name, e.g. `"田中傘"`.
83
+ - `version`: Voice library version. Default: newest installed version of the selected voice.
84
+ - `language`: Language code such as `"ja_JP"`. Default: the voice's first listed language.
85
+ - `speed`: Speaking speed, in the API's units.
86
+ - `pitch`: Pitch adjustment in cents.
87
+ - `volume`: Synthesis volume in dB.
88
+ - `alp`: Voice character parameter (`alp`).
89
+ - `huskiness`: Voice huskiness.
90
+ - `intonation`: Intonation strength.
91
+ - `style`: Map of style names to weights, e.g. `{ happy: 0.5 }` if that voice has a `happy` style. Unspecified weights keep the voice's defaults.
92
+ - `max-chars`: Maximum characters per synthesis request. Default: `500`.
93
+ - `url`: Base URL; default `http://localhost:32766`. Do not include `/api/talk/v1`.
94
+
95
+ Omitted numeric voice parameters use the API defaults. `/api/talk/v1/voices` lists voices, versions, and languages; `/api/talk/v1/voices/<voice_name>/<voice_version>` provides `style_names` and `default_style_weights`. Use `voice_name` if display names overlap. Long lines are split near punctuation and joined into one WAV; `gap` follows the complete line.
96
+
97
+ ### Text and timing
98
+
99
+ - `children`: Dialogue: strings, numbers, fragments, or `<br />`. Empty speech is an error.
100
+ - `name`: Timeline speaker label. Default: `voice`.
101
+ - `reading`: Pronunciation dictionary, e.g. `{ Mekuru: "めくる" }`. Longer keys win; displayed text is unchanged.
102
+ - `gap`: Trailing silence in frames. Default: `Math.round(0.3 * fps)` (9 at 30 fps).
103
+ - `gain-db`: Playback gain in dB. Default: no adjustment.
104
+ - `len`: Total frames, including the gap. Default: rounded-up audio duration + `gap`. Does not change speaking speed.
105
+ - `id`, `at`: Identifier and start timing for the generated `<dur>`; see [timing](../../docs/reference/Syntax.md#timing-attributes).
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).
108
+
109
+ ### Cache and preview
110
+
111
+ - Audio is cached in `.mekuru/cache/tts/` by engine, voice settings, and spoken text. Cached lines work without a running engine.
112
+ - Connection URLs and playback settings do not invalidate audio. Remove the cached WAV to regenerate speech after changing a server or updating a model under the same name.
113
+ - `writeXml()` writes a silent draft while synthesizing, then replaces it with completed XML. Await it before video export.
114
+
115
+ ### Connection
116
+
117
+ `url` → `MEKURU_VOISONA_URL` → the default URL, in that order. Check from the same environment as Bun:
118
+
119
+ ```sh
120
+ curl --user "$MEKURU_VOISONA_USER" http://localhost:32766/api/talk/v1/voices
121
+ ```
122
+
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.
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@mekuru/voisona",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "The speech component <Voisona> of Mekuru Movie Maker: lines of speech read by VoiSona Talk, through its REST API",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.tsx"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "sideEffects": false,
13
+ "peerDependencies": {
14
+ "@mekuru/core": "^0.1.2"
15
+ },
16
+ "devDependencies": {
17
+ "@mekuru/core": "0.1.2"
18
+ },
19
+ "dependencies": {
20
+ "@mekuru/speech": "^0.1.2"
21
+ },
22
+ "engines": {
23
+ "bun": ">=1.4"
24
+ }
25
+ }
@@ -0,0 +1,11 @@
1
+ import type { VoisonaProps } from "./index.tsx";
2
+
3
+ export const tanaka = { name: "田中傘", voice: "tanaka-san_ja_JP" } satisfies Partial<VoisonaProps>;
4
+ export const chisa = { name: "知声", voice: "chis-a_ja_JP" } satisfies Partial<VoisonaProps>;
5
+ export const sasara = { name: "さとうささら", voice: "sato-sasara_ja_JP" } satisfies Partial<VoisonaProps>;
6
+ export const tsuzumi = { name: "すずきつづみ", voice: "suzuki-tsudumi_ja_JP" } satisfies Partial<VoisonaProps>;
7
+ export const takahashi = { name: "タカハシ", voice: "takahashi_ja_JP" } satisfies Partial<VoisonaProps>;
8
+ export const rikka = { name: "小春六花", voice: "koharu-rikka_ja_JP" } satisfies Partial<VoisonaProps>;
9
+ export const karin = { name: "夏色花梨", voice: "natsuki-karin_ja_JP" } satisfies Partial<VoisonaProps>;
10
+ export const chifuyu = { name: "花隈千冬", voice: "hanakuma-chifuyu_ja_JP" } satisfies Partial<VoisonaProps>;
11
+ export const minato = { name: "双葉湊音", voice: "futaba-minato_ja_JP" } satisfies Partial<VoisonaProps>;
package/src/index.tsx ADDED
@@ -0,0 +1,197 @@
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>> => {
193
+ const { url, ...voice } = voiceOf(props);
194
+ return line("voisona", props, props.voice, voice, (say, out) => server(url).synthesize(props, say, out));
195
+ };
196
+
197
+ export * from "./characters.ts";