@kongyo2/kongyoroid 1.0.0 → 2.0.0

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.
Files changed (199) hide show
  1. package/README.md +233 -0
  2. package/dist/cache.d.ts +32 -7
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/cache.js +138 -9
  5. package/dist/cache.js.map +1 -1
  6. package/dist/cli/flags.d.ts +270 -0
  7. package/dist/cli/flags.d.ts.map +1 -0
  8. package/dist/cli/flags.js +240 -0
  9. package/dist/cli/flags.js.map +1 -0
  10. package/dist/cli/help.d.ts +11 -0
  11. package/dist/cli/help.d.ts.map +1 -0
  12. package/dist/cli/help.js +336 -0
  13. package/dist/cli/help.js.map +1 -0
  14. package/dist/cli.js +598 -232
  15. package/dist/cli.js.map +1 -1
  16. package/dist/errors.d.ts +43 -3
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +56 -3
  19. package/dist/errors.js.map +1 -1
  20. package/dist/index.d.ts +59 -22
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +32 -14
  23. package/dist/index.js.map +1 -1
  24. package/dist/io.d.ts +11 -0
  25. package/dist/io.d.ts.map +1 -1
  26. package/dist/io.js +149 -5
  27. package/dist/io.js.map +1 -1
  28. package/dist/kongyoroid.d.ts +237 -10
  29. package/dist/kongyoroid.d.ts.map +1 -1
  30. package/dist/kongyoroid.js +777 -86
  31. package/dist/kongyoroid.js.map +1 -1
  32. package/dist/limits.d.ts +5 -0
  33. package/dist/limits.d.ts.map +1 -1
  34. package/dist/limits.js +6 -1
  35. package/dist/limits.js.map +1 -1
  36. package/dist/request.d.ts +1 -0
  37. package/dist/request.d.ts.map +1 -1
  38. package/dist/request.js +102 -15
  39. package/dist/request.js.map +1 -1
  40. package/dist/schema.d.ts +3 -0
  41. package/dist/schema.d.ts.map +1 -1
  42. package/dist/schema.js +271 -68
  43. package/dist/schema.js.map +1 -1
  44. package/dist/song/mml.d.ts +15 -0
  45. package/dist/song/mml.d.ts.map +1 -0
  46. package/dist/song/mml.js +184 -0
  47. package/dist/song/mml.js.map +1 -0
  48. package/dist/song/pitch.d.ts.map +1 -0
  49. package/dist/{pitch.js → song/pitch.js} +1 -1
  50. package/dist/song/pitch.js.map +1 -0
  51. package/dist/song/score.d.ts +92 -0
  52. package/dist/song/score.d.ts.map +1 -0
  53. package/dist/song/score.js +454 -0
  54. package/dist/song/score.js.map +1 -0
  55. package/dist/synth/acoustics.d.ts +27 -0
  56. package/dist/synth/acoustics.d.ts.map +1 -0
  57. package/dist/synth/acoustics.js +608 -0
  58. package/dist/synth/acoustics.js.map +1 -0
  59. package/dist/synth/analysis.d.ts +22 -0
  60. package/dist/synth/analysis.d.ts.map +1 -0
  61. package/dist/synth/analysis.js +170 -0
  62. package/dist/synth/analysis.js.map +1 -0
  63. package/dist/synth/engine.d.ts +115 -0
  64. package/dist/synth/engine.d.ts.map +1 -0
  65. package/dist/synth/engine.js +223 -0
  66. package/dist/synth/engine.js.map +1 -0
  67. package/dist/synth/filters.d.ts +60 -0
  68. package/dist/synth/filters.d.ts.map +1 -0
  69. package/dist/synth/filters.js +166 -0
  70. package/dist/synth/filters.js.map +1 -0
  71. package/dist/synth/glottal.d.ts +20 -0
  72. package/dist/synth/glottal.d.ts.map +1 -0
  73. package/dist/synth/glottal.js +76 -0
  74. package/dist/synth/glottal.js.map +1 -0
  75. package/dist/synth/phonemes.d.ts +42 -0
  76. package/dist/synth/phonemes.d.ts.map +1 -0
  77. package/dist/synth/phonemes.js +311 -0
  78. package/dist/synth/phonemes.js.map +1 -0
  79. package/dist/synth/plan.d.ts +127 -0
  80. package/dist/synth/plan.d.ts.map +1 -0
  81. package/dist/synth/plan.js +81 -0
  82. package/dist/synth/plan.js.map +1 -0
  83. package/dist/synth/prosody.d.ts +30 -0
  84. package/dist/synth/prosody.d.ts.map +1 -0
  85. package/dist/synth/prosody.js +46 -0
  86. package/dist/synth/prosody.js.map +1 -0
  87. package/dist/synth/random.d.ts +15 -0
  88. package/dist/synth/random.d.ts.map +1 -0
  89. package/dist/synth/random.js +59 -0
  90. package/dist/synth/random.js.map +1 -0
  91. package/dist/synth/renderer.d.ts +77 -0
  92. package/dist/synth/renderer.d.ts.map +1 -0
  93. package/dist/synth/renderer.js +343 -0
  94. package/dist/synth/renderer.js.map +1 -0
  95. package/dist/synth/song.d.ts +30 -0
  96. package/dist/synth/song.d.ts.map +1 -0
  97. package/dist/synth/song.js +453 -0
  98. package/dist/synth/song.js.map +1 -0
  99. package/dist/synth/speech.d.ts +49 -0
  100. package/dist/synth/speech.d.ts.map +1 -0
  101. package/dist/synth/speech.js +354 -0
  102. package/dist/synth/speech.js.map +1 -0
  103. package/dist/synth/stream.d.ts +26 -0
  104. package/dist/synth/stream.d.ts.map +1 -0
  105. package/dist/synth/stream.js +67 -0
  106. package/dist/synth/stream.js.map +1 -0
  107. package/dist/synth/textstream.d.ts +35 -0
  108. package/dist/synth/textstream.d.ts.map +1 -0
  109. package/dist/synth/textstream.js +127 -0
  110. package/dist/synth/textstream.js.map +1 -0
  111. package/dist/synth/voice.d.ts +64 -0
  112. package/dist/synth/voice.d.ts.map +1 -0
  113. package/dist/synth/voice.js +208 -0
  114. package/dist/synth/voice.js.map +1 -0
  115. package/dist/text/dictionary.d.ts +50 -0
  116. package/dist/text/dictionary.d.ts.map +1 -0
  117. package/dist/text/dictionary.js +0 -0
  118. package/dist/text/dictionary.js.map +1 -0
  119. package/dist/text/frontend.d.ts +34 -0
  120. package/dist/text/frontend.d.ts.map +1 -0
  121. package/dist/text/frontend.js +550 -0
  122. package/dist/text/frontend.js.map +1 -0
  123. package/dist/text/lexicon.d.ts +10 -0
  124. package/dist/text/lexicon.d.ts.map +1 -0
  125. package/dist/text/lexicon.js +763 -0
  126. package/dist/text/lexicon.js.map +1 -0
  127. package/dist/{mora.d.ts → text/mora.d.ts} +15 -3
  128. package/dist/text/mora.d.ts.map +1 -0
  129. package/dist/{mora.js → text/mora.js} +87 -9
  130. package/dist/text/mora.js.map +1 -0
  131. package/dist/text/normalize.d.ts +34 -0
  132. package/dist/text/normalize.d.ts.map +1 -0
  133. package/dist/text/normalize.js +496 -0
  134. package/dist/text/normalize.js.map +1 -0
  135. package/dist/text/notation.d.ts +32 -0
  136. package/dist/text/notation.d.ts.map +1 -0
  137. package/dist/text/notation.js +182 -0
  138. package/dist/text/notation.js.map +1 -0
  139. package/dist/text/reading.d.ts +52 -0
  140. package/dist/text/reading.d.ts.map +1 -0
  141. package/dist/text/reading.js +32 -0
  142. package/dist/text/reading.js.map +1 -0
  143. package/dist/text/sentences.d.ts +10 -0
  144. package/dist/text/sentences.d.ts.map +1 -0
  145. package/dist/text/sentences.js +84 -0
  146. package/dist/text/sentences.js.map +1 -0
  147. package/dist/{text.d.ts → text/split.d.ts} +2 -2
  148. package/dist/text/split.d.ts.map +1 -0
  149. package/dist/{text.js → text/split.js} +2 -2
  150. package/dist/text/split.js.map +1 -0
  151. package/dist/types.d.ts +58 -3
  152. package/dist/types.d.ts.map +1 -1
  153. package/dist/version.d.ts +4 -0
  154. package/dist/version.d.ts.map +1 -1
  155. package/dist/version.js +5 -1
  156. package/dist/version.js.map +1 -1
  157. package/dist/voicevox/client.d.ts +1 -1
  158. package/dist/voicevox/client.d.ts.map +1 -1
  159. package/dist/voicevox/dictionary.js +1 -1
  160. package/dist/voicevox/dictionary.js.map +1 -1
  161. package/dist/voicevox/song.js +1 -1
  162. package/dist/voicevox/song.js.map +1 -1
  163. package/dist/voicevox/speech.d.ts +1 -0
  164. package/dist/voicevox/speech.d.ts.map +1 -1
  165. package/dist/voicevox/speech.js +8 -2
  166. package/dist/voicevox/speech.js.map +1 -1
  167. package/docs/cli.md +251 -0
  168. package/examples/batch.jsonl +4 -3
  169. package/examples/builtin/dictionary.json +7 -0
  170. package/examples/builtin/song-compact.json +13 -0
  171. package/examples/builtin/song-mml.json +9 -0
  172. package/examples/builtin/song-notes.json +25 -0
  173. package/examples/builtin/speech-dictionary.json +9 -0
  174. package/examples/builtin/speech-kana.json +7 -0
  175. package/examples/builtin/speech.json +7 -0
  176. package/package.json +11 -3
  177. package/dist/formant.d.ts +0 -34
  178. package/dist/formant.d.ts.map +0 -1
  179. package/dist/formant.js +0 -622
  180. package/dist/formant.js.map +0 -1
  181. package/dist/mora.d.ts.map +0 -1
  182. package/dist/mora.js.map +0 -1
  183. package/dist/notation.d.ts +0 -15
  184. package/dist/notation.d.ts.map +0 -1
  185. package/dist/notation.js +0 -114
  186. package/dist/notation.js.map +0 -1
  187. package/dist/pitch.d.ts.map +0 -1
  188. package/dist/pitch.js.map +0 -1
  189. package/dist/score.d.ts +0 -37
  190. package/dist/score.d.ts.map +0 -1
  191. package/dist/score.js +0 -166
  192. package/dist/score.js.map +0 -1
  193. package/dist/text.d.ts.map +0 -1
  194. package/dist/text.js.map +0 -1
  195. package/examples/song-notes.json +0 -24
  196. /package/dist/{pitch.d.ts → song/pitch.d.ts} +0 -0
  197. /package/examples/{song.json → voicevox/song.json} +0 -0
  198. /package/examples/{speech-kana.json → voicevox/speech-kana.json} +0 -0
  199. /package/examples/{speech.json → voicevox/speech.json} +0 -0
package/dist/cli.js CHANGED
@@ -1,218 +1,109 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve } from "node:path";
3
3
  import process from "node:process";
4
- import { parseArgs } from "node:util";
5
- import { EXIT_CODES, KongyoroidError, asKongyoroidError, exitCodeOf, invalid } from "./errors.js";
6
- import { readLines, readText, writeAudio, writeStream } from "./io.js";
4
+ import { commandHelp, rootHelp } from "./cli/help.js";
5
+ import { ALLOWED, assign, checkAllowed, env, numberFlag, parseCommandLine, parseDictEntries, styleFlag, } from "./cli/flags.js";
6
+ import { DiskCache } from "./cache.js";
7
+ import { EXIT_CODES, KongyoroidError, asKongyoroidError, exitCodeOf, formatDiagnostic, invalid } from "./errors.js";
8
+ import { existingFileSha256, readBytes, readChunks, readLines, readText, writeAudioIdempotent, writeStream, writeTextFile, writeWithBackpressure, } from "./io.js";
7
9
  import { LIMITS } from "./limits.js";
8
10
  import { Kongyoroid } from "./kongyoroid.js";
9
11
  import { playWav } from "./player.js";
10
12
  import { parseEngine, parseJson } from "./request.js";
11
- import { BATCH_JOB_SCHEMA, CAPABILITIES, REQUEST_SCHEMA } from "./schema.js";
13
+ import { BATCH_JOB_SCHEMA, CAPABILITIES, DICTIONARY_SCHEMA, REQUEST_SCHEMA } from "./schema.js";
14
+ import { pcm16Bytes, streamingWavHeader } from "./synth/stream.js";
15
+ import { LocalDictionary } from "./text/dictionary.js";
12
16
  import { isObject, keys, literal, object, string } from "./validate.js";
13
17
  import { PACKAGE_NAME, VERSION } from "./version.js";
14
- const HELP = `kongyoroid ${VERSION} Japanese speech and singing for LLM agents (built-in formant engine, optional VOICEVOX ENGINE)
15
-
16
- Usage: kongyoroid <command> [flags]
17
-
18
- speak --text "こんにちは" [--speaker ずんだもん] [-o out.wav] Read text aloud
19
- sing --lyrics "ドレミ" --melody "C4 D4 E4" [--beats "1 1 2"] Sing a score
20
- render --input request.json | - Render a full JSON request (see: schema)
21
- batch --input jobs.jsonl --output-dir out Many renders, one JSONL line per job
22
- reading --text "..." [--speaker ...] Show the engine's reading (kana notation)
23
- voices [--kind speech|song|all] [--query 名前] List VOICEVOX styles (id, character, type)
24
- doctor [--speaker ...] [--initialize] Check the engine, versions, style counts
25
- dict list | add | update <uuid> | delete <uuid> Manage the VOICEVOX user dictionary
26
- play <file.wav> Play a WAV with the system player
27
- schema [--kind request|batch] JSON Schema of the request format
28
- capabilities Machine-readable feature descriptor
29
-
30
- Common flags
31
- --engine formant|voicevox|auto (default formant; a speaker/singer/teacher selects voicevox)
32
- --endpoint URL --timeout-ms N --retries N --cache-dir DIR
33
- -o, --output FILE|- (default: kongyoroid-<kind>-<hash>.wav; "-" streams WAV to stdout, JSON to stderr)
34
- --force replace an existing output file --play play the result after writing
35
-
36
- speak flags: -t/--text, -i/--input FILE|- (text), --kana NOTATION, --speaker ID|NAME, --speed, --pitch,
37
- --intonation, --volume, --pre-pause, --post-pause, --pause-length, --pause-scale, --no-upspeak,
38
- --split sentence|paragraph|none, --sample-rate, --seed
39
- sing flags: --lyrics, --melody, --beats, --tempo, --singer, --teacher, --transpose, --volume, --vibrato-depth,
40
- --vibrato-rate, --lead-in, --lead-out, --sample-rate, --seed, -i/--input FILE|- (song JSON; flags override)
41
- dict flags: --surface, --pronunciation, --accent N, --word-type PROPER_NOUN|COMMON_NOUN|VERB|ADJECTIVE|SUFFIX, --priority 0-10
42
-
43
- Environment: KONGYOROID_ENDPOINT (or VOICEVOX_URL), KONGYOROID_ENGINE, KONGYOROID_SPEAKER, KONGYOROID_SINGER,
44
- KONGYOROID_TEACHER, KONGYOROID_CACHE_DIR
45
- Output: one JSON object per line on stdout ({ "ok": true, ... }); errors on stderr ({ "ok": false, "error": {...} }).
46
- Exit codes: 0 ok, 1 internal, 2 input, 3 engine, 4 io, 130 cancelled.
47
- `;
48
- const OPTIONS = {
49
- help: { type: "boolean", short: "h" },
50
- version: { type: "boolean", short: "v" },
51
- text: { type: "string", short: "t" },
52
- input: { type: "string", short: "i" },
53
- output: { type: "string", short: "o" },
54
- "output-dir": { type: "string" },
55
- force: { type: "boolean" },
56
- play: { type: "boolean" },
57
- engine: { type: "string" },
58
- endpoint: { type: "string" },
59
- "timeout-ms": { type: "string" },
60
- retries: { type: "string" },
61
- concurrency: { type: "string" },
62
- "cache-dir": { type: "string" },
63
- kana: { type: "string" },
64
- speaker: { type: "string" },
65
- speed: { type: "string" },
66
- pitch: { type: "string" },
67
- intonation: { type: "string" },
68
- volume: { type: "string" },
69
- "pre-pause": { type: "string" },
70
- "post-pause": { type: "string" },
71
- "pause-length": { type: "string" },
72
- "pause-scale": { type: "string" },
73
- upspeak: { type: "boolean" },
74
- split: { type: "string" },
75
- "sample-rate": { type: "string" },
76
- seed: { type: "string" },
77
- lyrics: { type: "string" },
78
- melody: { type: "string" },
79
- beats: { type: "string" },
80
- tempo: { type: "string" },
81
- singer: { type: "string" },
82
- teacher: { type: "string" },
83
- transpose: { type: "string" },
84
- "vibrato-depth": { type: "string" },
85
- "vibrato-rate": { type: "string" },
86
- "lead-in": { type: "string" },
87
- "lead-out": { type: "string" },
88
- kind: { type: "string" },
89
- query: { type: "string" },
90
- initialize: { type: "boolean" },
91
- surface: { type: "string" },
92
- pronunciation: { type: "string" },
93
- accent: { type: "string" },
94
- "word-type": { type: "string" },
95
- priority: { type: "string" },
96
- };
97
- const NETWORK = ["endpoint", "timeout-ms", "retries", "engine"];
98
- const OUTPUT = ["output", "force", "play", "cache-dir", "concurrency"];
99
- const SPEECH = [
100
- "text",
101
- "input",
102
- "kana",
103
- "speaker",
104
- "speed",
105
- "pitch",
106
- "intonation",
107
- "volume",
108
- "pre-pause",
109
- "post-pause",
110
- "pause-length",
111
- "pause-scale",
112
- "upspeak",
113
- "split",
114
- "sample-rate",
115
- "seed",
116
- ];
117
- const SONG = [
118
- "input",
119
- "lyrics",
120
- "melody",
121
- "beats",
122
- "tempo",
123
- "singer",
124
- "teacher",
125
- "transpose",
126
- "volume",
127
- "vibrato-depth",
128
- "vibrato-rate",
129
- "lead-in",
130
- "lead-out",
131
- "sample-rate",
132
- "seed",
133
- ];
134
- const ALLOWED = {
135
- speak: [...NETWORK, ...OUTPUT, ...SPEECH],
136
- sing: [...NETWORK, ...OUTPUT, ...SONG],
137
- render: [...NETWORK, ...OUTPUT, "input"],
138
- batch: [...NETWORK, "input", "output-dir", "force", "concurrency", "cache-dir"],
139
- reading: [...NETWORK, "text", "input", "kana", "speaker"],
140
- voices: [...NETWORK, "kind", "query"],
141
- doctor: [...NETWORK, "speaker", "initialize"],
142
- dict: [...NETWORK, "surface", "pronunciation", "accent", "word-type", "priority"],
143
- play: [],
144
- schema: ["kind"],
145
- capabilities: [],
146
- };
18
+ import { wavHeader } from "./wav.js";
147
19
  async function emit(value, error = false) {
148
20
  await writeStream(error ? process.stderr : process.stdout, `${JSON.stringify(value)}\n`);
149
21
  }
150
- function numberFlag(values, name) {
151
- const raw = values[name];
152
- if (raw === undefined || typeof raw !== "string")
153
- return undefined;
154
- const value = Number(raw.trim());
155
- if (raw.trim().length === 0 || !Number.isFinite(value))
156
- invalid(`$flags.${name}`, `Expected a number, got ${JSON.stringify(raw)}.`);
157
- return value;
158
- }
159
- function styleFlag(values, name) {
160
- const raw = values[name];
161
- if (raw === undefined)
162
- return undefined;
163
- return /^\d+$/u.test(raw.trim()) ? Number(raw) : raw;
164
- }
165
- function assign(target, key, value) {
166
- if (value !== undefined)
167
- target[key] = value;
168
- }
169
- function env(name) {
170
- const value = process.env[name];
171
- return value === undefined || value.trim().length === 0 ? undefined : value.trim();
172
- }
173
22
  function metadata(result) {
174
23
  return {
175
24
  engine: result.engine,
25
+ engineVersion: result.engineVersion,
176
26
  kind: result.kind,
27
+ ...(result.voice === undefined ? {} : { voice: result.voice }),
177
28
  styles: result.styles,
178
29
  ...(result.kana === undefined ? {} : { kana: result.kana }),
179
30
  chunks: result.chunks,
180
31
  cached: result.cached,
181
32
  sha256: result.sha256,
33
+ requestHash: result.requestHash,
182
34
  bytes: result.audio.length,
183
35
  sampleRate: result.info.sampleRate,
184
36
  channels: result.info.channels,
185
37
  frames: result.info.frames,
186
38
  durationSeconds: Math.round(result.info.durationSeconds * 1000) / 1000,
39
+ ...(result.peak === undefined ? {} : { peak: result.peak, rms: result.rms, limitedSamples: result.limitedSamples }),
187
40
  elapsedMs: result.elapsedMs,
41
+ timings: result.timings,
42
+ warnings: result.warnings,
43
+ adjustments: result.adjustments,
188
44
  };
189
45
  }
190
- async function deliver(result, values, signal) {
46
+ async function reportDiagnostics(values, warnings, location) {
47
+ if (values.diagnostics !== "compact")
48
+ return;
49
+ for (const warning of warnings)
50
+ await writeStream(process.stderr, `${formatDiagnostic(warning, location)}\n`);
51
+ }
52
+ async function deliver(agent, result, values, signal, plan) {
53
+ if (values["plan-out"] !== undefined && values["plan-out"] !== "-") {
54
+ await writeTextFile(values["plan-out"], `${JSON.stringify(plan ?? (await agent.plan(result.requestHash)), null, 2)}\n`, values.force ?? false);
55
+ }
56
+ await reportDiagnostics(values, result.warnings, values.input ?? "<request>");
191
57
  if (values.output === "-") {
192
58
  if (values.play)
193
- invalid("$flags.play", "--play cannot be combined with --output -.");
59
+ invalid("$flags.play", "--play cannot be combined with --output -.", {
60
+ hint: "Write to a file and play it: -o out.wav --play",
61
+ });
194
62
  await writeStream(process.stdout, result.audio);
195
- return { ok: true, output: "stdout", ...metadata(result) };
63
+ return {
64
+ ok: true,
65
+ output: "stdout",
66
+ ...metadata(result),
67
+ ...(plan === undefined || values["plan-out"] !== "-" ? {} : { plan }),
68
+ };
196
69
  }
197
- const path = values.output ?? `kongyoroid-${result.kind}-${result.sha256.slice(0, 8)}.wav`;
198
- const written = await writeAudio(path, result.audio, values.force ?? false, signal);
199
- const played = values.play ? await playWav(written, signal) : undefined;
200
- return { ok: true, path: written, ...metadata(result), ...(played === undefined ? {} : { player: played.player }) };
70
+ const path = values.output ?? `kongyoroid-${result.kind}-${result.sha256.slice(0, 12)}.wav`;
71
+ const written = await writeAudioIdempotent(path, result.audio, values.force ?? false, signal);
72
+ const played = values.play ? await playWav(written.path, signal) : undefined;
73
+ return {
74
+ ok: true,
75
+ path: written.path,
76
+ written: written.written,
77
+ unchanged: written.unchanged,
78
+ ...metadata(result),
79
+ ...(played === undefined ? {} : { player: played.player }),
80
+ ...(plan === undefined || values["plan-out"] !== "-" ? {} : { plan }),
81
+ };
201
82
  }
202
83
  function speechRequestFromFlags(values, text) {
203
84
  const request = { kind: "speech", text };
204
85
  assign(request, "engine", values.engine);
86
+ assign(request, "voice", values.voice);
205
87
  assign(request, "kana", values.kana);
88
+ const entries = parseDictEntries(values);
89
+ if (entries.length > 0)
90
+ request["dictionary"] = entries;
91
+ if (values["strict-reading"] === false)
92
+ request["strictReading"] = false;
206
93
  assign(request, "speaker", styleFlag(values, "speaker"));
207
94
  assign(request, "speed", numberFlag(values, "speed"));
208
95
  assign(request, "pitch", numberFlag(values, "pitch"));
96
+ assign(request, "pitchSemitones", numberFlag(values, "pitch-semitones"));
209
97
  assign(request, "intonation", numberFlag(values, "intonation"));
210
98
  assign(request, "volume", numberFlag(values, "volume"));
99
+ assign(request, "gainDb", numberFlag(values, "gain-db"));
100
+ assign(request, "breathiness", numberFlag(values, "breathiness"));
211
101
  assign(request, "prePause", numberFlag(values, "pre-pause"));
212
102
  assign(request, "postPause", numberFlag(values, "post-pause"));
213
103
  assign(request, "pauseLength", numberFlag(values, "pause-length"));
214
104
  assign(request, "pauseScale", numberFlag(values, "pause-scale"));
215
- assign(request, "upspeak", values.upspeak);
105
+ if (values.upspeak !== undefined)
106
+ request["upspeak"] = values.upspeak;
216
107
  assign(request, "split", values.split);
217
108
  assign(request, "sampleRate", numberFlag(values, "sample-rate"));
218
109
  assign(request, "seed", numberFlag(values, "seed"));
@@ -220,22 +111,53 @@ function speechRequestFromFlags(values, text) {
220
111
  }
221
112
  function songRequestFromFlags(values, base) {
222
113
  const request = { ...base, kind: "song" };
223
- if (values.lyrics !== undefined || values.melody !== undefined) {
224
- const notes = { lyrics: values.lyrics ?? "", melody: values.melody ?? "" };
114
+ if (values.mml !== undefined) {
115
+ if (values.melody !== undefined || values.beats !== undefined)
116
+ invalid("$flags.mml", "--mml cannot be combined with --melody or --beats.");
117
+ request["notes"] = { mml: values.mml, ...(values.lyrics === undefined ? {} : { lyrics: values.lyrics }) };
118
+ }
119
+ else if (values.lyrics !== undefined || values.melody !== undefined) {
120
+ if (values.melody === undefined)
121
+ invalid("$flags.melody", "--lyrics needs --melody (or use --mml).", {
122
+ hint: 'Example: --lyrics "ドレミ" --melody "C4 D4 E4"',
123
+ });
124
+ const notes = { lyrics: values.lyrics ?? "", melody: values.melody };
225
125
  assign(notes, "beats", values.beats);
226
126
  request["notes"] = notes;
227
127
  }
228
128
  else if (values.beats !== undefined) {
229
- invalid("$flags.beats", "--beats needs --melody.");
129
+ invalid("$flags.beats", "--beats needs --melody.", {
130
+ hint: 'Example: --lyrics "ドレミ" --melody "C4 D4 E4" --beats "1 1 2"',
131
+ });
230
132
  }
231
133
  assign(request, "engine", values.engine);
134
+ assign(request, "voice", values.voice);
232
135
  assign(request, "tempo", numberFlag(values, "tempo"));
233
136
  assign(request, "singer", styleFlag(values, "singer"));
234
137
  assign(request, "teacher", styleFlag(values, "teacher"));
235
138
  assign(request, "transpose", numberFlag(values, "transpose"));
236
139
  assign(request, "volume", numberFlag(values, "volume"));
140
+ assign(request, "gainDb", numberFlag(values, "gain-db"));
141
+ assign(request, "breathiness", numberFlag(values, "breathiness"));
237
142
  assign(request, "vibratoDepth", numberFlag(values, "vibrato-depth"));
238
143
  assign(request, "vibratoRate", numberFlag(values, "vibrato-rate"));
144
+ const delay = numberFlag(values, "vibrato-delay-ms");
145
+ const fade = numberFlag(values, "vibrato-fade-ms");
146
+ if (delay !== undefined || fade !== undefined) {
147
+ const vibrato = {};
148
+ assign(vibrato, "delayMs", delay);
149
+ assign(vibrato, "fadeMs", fade);
150
+ assign(vibrato, "depthCents", numberFlag(values, "vibrato-depth"));
151
+ assign(vibrato, "rateHz", numberFlag(values, "vibrato-rate"));
152
+ delete request["vibratoDepth"];
153
+ delete request["vibratoRate"];
154
+ request["vibrato"] = vibrato;
155
+ }
156
+ assign(request, "portamentoMs", numberFlag(values, "portamento-ms"));
157
+ assign(request, "scoopCents", numberFlag(values, "scoop-cents"));
158
+ assign(request, "scoopMs", numberFlag(values, "scoop-ms"));
159
+ if (values["consonant-compression"] !== undefined)
160
+ request["consonantCompression"] = values["consonant-compression"];
239
161
  assign(request, "leadIn", numberFlag(values, "lead-in"));
240
162
  assign(request, "leadOut", numberFlag(values, "lead-out"));
241
163
  assign(request, "sampleRate", numberFlag(values, "sample-rate"));
@@ -249,28 +171,208 @@ async function textInput(values, signal) {
249
171
  return values.text;
250
172
  if (values.input !== undefined)
251
173
  return (await readText(values.input, signal)).replace(/\r?\n$/u, "");
252
- return invalid("$flags.text", "Provide --text or --input FILE (use - for standard input).");
174
+ return invalid("$flags.text", "Provide --text or --input FILE (use - for standard input).", {
175
+ hint: 'Example: kongyoroid speak --text "こんにちは" -o hello.wav',
176
+ });
253
177
  }
254
- function createAgent(values) {
178
+ async function requestFromInputs(values, signal, command) {
179
+ const hasSpeech = values.text !== undefined || values.kana !== undefined;
180
+ const hasSong = values.lyrics !== undefined || values.melody !== undefined || values.mml !== undefined;
181
+ if (values.input !== undefined) {
182
+ const raw = await readText(values.input, signal);
183
+ const trimmed = raw.trim();
184
+ if (hasSpeech && !hasSong && !trimmed.startsWith("{"))
185
+ return speechRequestFromFlags(values, raw.replace(/\r?\n$/u, ""));
186
+ const json = parseJson(raw);
187
+ if (isObject(json) && json["kind"] === "song")
188
+ return songRequestFromFlags(values, json);
189
+ if (isObject(json) && values.engine !== undefined)
190
+ return { ...json, engine: values.engine };
191
+ return json;
192
+ }
193
+ if (hasSong)
194
+ return songRequestFromFlags(values, {});
195
+ if (hasSpeech || values.text !== undefined)
196
+ return speechRequestFromFlags(values, await textInput(values, signal));
197
+ return invalid("$flags.input", `Provide --input FILE|- (a request JSON), --text, or --lyrics/--melody/--mml for ${command}.`, {
198
+ hint: `Examples: kongyoroid ${command} --input request.json | kongyoroid ${command} --text "こんにちは"`,
199
+ });
200
+ }
201
+ async function loadDictionaryFile(path, tolerateMissing) {
202
+ if (path === undefined)
203
+ return undefined;
204
+ try {
205
+ return await LocalDictionary.load(path);
206
+ }
207
+ catch (error) {
208
+ if (tolerateMissing &&
209
+ error instanceof KongyoroidError &&
210
+ error.code === "IO_ERROR" &&
211
+ /ENOENT/u.test(error.message))
212
+ return undefined;
213
+ throw error;
214
+ }
215
+ }
216
+ async function createAgent(values, tolerateMissingDictionary = false) {
255
217
  const engineFlag = values.engine ?? env("KONGYOROID_ENGINE");
256
218
  const concurrency = numberFlag(values, "concurrency");
257
219
  const timeoutMs = numberFlag(values, "timeout-ms");
258
220
  const retries = numberFlag(values, "retries");
259
221
  const cacheDir = values["cache-dir"] ?? env("KONGYOROID_CACHE_DIR");
222
+ const voice = values.voice ?? env("KONGYOROID_VOICE");
260
223
  const speaker = env("KONGYOROID_SPEAKER");
261
224
  const singer = env("KONGYOROID_SINGER");
262
225
  const teacher = env("KONGYOROID_TEACHER");
263
- return new Kongyoroid({
226
+ const dictionaryPath = values.dictionary ?? env("KONGYOROID_DICTIONARY");
227
+ const dictionary = await loadDictionaryFile(dictionaryPath, tolerateMissingDictionary);
228
+ const options = {
264
229
  endpoint: values.endpoint ?? env("KONGYOROID_ENDPOINT") ?? env("VOICEVOX_URL") ?? "http://127.0.0.1:50021",
265
- ...(engineFlag === undefined ? {} : { engine: parseEngine(engineFlag, "$flags.engine") }),
230
+ ...(engineFlag === undefined || engineFlag === "all" ? {} : { engine: parseEngine(engineFlag, "$flags.engine") }),
231
+ ...(voice === undefined ? {} : { voice }),
266
232
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
267
233
  ...(retries === undefined ? {} : { retries }),
268
234
  ...(concurrency === undefined ? {} : { concurrency }),
269
- ...(cacheDir === undefined ? {} : { cacheDir }),
235
+ ...(dictionary === undefined ? {} : { dictionary }),
270
236
  ...(speaker === undefined ? {} : { speaker }),
271
237
  ...(singer === undefined ? {} : { singer }),
272
238
  ...(teacher === undefined ? {} : { teacher }),
273
- });
239
+ cache: {
240
+ ...(cacheDir === undefined ? {} : { directory: cacheDir }),
241
+ ...(values.cache === false ? { enabled: false } : {}),
242
+ },
243
+ };
244
+ return new Kongyoroid(options);
245
+ }
246
+ function planDetail(values) {
247
+ return literal(values.detail ?? "summary", "$flags.detail", ["summary", "phonemes", "acoustics"]);
248
+ }
249
+ async function runValidate(agent, values, signal) {
250
+ const request = await requestFromInputs(values, signal, "validate");
251
+ const result = await agent.validate(request, { signal });
252
+ if (values.diagnostics === "compact") {
253
+ for (const warning of result.warnings)
254
+ await writeStream(process.stdout, `${formatDiagnostic(warning, values.input ?? "<request>")}\n`);
255
+ if (result.warnings.length === 0)
256
+ await emit({ ok: true, operation: "validate", renderable: result.renderable, requestHash: result.requestHash });
257
+ return;
258
+ }
259
+ const { request: resolved, ...rest } = result;
260
+ await emit({ ...rest, request: resolved });
261
+ }
262
+ async function runPlan(agent, values, signal) {
263
+ const request = await requestFromInputs(values, signal, "plan");
264
+ const result = await agent.plan(request, { signal, detail: planDetail(values) });
265
+ await emit(result);
266
+ }
267
+ async function runSpeakStream(agent, values, signal) {
268
+ if (values.play)
269
+ invalid("$flags.play", "--play cannot be combined with --stream.");
270
+ const format = literal(values.format ?? "wav", "$flags.format", ["wav", "pcm", "ndjson"]);
271
+ const output = values.output ?? "-";
272
+ if (output !== "-" && format !== "wav")
273
+ invalid("$flags.format", "Streaming to a file writes WAV; use --output - for pcm or ndjson.");
274
+ const input = speechRequestFromFlags(values, "placeholder");
275
+ delete input["text"];
276
+ const source = values.text !== undefined
277
+ ? (async function* () {
278
+ yield values.text ?? "";
279
+ })()
280
+ : readChunks(values.input ?? "-", signal);
281
+ const events = agent.speakStream(source, input, { signal });
282
+ const stdout = process.stdout;
283
+ let sampleRate = 0;
284
+ let file;
285
+ let fileFrames = 0;
286
+ let fileBytes = 0;
287
+ let filePath = "";
288
+ const sentences = [];
289
+ const openFile = async () => {
290
+ if (output === "-")
291
+ return;
292
+ const { open } = await import("node:fs/promises");
293
+ filePath = resolve(output);
294
+ if (!values.force) {
295
+ const existing = await existingFileSha256(filePath);
296
+ if (existing !== undefined)
297
+ invalid("$flags.output", `Refusing to overwrite ${filePath}.`, {
298
+ hint: "Pass --force or choose another path.",
299
+ repairOptions: [{ action: "use-force", description: "Overwrite with --force." }],
300
+ });
301
+ }
302
+ file = await open(filePath, "w", 0o644);
303
+ await file.write(wavHeader(0, sampleRate));
304
+ fileBytes = 44;
305
+ };
306
+ let headerWritten = false;
307
+ try {
308
+ for await (const event of events) {
309
+ if (event.type === "sentence") {
310
+ const line = {
311
+ type: "sentence",
312
+ index: event.index,
313
+ text: event.text,
314
+ kana: event.kana,
315
+ durationSeconds: Math.round(event.durationSeconds * 1000) / 1000,
316
+ startSeconds: sampleRate === 0 ? 0 : Math.round((event.startFrame / sampleRate) * 1000) / 1000,
317
+ warnings: event.warnings,
318
+ };
319
+ sentences.push(line);
320
+ if (format === "ndjson" && output === "-")
321
+ await writeWithBackpressure(stdout, `${JSON.stringify(line)}\n`, signal);
322
+ else if (values.progress)
323
+ await emit(line, true);
324
+ continue;
325
+ }
326
+ if (event.type === "audio") {
327
+ const block = event.block;
328
+ if (sampleRate === 0) {
329
+ sampleRate = block.sampleRate;
330
+ await openFile();
331
+ }
332
+ const bytes = pcm16Bytes(block.samples);
333
+ if (file !== undefined) {
334
+ await file.write(bytes);
335
+ fileFrames += block.samples.length;
336
+ fileBytes += bytes.length;
337
+ continue;
338
+ }
339
+ if (format === "ndjson") {
340
+ await writeWithBackpressure(stdout, `${JSON.stringify({ type: "audio", sequence: event.sequence, sentence: event.index, sampleRate, encoding: "s16le", channels: 1, frames: block.samples.length, startFrame: block.start, base64: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64") })}\n`, signal);
341
+ continue;
342
+ }
343
+ if (format === "wav" && !headerWritten) {
344
+ await writeWithBackpressure(stdout, streamingWavHeader(sampleRate), signal);
345
+ headerWritten = true;
346
+ }
347
+ await writeWithBackpressure(stdout, bytes, signal);
348
+ continue;
349
+ }
350
+ const summary = {
351
+ ok: true,
352
+ operation: "speak",
353
+ stream: true,
354
+ format,
355
+ output: output === "-" ? "stdout" : filePath,
356
+ sentences: event.sentences,
357
+ frames: event.frames,
358
+ durationSeconds: Math.round(event.durationSeconds * 1000) / 1000,
359
+ sampleRate: sampleRate === 0 ? null : sampleRate,
360
+ ...(values.progress || format === "ndjson" ? {} : { segments: sentences }),
361
+ };
362
+ if (format === "ndjson" && output === "-")
363
+ await writeWithBackpressure(stdout, `${JSON.stringify({ type: "end", ...summary })}\n`, signal);
364
+ else
365
+ await emit(summary, output === "-");
366
+ }
367
+ }
368
+ finally {
369
+ if (file !== undefined) {
370
+ const header = wavHeader(fileFrames, sampleRate);
371
+ await file.write(header, 0, 44, 0);
372
+ await file.close();
373
+ void fileBytes;
374
+ }
375
+ }
274
376
  }
275
377
  async function runBatch(agent, values, signal, onFailure) {
276
378
  const input = string(values.input, "$flags.input", 1, 4096);
@@ -279,6 +381,7 @@ async function runBatch(agent, values, signal, onFailure) {
279
381
  const active = new Set();
280
382
  const ids = new Set();
281
383
  let line = 0;
384
+ let stop = false;
282
385
  const run = async (text, index) => {
283
386
  let id = null;
284
387
  try {
@@ -291,12 +394,22 @@ async function runBatch(agent, values, signal, onFailure) {
291
394
  invalid(`$line[${index}].id`, "Duplicate job id (case-insensitive).");
292
395
  ids.add(id.toLowerCase());
293
396
  const result = await agent.render(job["request"], { signal });
294
- const path = await writeAudio(resolve(directory, `${id}.wav`), result.audio, values.force ?? false, signal);
295
- await emit({ ok: true, id, line: index, path, ...metadata(result) });
397
+ const written = await writeAudioIdempotent(resolve(directory, `${id}.wav`), result.audio, values.force ?? false, signal);
398
+ await emit({
399
+ ok: true,
400
+ id,
401
+ line: index,
402
+ path: written.path,
403
+ written: written.written,
404
+ unchanged: written.unchanged,
405
+ ...metadata(result),
406
+ });
296
407
  }
297
408
  catch (error) {
298
409
  const failure = asKongyoroidError(error);
299
410
  onFailure();
411
+ if (values["stop-on-error"])
412
+ stop = true;
300
413
  process.exitCode = Math.max(Number(process.exitCode ?? 0), exitCodeOf(failure));
301
414
  await emit({ ok: false, id, line: index, error: failure.toJSON() });
302
415
  if (failure.code === "ABORTED")
@@ -305,6 +418,8 @@ async function runBatch(agent, values, signal, onFailure) {
305
418
  };
306
419
  try {
307
420
  for await (const text of readLines(input, signal)) {
421
+ if (stop)
422
+ break;
308
423
  line += 1;
309
424
  if (line > LIMITS.batchLines)
310
425
  invalid("$flags.input", `A batch is limited to ${LIMITS.batchLines} lines.`);
@@ -321,69 +436,226 @@ async function runBatch(agent, values, signal, onFailure) {
321
436
  }
322
437
  }
323
438
  async function runDict(agent, values, positionals, signal) {
324
- const action = literal(positionals[1], "$.dict", ["list", "add", "update", "delete"]);
439
+ const action = literal(positionals[1], "$.dict", ["list", "add", "update", "delete", "check"]);
440
+ const scope = literal(values.scope ?? "local", "$flags.scope", ["local", "voicevox"]);
441
+ const reading = values.reading ?? values.pronunciation;
442
+ if (scope === "voicevox") {
443
+ if (action === "check")
444
+ invalid("$flags.scope", "dict check is only available for the local dictionary.");
445
+ if (action === "list") {
446
+ await emit({ ok: true, operation: "dict.list", scope, words: await agent.voicevoxDictionary.list({ signal }) });
447
+ return;
448
+ }
449
+ if (action === "delete") {
450
+ const uuid = string(values.id ?? positionals[2], "$flags.id", 1, 200);
451
+ if (values["dry-run"]) {
452
+ await emit({ ok: true, operation: "dict.delete", scope, dryRun: true, uuid });
453
+ return;
454
+ }
455
+ await agent.voicevoxDictionary.remove(uuid, { signal });
456
+ await emit({ ok: true, operation: "dict.delete", scope, deleted: uuid });
457
+ return;
458
+ }
459
+ const priority = numberFlag(values, "priority");
460
+ const draft = {
461
+ surface: string(values.surface, "$flags.surface", 1, 200),
462
+ pronunciation: string(reading, "$flags.reading", 1, 200),
463
+ accentType: numberFlag(values, "accent") ?? invalid("$flags.accent", "Provide --accent N (0 = flat)."),
464
+ ...(values["word-type"] === undefined ? {} : { wordType: values["word-type"] }),
465
+ ...(priority === undefined ? {} : { priority }),
466
+ };
467
+ if (action === "add") {
468
+ if (values["dry-run"]) {
469
+ await emit({ ok: true, operation: "dict.add", scope, dryRun: true, word: draft });
470
+ return;
471
+ }
472
+ const uuid = await agent.voicevoxDictionary.add(draft, { signal });
473
+ await emit({ ok: true, operation: "dict.add", scope, uuid, word: draft });
474
+ return;
475
+ }
476
+ const uuid = string(values.id ?? positionals[2], "$flags.id", 1, 200);
477
+ await agent.voicevoxDictionary.update(uuid, draft, { signal });
478
+ await emit({ ok: true, operation: "dict.update", scope, updated: uuid, word: draft });
479
+ return;
480
+ }
481
+ const path = values.dictionary ?? env("KONGYOROID_DICTIONARY") ?? "kongyoroid-dictionary.json";
482
+ const dictionary = (await loadDictionaryFile(path, true)) ?? new LocalDictionary();
325
483
  if (action === "list") {
326
- await emit({ ok: true, words: await agent.dictionary.list({ signal }) });
484
+ await emit({
485
+ ok: true,
486
+ operation: "dict.list",
487
+ scope,
488
+ path: resolve(path),
489
+ entries: dictionary.list(),
490
+ digest: dictionary.digest(),
491
+ });
492
+ return;
493
+ }
494
+ if (action === "check") {
495
+ const text = await textInput(values, signal);
496
+ const merged = agent.dictionary.merge(dictionary);
497
+ const analysed = await agent.reading(text, { signal, dictionary: merged, engine: "formant", strict: false });
498
+ await emit({
499
+ ok: true,
500
+ operation: "dict.check",
501
+ scope,
502
+ path: resolve(path),
503
+ text,
504
+ kana: analysed.kana,
505
+ hits: analysed.dictionaryHits,
506
+ phrases: analysed.phrases.map((p) => ({ text: p.text, accent: p.accent, accentSource: p.accentSource })),
507
+ warnings: analysed.warnings,
508
+ });
327
509
  return;
328
510
  }
329
511
  if (action === "delete") {
330
- const uuid = string(positionals[2], "$.dict.uuid", 1, 200);
331
- await agent.dictionary.remove(uuid, { signal });
332
- await emit({ ok: true, deleted: uuid });
512
+ const id = string(values.id ?? positionals[2], "$flags.id", 1, 100);
513
+ const entry = dictionary.get(id);
514
+ if (entry === undefined) {
515
+ await emit({
516
+ ok: true,
517
+ operation: "dict.delete",
518
+ scope,
519
+ path: resolve(path),
520
+ id,
521
+ deleted: false,
522
+ message: "No such entry: nothing to do.",
523
+ });
524
+ return;
525
+ }
526
+ if (values["dry-run"]) {
527
+ await emit({ ok: true, operation: "dict.delete", scope, path: resolve(path), dryRun: true, entry });
528
+ return;
529
+ }
530
+ dictionary.remove(id);
531
+ await writeTextFile(path, `${JSON.stringify(dictionary.toJSON(), null, 2)}\n`, true);
532
+ await emit({
533
+ ok: true,
534
+ operation: "dict.delete",
535
+ scope,
536
+ path: resolve(path),
537
+ id,
538
+ deleted: true,
539
+ digest: dictionary.digest(),
540
+ });
333
541
  return;
334
542
  }
543
+ const surface = string(values.surface, "$flags.surface", 1, 200);
544
+ const accent = numberFlag(values, "accent");
335
545
  const priority = numberFlag(values, "priority");
336
- const draft = {
337
- surface: string(values.surface, "$flags.surface", 1, 200),
338
- pronunciation: string(values.pronunciation, "$flags.pronunciation", 1, 200),
339
- accentType: numberFlag(values, "accent") ?? invalid("$flags.accent", "Provide --accent N (0 = flat)."),
340
- ...(values["word-type"] === undefined ? {} : { wordType: values["word-type"] }),
546
+ const input = {
547
+ ...(values.id === undefined ? {} : { id: values.id }),
548
+ surface,
549
+ reading: string(reading, "$flags.reading", 1, 200),
550
+ ...(accent === undefined ? {} : { accent }),
551
+ ...(values.match === undefined ? {} : { match: literal(values.match, "$flags.match", ["word", "anywhere"]) }),
341
552
  ...(priority === undefined ? {} : { priority }),
342
553
  };
343
- if (action === "add") {
344
- const uuid = await agent.dictionary.add(draft, { signal });
345
- await emit({ ok: true, uuid, word: draft });
554
+ if (action === "update" && values.id === undefined)
555
+ invalid("$flags.id", "dict update needs --id ENTRY_ID.", { hint: "List ids with: kongyoroid dict list" });
556
+ if (action === "add" && values.id === undefined) {
557
+ const existing = dictionary
558
+ .find(surface)
559
+ .find((entry) => entry.reading === input.reading && (accent === undefined || entry.accent === accent));
560
+ if (existing !== undefined) {
561
+ await emit({
562
+ ok: true,
563
+ operation: "dict.add",
564
+ scope,
565
+ path: resolve(path),
566
+ entry: existing,
567
+ created: false,
568
+ unchanged: true,
569
+ });
570
+ return;
571
+ }
572
+ }
573
+ if (values["dry-run"]) {
574
+ await emit({ ok: true, operation: `dict.${action}`, scope, path: resolve(path), dryRun: true, entry: input });
346
575
  return;
347
576
  }
348
- const uuid = string(positionals[2], "$.dict.uuid", 1, 200);
349
- await agent.dictionary.update(uuid, draft, { signal });
350
- await emit({ ok: true, updated: uuid, word: draft });
351
- }
352
- async function main(argv) {
353
- const { values, positionals } = parseArgs({
354
- args: [...argv],
355
- options: OPTIONS,
356
- allowPositionals: true,
357
- allowNegative: true,
358
- strict: true,
577
+ const result = dictionary.upsert(input);
578
+ await writeTextFile(path, `${JSON.stringify(dictionary.toJSON(), null, 2)}\n`, true);
579
+ await emit({
580
+ ok: true,
581
+ operation: `dict.${action}`,
582
+ scope,
583
+ path: resolve(path),
584
+ entry: result.entry,
585
+ created: result.created,
586
+ digest: dictionary.digest(),
359
587
  });
360
- if (values.help) {
361
- await writeStream(process.stdout, HELP);
588
+ }
589
+ async function runCache(values, positionals) {
590
+ const action = literal(positionals[1], "$.cache", ["stats", "prune", "clear"]);
591
+ const directory = values["cache-dir"] ?? env("KONGYOROID_CACHE_DIR");
592
+ if (directory === undefined)
593
+ invalid("$flags.cache-dir", "Provide --cache-dir DIR (or set KONGYOROID_CACHE_DIR).", {
594
+ hint: "Example: kongyoroid cache stats --cache-dir ./cache",
595
+ });
596
+ const cache = new DiskCache(directory);
597
+ if (action === "stats") {
598
+ await emit({ ok: true, operation: "cache.stats", ...(await cache.stats()) });
599
+ return;
600
+ }
601
+ const before = await cache.stats();
602
+ if (values["dry-run"]) {
603
+ await emit({
604
+ ok: true,
605
+ operation: `cache.${action}`,
606
+ dryRun: true,
607
+ directory: cache.directory,
608
+ wouldRemove: action === "clear" ? before.entries : null,
609
+ ...before,
610
+ });
362
611
  return;
363
612
  }
613
+ const removed = action === "clear"
614
+ ? await cache.clear()
615
+ : await cache.prune(numberFlag(values, "max-bytes"), numberFlag(values, "max-entries"));
616
+ await emit({ ok: true, operation: `cache.${action}`, directory: cache.directory, removed, ...(await cache.stats()) });
617
+ }
618
+ async function main(argv) {
619
+ const { values, positionals } = parseCommandLine(argv);
620
+ const command = positionals[0];
364
621
  if (values.version) {
365
622
  await emit({ ok: true, name: PACKAGE_NAME, version: VERSION });
366
623
  return;
367
624
  }
368
- const command = positionals[0];
369
- if (command === undefined || !Object.hasOwn(ALLOWED, command)) {
370
- invalid("$.command", `Unknown command ${JSON.stringify(command ?? "")}.`, "Run kongyoroid --help for the command list.");
625
+ if (command === "help") {
626
+ await writeStream(process.stdout, positionals[1] === undefined ? rootHelp() : commandHelp(positionals[1]));
627
+ return;
371
628
  }
372
- const allowed = ALLOWED[command] ?? [];
373
- for (const key of Object.keys(values)) {
374
- if (!allowed.includes(key))
375
- invalid(`$flags.${key}`, `--${key} is not accepted by ${command}.`, `Accepted: ${allowed.join(", ")}.`);
629
+ if (values.help || command === undefined) {
630
+ if (command === undefined && !values.help) {
631
+ await writeStream(process.stderr, rootHelp());
632
+ process.exitCode = EXIT_CODES.input;
633
+ return;
634
+ }
635
+ await writeStream(process.stdout, command === undefined ? rootHelp() : commandHelp(command));
636
+ return;
637
+ }
638
+ if (!Object.hasOwn(ALLOWED, command)) {
639
+ invalid("$.command", `Unknown command ${JSON.stringify(command)}.`, {
640
+ hint: `Commands: ${Object.keys(ALLOWED)
641
+ .filter((name) => name !== "help")
642
+ .join(", ")}. Run kongyoroid --help.`,
643
+ });
376
644
  }
645
+ checkAllowed(command, values);
377
646
  if (command === "schema") {
378
- await emit(literal(values.kind ?? "request", "$flags.kind", ["request", "batch"]) === "batch"
379
- ? BATCH_JOB_SCHEMA
380
- : REQUEST_SCHEMA);
647
+ const kind = literal(values.kind ?? "request", "$flags.kind", ["request", "batch", "dictionary"]);
648
+ await emit(kind === "batch" ? BATCH_JOB_SCHEMA : kind === "dictionary" ? DICTIONARY_SCHEMA : REQUEST_SCHEMA);
381
649
  return;
382
650
  }
383
651
  if (command === "capabilities") {
384
652
  await emit(CAPABILITIES);
385
653
  return;
386
654
  }
655
+ if (command === "cache") {
656
+ await runCache(values, positionals);
657
+ return;
658
+ }
387
659
  const controller = new AbortController();
388
660
  const cancel = () => {
389
661
  controller.abort();
@@ -394,32 +666,88 @@ async function main(argv) {
394
666
  try {
395
667
  if (command === "play") {
396
668
  const path = string(positionals[1], "$.play.file", 1, 4096);
397
- await emit({ ok: true, path: resolve(path), ...(await playWav(resolve(path), signal)) });
669
+ await emit({ ok: true, operation: "play", path: resolve(path), ...(await playWav(resolve(path), signal)) });
398
670
  return;
399
671
  }
400
- const agent = createAgent(values);
672
+ if (command === "inspect") {
673
+ const path = string(positionals[1] ?? values.input, "$.inspect.file", 1, 4096);
674
+ const agent = new Kongyoroid({ cache: { enabled: false } });
675
+ const bytes = await readBytes(resolve(path));
676
+ const windowMs = numberFlag(values, "window-ms");
677
+ const result = agent.inspect(bytes, {
678
+ pitchTrack: values["pitch-track"] ?? false,
679
+ ...(windowMs === undefined ? {} : { windowMs }),
680
+ });
681
+ await emit({ ok: true, operation: "inspect", path: resolve(path), ...result });
682
+ return;
683
+ }
684
+ if (positionals.length > 1 && command !== "dict") {
685
+ invalid("$.command", `Unexpected argument ${JSON.stringify(positionals[1])} for ${command}.`, {
686
+ hint: `Run: kongyoroid ${command} --help`,
687
+ });
688
+ }
689
+ const agent = await createAgent(values, command === "dict");
401
690
  switch (command) {
402
691
  case "speak": {
403
- const result = await agent.render(speechRequestFromFlags(values, await textInput(values, signal)), { signal });
404
- await emit(await deliver(result, values, signal), values.output === "-");
692
+ if (values.stream) {
693
+ await runSpeakStream(agent, values, signal);
694
+ return;
695
+ }
696
+ const request = speechRequestFromFlags(values, await textInput(values, signal));
697
+ if (values["dry-run"]) {
698
+ await runValidateRequest(agent, request, values, signal);
699
+ return;
700
+ }
701
+ const result = await agent.render(request, { signal });
702
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
703
+ ? undefined
704
+ : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
705
+ await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
405
706
  return;
406
707
  }
407
708
  case "sing": {
408
709
  const base = values.input === undefined ? {} : object(parseJson(await readText(values.input, signal)), "$");
409
710
  if (base["kind"] !== undefined && base["kind"] !== "song")
410
711
  invalid("$.kind", "sing accepts only song requests.");
411
- const result = await agent.render(songRequestFromFlags(values, base), { signal });
412
- await emit(await deliver(result, values, signal), values.output === "-");
712
+ const request = songRequestFromFlags(values, base);
713
+ if (values["dry-run"]) {
714
+ await runValidateRequest(agent, request, values, signal);
715
+ return;
716
+ }
717
+ const result = await agent.render(request, { signal });
718
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
719
+ ? undefined
720
+ : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
721
+ await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
413
722
  return;
414
723
  }
415
724
  case "render": {
416
725
  const input = string(values.input, "$flags.input", 1, 4096);
417
726
  const request = parseJson(await readText(input, signal));
418
- const withEngine = isObject(request) && values.engine !== undefined ? { ...request, engine: values.engine } : request;
419
- const result = await agent.render(withEngine, { signal });
420
- await emit(await deliver(result, values, signal), values.output === "-");
727
+ const withFlags = isObject(request)
728
+ ? {
729
+ ...request,
730
+ ...(values.engine === undefined ? {} : { engine: values.engine }),
731
+ ...(values.voice === undefined ? {} : { voice: values.voice }),
732
+ }
733
+ : request;
734
+ if (values["dry-run"]) {
735
+ await runValidateRequest(agent, withFlags, values, signal);
736
+ return;
737
+ }
738
+ const result = await agent.render(withFlags, { signal });
739
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
740
+ ? undefined
741
+ : (await agent.plan(withFlags, { signal, detail: "phonemes" })).plan;
742
+ await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
421
743
  return;
422
744
  }
745
+ case "validate":
746
+ await runValidate(agent, values, signal);
747
+ return;
748
+ case "plan":
749
+ await runPlan(agent, values, signal);
750
+ return;
423
751
  case "batch": {
424
752
  let failures = 0;
425
753
  await runBatch(agent, values, signal, () => {
@@ -430,28 +758,60 @@ async function main(argv) {
430
758
  return;
431
759
  }
432
760
  case "reading": {
433
- const text = await textInput(values, signal);
761
+ const text = values.kana !== undefined && values.text === undefined && values.input === undefined
762
+ ? values.kana
763
+ : await textInput(values, signal);
434
764
  const speakerRef = styleFlag(values, "speaker");
765
+ const entries = parseDictEntries(values);
435
766
  const reading = await agent.reading(text, {
436
767
  signal,
437
768
  ...(values.kana === undefined ? {} : { kana: values.kana }),
438
769
  ...(speakerRef === undefined ? {} : { speaker: speakerRef }),
770
+ ...(values.engine === undefined ? {} : { engine: parseEngine(values.engine, "$flags.engine") }),
771
+ ...(entries.length === 0
772
+ ? {}
773
+ : {
774
+ dictionary: new LocalDictionary(entries.map((entry, index) => ({
775
+ id: `flag-${index + 1}`,
776
+ surface: entry.surface,
777
+ reading: entry.reading,
778
+ accent: entry.accent ?? null,
779
+ match: "word",
780
+ priority: 10,
781
+ caseSensitive: false,
782
+ }))),
783
+ }),
784
+ ...(values["strict-reading"] === false ? { strict: false } : {}),
439
785
  });
440
- await emit({ ok: true, text, ...reading });
786
+ await emit({ ok: true, operation: "reading", text, ...reading });
441
787
  return;
442
788
  }
443
789
  case "voices": {
444
790
  const kind = literal(values.kind ?? "all", "$flags.kind", ["speech", "song", "all"]);
791
+ const engine = literal(values.engine ?? "all", "$flags.engine", ["formant", "voicevox", "auto", "all"]);
445
792
  const query = values.query?.normalize("NFKC").toLowerCase();
446
- const styles = (await agent.voices(kind, { signal })).filter((style) => query === undefined || `${style.character}/${style.name}`.normalize("NFKC").toLowerCase().includes(query));
447
- await emit({ ok: true, kind, count: styles.length, styles });
793
+ const result = await agent.voices({ signal, engine, kind });
794
+ const voices = result.voices.filter((voice) => query === undefined ||
795
+ `${voice.id} ${voice.name} ${voice.character ?? ""}`.normalize("NFKC").toLowerCase().includes(query));
796
+ await emit({
797
+ ok: true,
798
+ operation: "voices",
799
+ kind,
800
+ engine,
801
+ count: voices.length,
802
+ voices,
803
+ voicevox: result.voicevox,
804
+ });
448
805
  return;
449
806
  }
450
807
  case "doctor": {
451
- const diagnosis = await agent.doctor({ signal });
808
+ const engine = values.engine === undefined
809
+ ? undefined
810
+ : literal(values.engine, "$flags.engine", ["formant", "voicevox", "auto", "all"]);
811
+ const diagnosis = await agent.doctor({ signal, ...(engine === undefined ? {} : { engine }) });
452
812
  const speakerRef = styleFlag(values, "speaker");
453
813
  let style;
454
- if (diagnosis.ok && speakerRef !== undefined) {
814
+ if (diagnosis.voicevox?.ok === true && speakerRef !== undefined) {
455
815
  const all = await agent.styles.all({ signal });
456
816
  const selected = all.find((s) => s.id === speakerRef || `${s.character}/${s.name}` === speakerRef || s.character === speakerRef);
457
817
  if (selected === undefined)
@@ -460,7 +820,7 @@ async function main(argv) {
460
820
  await agent.client.initializeSpeaker(selected.id, true, { signal });
461
821
  style = { ...selected, initialized: await agent.client.isInitializedSpeaker(selected.id, { signal }) };
462
822
  }
463
- await emit({ ...diagnosis, formant: { available: true }, ...(style === undefined ? {} : { style }) });
823
+ await emit({ ...diagnosis, operation: "doctor", ...(style === undefined ? {} : { style }) });
464
824
  if (!diagnosis.ok)
465
825
  process.exitCode = EXIT_CODES.engine;
466
826
  return;
@@ -477,6 +837,12 @@ async function main(argv) {
477
837
  process.removeListener("SIGTERM", cancel);
478
838
  }
479
839
  }
840
+ async function runValidateRequest(agent, request, values, signal) {
841
+ const result = await agent.validate(request, { signal });
842
+ await reportDiagnostics(values, result.warnings, values.input ?? "<request>");
843
+ const { request: resolved, ...rest } = result;
844
+ await emit({ ...rest, dryRun: true, request: resolved });
845
+ }
480
846
  function isArgumentError(error) {
481
847
  return (error instanceof Error &&
482
848
  "code" in error &&
@@ -493,7 +859,7 @@ catch (error) {
493
859
  message: error.message,
494
860
  path: "$flags",
495
861
  retryable: false,
496
- hint: "Run kongyoroid --help.",
862
+ hint: "Run kongyoroid --help, or kongyoroid <command> --help for the accepted flags.",
497
863
  }
498
864
  : asKongyoroidError(error).toJSON();
499
865
  process.exitCode = exitCodeOf(new KongyoroidError(data));