@kongyo2/kongyoroid 1.0.0 → 2.1.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 (206) hide show
  1. package/README.md +239 -0
  2. package/dist/cache.d.ts +32 -7
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/cache.js +139 -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 +346 -0
  13. package/dist/cli/help.js.map +1 -0
  14. package/dist/cli.js +729 -243
  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 +12 -0
  25. package/dist/io.d.ts.map +1 -1
  26. package/dist/io.js +161 -6
  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 +787 -87
  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/player.d.ts.map +1 -1
  37. package/dist/player.js +18 -22
  38. package/dist/player.js.map +1 -1
  39. package/dist/request.d.ts +1 -0
  40. package/dist/request.d.ts.map +1 -1
  41. package/dist/request.js +102 -15
  42. package/dist/request.js.map +1 -1
  43. package/dist/schema.d.ts +3 -0
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/schema.js +271 -68
  46. package/dist/schema.js.map +1 -1
  47. package/dist/song/mml.d.ts +15 -0
  48. package/dist/song/mml.d.ts.map +1 -0
  49. package/dist/song/mml.js +184 -0
  50. package/dist/song/mml.js.map +1 -0
  51. package/dist/song/pitch.d.ts.map +1 -0
  52. package/dist/{pitch.js → song/pitch.js} +1 -1
  53. package/dist/song/pitch.js.map +1 -0
  54. package/dist/song/score.d.ts +92 -0
  55. package/dist/song/score.d.ts.map +1 -0
  56. package/dist/song/score.js +454 -0
  57. package/dist/song/score.js.map +1 -0
  58. package/dist/synth/acoustics.d.ts +27 -0
  59. package/dist/synth/acoustics.d.ts.map +1 -0
  60. package/dist/synth/acoustics.js +608 -0
  61. package/dist/synth/acoustics.js.map +1 -0
  62. package/dist/synth/analysis.d.ts +22 -0
  63. package/dist/synth/analysis.d.ts.map +1 -0
  64. package/dist/synth/analysis.js +170 -0
  65. package/dist/synth/analysis.js.map +1 -0
  66. package/dist/synth/engine.d.ts +115 -0
  67. package/dist/synth/engine.d.ts.map +1 -0
  68. package/dist/synth/engine.js +223 -0
  69. package/dist/synth/engine.js.map +1 -0
  70. package/dist/synth/filters.d.ts +60 -0
  71. package/dist/synth/filters.d.ts.map +1 -0
  72. package/dist/synth/filters.js +166 -0
  73. package/dist/synth/filters.js.map +1 -0
  74. package/dist/synth/glottal.d.ts +20 -0
  75. package/dist/synth/glottal.d.ts.map +1 -0
  76. package/dist/synth/glottal.js +76 -0
  77. package/dist/synth/glottal.js.map +1 -0
  78. package/dist/synth/phonemes.d.ts +42 -0
  79. package/dist/synth/phonemes.d.ts.map +1 -0
  80. package/dist/synth/phonemes.js +311 -0
  81. package/dist/synth/phonemes.js.map +1 -0
  82. package/dist/synth/plan.d.ts +127 -0
  83. package/dist/synth/plan.d.ts.map +1 -0
  84. package/dist/synth/plan.js +81 -0
  85. package/dist/synth/plan.js.map +1 -0
  86. package/dist/synth/prosody.d.ts +38 -0
  87. package/dist/synth/prosody.d.ts.map +1 -0
  88. package/dist/synth/prosody.js +120 -0
  89. package/dist/synth/prosody.js.map +1 -0
  90. package/dist/synth/random.d.ts +15 -0
  91. package/dist/synth/random.d.ts.map +1 -0
  92. package/dist/synth/random.js +59 -0
  93. package/dist/synth/random.js.map +1 -0
  94. package/dist/synth/renderer.d.ts +77 -0
  95. package/dist/synth/renderer.d.ts.map +1 -0
  96. package/dist/synth/renderer.js +343 -0
  97. package/dist/synth/renderer.js.map +1 -0
  98. package/dist/synth/song.d.ts +30 -0
  99. package/dist/synth/song.d.ts.map +1 -0
  100. package/dist/synth/song.js +453 -0
  101. package/dist/synth/song.js.map +1 -0
  102. package/dist/synth/speech.d.ts +49 -0
  103. package/dist/synth/speech.d.ts.map +1 -0
  104. package/dist/synth/speech.js +358 -0
  105. package/dist/synth/speech.js.map +1 -0
  106. package/dist/synth/stream.d.ts +26 -0
  107. package/dist/synth/stream.d.ts.map +1 -0
  108. package/dist/synth/stream.js +67 -0
  109. package/dist/synth/stream.js.map +1 -0
  110. package/dist/synth/textstream.d.ts +35 -0
  111. package/dist/synth/textstream.d.ts.map +1 -0
  112. package/dist/synth/textstream.js +209 -0
  113. package/dist/synth/textstream.js.map +1 -0
  114. package/dist/synth/voice.d.ts +64 -0
  115. package/dist/synth/voice.d.ts.map +1 -0
  116. package/dist/synth/voice.js +208 -0
  117. package/dist/synth/voice.js.map +1 -0
  118. package/dist/text/dictionary.d.ts +50 -0
  119. package/dist/text/dictionary.d.ts.map +1 -0
  120. package/dist/text/dictionary.js +0 -0
  121. package/dist/text/dictionary.js.map +1 -0
  122. package/dist/text/frontend.d.ts +35 -0
  123. package/dist/text/frontend.d.ts.map +1 -0
  124. package/dist/text/frontend.js +559 -0
  125. package/dist/text/frontend.js.map +1 -0
  126. package/dist/text/lexicon.d.ts +10 -0
  127. package/dist/text/lexicon.d.ts.map +1 -0
  128. package/dist/text/lexicon.js +763 -0
  129. package/dist/text/lexicon.js.map +1 -0
  130. package/dist/{mora.d.ts → text/mora.d.ts} +15 -3
  131. package/dist/text/mora.d.ts.map +1 -0
  132. package/dist/{mora.js → text/mora.js} +87 -9
  133. package/dist/text/mora.js.map +1 -0
  134. package/dist/text/normalize.d.ts +34 -0
  135. package/dist/text/normalize.d.ts.map +1 -0
  136. package/dist/text/normalize.js +504 -0
  137. package/dist/text/normalize.js.map +1 -0
  138. package/dist/text/notation.d.ts +32 -0
  139. package/dist/text/notation.d.ts.map +1 -0
  140. package/dist/text/notation.js +182 -0
  141. package/dist/text/notation.js.map +1 -0
  142. package/dist/text/reading.d.ts +52 -0
  143. package/dist/text/reading.d.ts.map +1 -0
  144. package/dist/text/reading.js +32 -0
  145. package/dist/text/reading.js.map +1 -0
  146. package/dist/text/sentences.d.ts +11 -0
  147. package/dist/text/sentences.d.ts.map +1 -0
  148. package/dist/text/sentences.js +91 -0
  149. package/dist/text/sentences.js.map +1 -0
  150. package/dist/{text.d.ts → text/split.d.ts} +2 -2
  151. package/dist/text/split.d.ts.map +1 -0
  152. package/dist/{text.js → text/split.js} +2 -2
  153. package/dist/text/split.js.map +1 -0
  154. package/dist/types.d.ts +58 -3
  155. package/dist/types.d.ts.map +1 -1
  156. package/dist/version.d.ts +4 -0
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +5 -1
  159. package/dist/version.js.map +1 -1
  160. package/dist/voicevox/client.d.ts +1 -1
  161. package/dist/voicevox/client.d.ts.map +1 -1
  162. package/dist/voicevox/dictionary.js +1 -1
  163. package/dist/voicevox/dictionary.js.map +1 -1
  164. package/dist/voicevox/song.js +1 -1
  165. package/dist/voicevox/song.js.map +1 -1
  166. package/dist/voicevox/speech.d.ts +1 -0
  167. package/dist/voicevox/speech.d.ts.map +1 -1
  168. package/dist/voicevox/speech.js +8 -2
  169. package/dist/voicevox/speech.js.map +1 -1
  170. package/dist/wav.d.ts +1 -0
  171. package/dist/wav.d.ts.map +1 -1
  172. package/dist/wav.js +8 -6
  173. package/dist/wav.js.map +1 -1
  174. package/docs/cli.md +261 -0
  175. package/examples/batch.jsonl +4 -3
  176. package/examples/builtin/dictionary.json +7 -0
  177. package/examples/builtin/song-compact.json +13 -0
  178. package/examples/builtin/song-mml.json +9 -0
  179. package/examples/builtin/song-notes.json +25 -0
  180. package/examples/builtin/speech-dictionary.json +9 -0
  181. package/examples/builtin/speech-kana.json +7 -0
  182. package/examples/builtin/speech.json +7 -0
  183. package/package.json +10 -4
  184. package/dist/formant.d.ts +0 -34
  185. package/dist/formant.d.ts.map +0 -1
  186. package/dist/formant.js +0 -622
  187. package/dist/formant.js.map +0 -1
  188. package/dist/mora.d.ts.map +0 -1
  189. package/dist/mora.js.map +0 -1
  190. package/dist/notation.d.ts +0 -15
  191. package/dist/notation.d.ts.map +0 -1
  192. package/dist/notation.js +0 -114
  193. package/dist/notation.js.map +0 -1
  194. package/dist/pitch.d.ts.map +0 -1
  195. package/dist/pitch.js.map +0 -1
  196. package/dist/score.d.ts +0 -37
  197. package/dist/score.d.ts.map +0 -1
  198. package/dist/score.js +0 -166
  199. package/dist/score.js.map +0 -1
  200. package/dist/text.d.ts.map +0 -1
  201. package/dist/text.js.map +0 -1
  202. package/examples/song-notes.json +0 -24
  203. /package/dist/{pitch.d.ts → song/pitch.d.ts} +0 -0
  204. /package/examples/{song.json → voicevox/song.json} +0 -0
  205. /package/examples/{speech-kana.json → voicevox/speech-kana.json} +0 -0
  206. /package/examples/{speech.json → voicevox/speech.json} +0 -0
package/dist/cli.js CHANGED
@@ -1,276 +1,490 @@
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";
7
- import { LIMITS } from "./limits.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 { ensureReadableFile, existingFileSha256, readBytes, readChunks, readLines, readText, writeAudioIdempotent, writeStream, writeTextFile, writeWithBackpressure, } from "./io.js";
9
+ import { DEFAULT_SAMPLE_RATE, 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";
12
- import { isObject, keys, literal, object, string } from "./validate.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";
16
+ import { integer, 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
- function metadata(result) {
22
+ function metadata(result, extraWarnings = []) {
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: extraWarnings.length === 0 ? result.warnings : [...result.warnings, ...extraWarnings],
43
+ adjustments: result.adjustments,
188
44
  };
189
45
  }
190
- async function deliver(result, values, signal) {
46
+ const PLAN_UNAVAILABLE = {
47
+ severity: "warning",
48
+ code: "PLAN_UNAVAILABLE",
49
+ message: "--plan-out was ignored: only the formant engine produces a synthesis plan.",
50
+ help: "Render with --engine formant (and no VOICEVOX style) to write the plan.",
51
+ path: "$flags.plan-out",
52
+ };
53
+ async function reportDiagnostics(values, warnings, location) {
54
+ if (values.diagnostics !== "compact")
55
+ return;
56
+ for (const warning of warnings)
57
+ await writeStream(process.stderr, `${formatDiagnostic(warning, location)}\n`);
58
+ }
59
+ async function deliver(result, values, signal, plan) {
60
+ const planOut = values["plan-out"];
61
+ const extraWarnings = planOut !== undefined && plan === undefined ? [PLAN_UNAVAILABLE] : [];
62
+ if (planOut !== undefined && planOut !== "-" && plan !== undefined) {
63
+ await writeTextFile(planOut, `${JSON.stringify(plan, null, 2)}\n`, values.force ?? false);
64
+ }
65
+ await reportDiagnostics(values, [...result.warnings, ...extraWarnings], values.input ?? "<request>");
191
66
  if (values.output === "-") {
192
67
  if (values.play)
193
- invalid("$flags.play", "--play cannot be combined with --output -.");
68
+ invalid("$flags.play", "--play cannot be combined with --output -.", {
69
+ hint: "Write to a file and play it: -o out.wav --play",
70
+ });
194
71
  await writeStream(process.stdout, result.audio);
195
- return { ok: true, output: "stdout", ...metadata(result) };
72
+ return {
73
+ ok: true,
74
+ output: "stdout",
75
+ ...metadata(result, extraWarnings),
76
+ ...(plan === undefined || planOut !== "-" ? {} : { plan }),
77
+ };
196
78
  }
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 }) };
79
+ const path = values.output ?? `kongyoroid-${result.kind}-${result.sha256.slice(0, 12)}.wav`;
80
+ const written = await writeAudioIdempotent(path, result.audio, values.force ?? false, signal);
81
+ const played = values.play ? await playWav(written.path, signal) : undefined;
82
+ return {
83
+ ok: true,
84
+ path: written.path,
85
+ written: written.written,
86
+ unchanged: written.unchanged,
87
+ ...metadata(result, extraWarnings),
88
+ ...(played === undefined ? {} : { player: played.player }),
89
+ ...(plan === undefined || planOut !== "-" ? {} : { plan }),
90
+ };
201
91
  }
202
92
  function speechRequestFromFlags(values, text) {
203
- const request = { kind: "speech", text };
93
+ return withSpeechFlags(values, { kind: "speech", text });
94
+ }
95
+ function assignExclusive(request, values, first, second) {
96
+ const a = numberFlag(values, first.flag);
97
+ const b = numberFlag(values, second.flag);
98
+ if (a !== undefined && b !== undefined)
99
+ invalid(`$flags.${second.flag}`, `Use either --${first.flag} or --${second.flag}, not both.`);
100
+ if (a !== undefined) {
101
+ request[first.key] = a;
102
+ delete request[second.key];
103
+ }
104
+ if (b !== undefined) {
105
+ request[second.key] = b;
106
+ delete request[first.key];
107
+ }
108
+ }
109
+ function withSpeechFlags(values, base) {
110
+ const request = { ...base, kind: "speech" };
204
111
  assign(request, "engine", values.engine);
112
+ assign(request, "voice", values.voice);
205
113
  assign(request, "kana", values.kana);
114
+ const entries = parseDictEntries(values);
115
+ if (entries.length > 0) {
116
+ const existing = Array.isArray(base["dictionary"]) ? base["dictionary"] : [];
117
+ request["dictionary"] = [...existing, ...entries];
118
+ }
119
+ if (values["strict-reading"] === false)
120
+ request["strictReading"] = false;
206
121
  assign(request, "speaker", styleFlag(values, "speaker"));
207
122
  assign(request, "speed", numberFlag(values, "speed"));
208
- assign(request, "pitch", numberFlag(values, "pitch"));
123
+ assignExclusive(request, values, { flag: "pitch", key: "pitch" }, { flag: "pitch-semitones", key: "pitchSemitones" });
209
124
  assign(request, "intonation", numberFlag(values, "intonation"));
210
- assign(request, "volume", numberFlag(values, "volume"));
125
+ assignExclusive(request, values, { flag: "volume", key: "volume" }, { flag: "gain-db", key: "gainDb" });
126
+ assign(request, "breathiness", numberFlag(values, "breathiness"));
211
127
  assign(request, "prePause", numberFlag(values, "pre-pause"));
212
128
  assign(request, "postPause", numberFlag(values, "post-pause"));
213
129
  assign(request, "pauseLength", numberFlag(values, "pause-length"));
214
130
  assign(request, "pauseScale", numberFlag(values, "pause-scale"));
215
- assign(request, "upspeak", values.upspeak);
131
+ if (values.upspeak !== undefined)
132
+ request["upspeak"] = values.upspeak;
216
133
  assign(request, "split", values.split);
217
134
  assign(request, "sampleRate", numberFlag(values, "sample-rate"));
218
135
  assign(request, "seed", numberFlag(values, "seed"));
219
136
  return request;
220
137
  }
138
+ function applyVibratoFlags(request, values, base) {
139
+ const depth = numberFlag(values, "vibrato-depth");
140
+ const rate = numberFlag(values, "vibrato-rate");
141
+ const delay = numberFlag(values, "vibrato-delay-ms");
142
+ const fade = numberFlag(values, "vibrato-fade-ms");
143
+ const existing = isObject(base["vibrato"]) ? base["vibrato"] : undefined;
144
+ if (existing === undefined && delay === undefined && fade === undefined) {
145
+ assign(request, "vibratoDepth", depth);
146
+ assign(request, "vibratoRate", rate);
147
+ return;
148
+ }
149
+ const vibrato = { ...existing };
150
+ if (existing === undefined) {
151
+ assign(vibrato, "depthCents", base["vibratoDepth"]);
152
+ assign(vibrato, "rateHz", base["vibratoRate"]);
153
+ delete request["vibratoDepth"];
154
+ delete request["vibratoRate"];
155
+ }
156
+ assign(vibrato, "depthCents", depth);
157
+ assign(vibrato, "rateHz", rate);
158
+ assign(vibrato, "delayMs", delay);
159
+ assign(vibrato, "fadeMs", fade);
160
+ request["vibrato"] = vibrato;
161
+ }
221
162
  function songRequestFromFlags(values, base) {
222
163
  const request = { ...base, kind: "song" };
223
- if (values.lyrics !== undefined || values.melody !== undefined) {
224
- const notes = { lyrics: values.lyrics ?? "", melody: values.melody ?? "" };
164
+ if (values.mml !== undefined) {
165
+ if (values.melody !== undefined || values.beats !== undefined)
166
+ invalid("$flags.mml", "--mml cannot be combined with --melody or --beats.");
167
+ request["notes"] = { mml: values.mml, ...(values.lyrics === undefined ? {} : { lyrics: values.lyrics }) };
168
+ }
169
+ else if (values.lyrics !== undefined || values.melody !== undefined) {
170
+ if (values.melody === undefined)
171
+ invalid("$flags.melody", "--lyrics needs --melody (or use --mml).", {
172
+ hint: 'Example: --lyrics "ドレミ" --melody "C4 D4 E4"',
173
+ });
174
+ const notes = { lyrics: values.lyrics ?? "", melody: values.melody };
225
175
  assign(notes, "beats", values.beats);
226
176
  request["notes"] = notes;
227
177
  }
228
178
  else if (values.beats !== undefined) {
229
- invalid("$flags.beats", "--beats needs --melody.");
179
+ invalid("$flags.beats", "--beats needs --melody.", {
180
+ hint: 'Example: --lyrics "ドレミ" --melody "C4 D4 E4" --beats "1 1 2"',
181
+ });
182
+ }
183
+ if (request["notes"] === undefined) {
184
+ invalid(values.input === undefined ? "$flags.melody" : "$.notes", "A song needs notes: give --lyrics with --melody, --mml, or --input FILE containing a notes list.", {
185
+ hint: 'Examples: kongyoroid sing --lyrics "ドレミ" --melody "C4 D4 E4" | kongyoroid sing --mml "t120 o4 c d e" | kongyoroid sing --input song.json',
186
+ });
230
187
  }
231
188
  assign(request, "engine", values.engine);
189
+ assign(request, "voice", values.voice);
232
190
  assign(request, "tempo", numberFlag(values, "tempo"));
233
191
  assign(request, "singer", styleFlag(values, "singer"));
234
192
  assign(request, "teacher", styleFlag(values, "teacher"));
235
193
  assign(request, "transpose", numberFlag(values, "transpose"));
236
- assign(request, "volume", numberFlag(values, "volume"));
237
- assign(request, "vibratoDepth", numberFlag(values, "vibrato-depth"));
238
- assign(request, "vibratoRate", numberFlag(values, "vibrato-rate"));
194
+ assignExclusive(request, values, { flag: "volume", key: "volume" }, { flag: "gain-db", key: "gainDb" });
195
+ assign(request, "breathiness", numberFlag(values, "breathiness"));
196
+ applyVibratoFlags(request, values, base);
197
+ assign(request, "portamentoMs", numberFlag(values, "portamento-ms"));
198
+ assign(request, "scoopCents", numberFlag(values, "scoop-cents"));
199
+ assign(request, "scoopMs", numberFlag(values, "scoop-ms"));
200
+ if (values["consonant-compression"] !== undefined)
201
+ request["consonantCompression"] = values["consonant-compression"];
239
202
  assign(request, "leadIn", numberFlag(values, "lead-in"));
240
203
  assign(request, "leadOut", numberFlag(values, "lead-out"));
241
204
  assign(request, "sampleRate", numberFlag(values, "sample-rate"));
242
205
  assign(request, "seed", numberFlag(values, "seed"));
243
206
  return request;
244
207
  }
245
- async function textInput(values, signal) {
208
+ function rejectTextAndInput(values) {
246
209
  if (values.text !== undefined && values.input !== undefined)
247
210
  invalid("$flags", "Use either --text or --input, not both.");
211
+ }
212
+ async function textInput(values, signal) {
213
+ rejectTextAndInput(values);
248
214
  if (values.text !== undefined)
249
215
  return values.text;
250
216
  if (values.input !== undefined)
251
217
  return (await readText(values.input, signal)).replace(/\r?\n$/u, "");
252
- return invalid("$flags.text", "Provide --text or --input FILE (use - for standard input).");
218
+ if (values.kana !== undefined)
219
+ return values.kana;
220
+ return invalid("$flags.text", "Provide --text or --input FILE (use - for standard input).", {
221
+ hint: 'Example: kongyoroid speak --text "こんにちは" -o hello.wav',
222
+ });
223
+ }
224
+ function overlayRequest(values, json) {
225
+ if (isObject(json) && json["kind"] === "song")
226
+ return songRequestFromFlags(values, json);
227
+ if (isObject(json) && json["kind"] === "speech" && typeof json["text"] === "string")
228
+ return withSpeechFlags(values, json);
229
+ if (isObject(json)) {
230
+ const overlay = { ...json };
231
+ assign(overlay, "engine", values.engine);
232
+ assign(overlay, "voice", values.voice);
233
+ return overlay;
234
+ }
235
+ return json;
253
236
  }
254
- function createAgent(values) {
237
+ async function requestFromInputs(values, signal, command) {
238
+ rejectTextAndInput(values);
239
+ const hasSpeech = values.text !== undefined || values.kana !== undefined;
240
+ const hasSong = values.lyrics !== undefined || values.melody !== undefined || values.mml !== undefined;
241
+ if (values.input !== undefined) {
242
+ const raw = await readText(values.input, signal);
243
+ const looksLikeJson = raw.trim().startsWith("{");
244
+ if (!looksLikeJson && !hasSong)
245
+ return speechRequestFromFlags(values, raw.replace(/\r?\n$/u, ""));
246
+ return overlayRequest(values, parseJson(raw));
247
+ }
248
+ if (hasSong)
249
+ return songRequestFromFlags(values, {});
250
+ if (hasSpeech)
251
+ return speechRequestFromFlags(values, await textInput(values, signal));
252
+ const scoreHint = command === "speak" ? "" : ", or --lyrics/--melody/--mml";
253
+ return invalid("$flags.input", `Provide --input FILE|- (text or a request JSON) or --text${scoreHint} for ${command}.`, {
254
+ hint: `Examples: kongyoroid ${command} --text "こんにちは" | kongyoroid ${command} --input request.json`,
255
+ });
256
+ }
257
+ async function loadDictionaryFile(path, tolerateMissing) {
258
+ if (path === undefined)
259
+ return undefined;
260
+ try {
261
+ return await LocalDictionary.load(path);
262
+ }
263
+ catch (error) {
264
+ if (tolerateMissing &&
265
+ error instanceof KongyoroidError &&
266
+ error.code === "IO_ERROR" &&
267
+ /ENOENT/u.test(error.message))
268
+ return undefined;
269
+ throw error;
270
+ }
271
+ }
272
+ async function createAgent(values, tolerateMissingDictionary = false) {
255
273
  const engineFlag = values.engine ?? env("KONGYOROID_ENGINE");
256
274
  const concurrency = numberFlag(values, "concurrency");
257
275
  const timeoutMs = numberFlag(values, "timeout-ms");
258
276
  const retries = numberFlag(values, "retries");
259
277
  const cacheDir = values["cache-dir"] ?? env("KONGYOROID_CACHE_DIR");
278
+ const voice = values.voice ?? env("KONGYOROID_VOICE");
260
279
  const speaker = env("KONGYOROID_SPEAKER");
261
280
  const singer = env("KONGYOROID_SINGER");
262
281
  const teacher = env("KONGYOROID_TEACHER");
263
- return new Kongyoroid({
282
+ const dictionaryPath = values.dictionary ?? env("KONGYOROID_DICTIONARY");
283
+ const dictionary = await loadDictionaryFile(dictionaryPath, tolerateMissingDictionary);
284
+ const options = {
264
285
  endpoint: values.endpoint ?? env("KONGYOROID_ENDPOINT") ?? env("VOICEVOX_URL") ?? "http://127.0.0.1:50021",
265
- ...(engineFlag === undefined ? {} : { engine: parseEngine(engineFlag, "$flags.engine") }),
286
+ ...(engineFlag === undefined || engineFlag === "all" ? {} : { engine: parseEngine(engineFlag, "$flags.engine") }),
287
+ ...(voice === undefined ? {} : { voice }),
266
288
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
267
289
  ...(retries === undefined ? {} : { retries }),
268
290
  ...(concurrency === undefined ? {} : { concurrency }),
269
- ...(cacheDir === undefined ? {} : { cacheDir }),
291
+ ...(dictionary === undefined ? {} : { dictionary }),
270
292
  ...(speaker === undefined ? {} : { speaker }),
271
293
  ...(singer === undefined ? {} : { singer }),
272
294
  ...(teacher === undefined ? {} : { teacher }),
273
- });
295
+ cache: {
296
+ ...(cacheDir === undefined ? {} : { directory: cacheDir }),
297
+ ...(values.cache === false ? { enabled: false } : {}),
298
+ },
299
+ };
300
+ return new Kongyoroid(options);
301
+ }
302
+ function planDetail(values) {
303
+ return literal(values.detail ?? "summary", "$flags.detail", ["summary", "phonemes", "acoustics"]);
304
+ }
305
+ async function runValidate(agent, values, signal) {
306
+ const request = await requestFromInputs(values, signal, "validate");
307
+ const result = await agent.validate(request, { signal });
308
+ if (values.diagnostics === "compact") {
309
+ for (const warning of result.warnings)
310
+ await writeStream(process.stdout, `${formatDiagnostic(warning, values.input ?? "<request>")}\n`);
311
+ if (result.warnings.length === 0)
312
+ await emit({ ok: true, operation: "validate", renderable: result.renderable, requestHash: result.requestHash });
313
+ return;
314
+ }
315
+ const { request: resolved, ...rest } = result;
316
+ await emit({ ...rest, request: resolved });
317
+ }
318
+ async function runPlan(agent, values, signal) {
319
+ const request = await requestFromInputs(values, signal, "plan");
320
+ const result = await agent.plan(request, { signal, detail: planDetail(values) });
321
+ await emit(result);
322
+ }
323
+ async function* single(text) {
324
+ yield text;
325
+ }
326
+ async function* prepend(head, iterator) {
327
+ for (const chunk of head)
328
+ yield chunk;
329
+ try {
330
+ while (true) {
331
+ const next = await iterator.next();
332
+ if (next.done === true)
333
+ return;
334
+ yield next.value;
335
+ }
336
+ }
337
+ finally {
338
+ await iterator.return?.();
339
+ }
340
+ }
341
+ async function streamInput(values, signal) {
342
+ const fromFlags = () => {
343
+ const request = speechRequestFromFlags(values, "placeholder");
344
+ delete request["text"];
345
+ return request;
346
+ };
347
+ if (values.text !== undefined)
348
+ return { request: fromFlags(), source: single(values.text) };
349
+ const iterator = readChunks(values.input ?? "-", signal)[Symbol.asyncIterator]();
350
+ const head = [];
351
+ let leading = "";
352
+ while (leading.trim().length === 0) {
353
+ const next = await iterator.next();
354
+ if (next.done === true)
355
+ break;
356
+ head.push(next.value);
357
+ leading += next.value;
358
+ }
359
+ if (!leading.trimStart().startsWith("{"))
360
+ return { request: fromFlags(), source: prepend(head, iterator) };
361
+ for (let next = await iterator.next(); next.done !== true; next = await iterator.next())
362
+ head.push(next.value);
363
+ const json = object(parseJson(head.join("")), "$");
364
+ if (json["kind"] !== undefined && json["kind"] !== "speech")
365
+ invalid("$.kind", "speak accepts only speech requests; use sing or render for songs.");
366
+ const text = json["text"];
367
+ if (typeof text !== "string")
368
+ invalid("$.text", "A request JSON given to --stream needs a text string to stream.", {
369
+ hint: 'Example: {"kind":"speech","text":"一文目。二文目。","voice":"soft"}',
370
+ });
371
+ const request = withSpeechFlags(values, json);
372
+ delete request["text"];
373
+ return { request, source: single(text) };
374
+ }
375
+ async function runSpeakStream(agent, values, signal) {
376
+ if (values.play)
377
+ invalid("$flags.play", "--play cannot be combined with --stream.");
378
+ if (values.kana !== undefined)
379
+ invalid("$flags.kana", "--kana cannot be combined with --stream; streaming reads each sentence from the text.", {
380
+ hint: "Give readings with --dict-entry SURFACE=READING[:ACCENT], or drop --stream to speak the kana at once.",
381
+ });
382
+ rejectTextAndInput(values);
383
+ const format = literal(values.format ?? "wav", "$flags.format", ["wav", "pcm", "ndjson"]);
384
+ const output = values.output ?? "-";
385
+ if (output !== "-" && format !== "wav")
386
+ invalid("$flags.format", "Streaming to a file writes WAV; use --output - for pcm or ndjson.");
387
+ const flushRaw = numberFlag(values, "flush-ms");
388
+ const flushMs = flushRaw === undefined ? undefined : integer(flushRaw, "$flags.flush-ms", 1, 600_000);
389
+ const { request: input, source } = await streamInput(values, signal);
390
+ const events = agent.speakStream(source, input, { signal, ...(flushMs === undefined ? {} : { flushMs }) });
391
+ const stdout = process.stdout;
392
+ let sampleRate = 0;
393
+ let file;
394
+ let fileFrames = 0;
395
+ const filePath = output === "-" ? "" : resolve(output);
396
+ const sentences = [];
397
+ const openFile = async () => {
398
+ if (output === "-" || file !== undefined)
399
+ return;
400
+ const { open } = await import("node:fs/promises");
401
+ if (!values.force) {
402
+ const existing = await existingFileSha256(filePath);
403
+ if (existing !== undefined)
404
+ invalid("$flags.output", `Refusing to overwrite ${filePath}.`, {
405
+ hint: "Pass --force or choose another path.",
406
+ repairOptions: [{ action: "use-force", description: "Overwrite with --force." }],
407
+ });
408
+ }
409
+ if (sampleRate === 0)
410
+ sampleRate = numberFlag(values, "sample-rate") ?? DEFAULT_SAMPLE_RATE;
411
+ file = await open(filePath, "w", 0o644);
412
+ await file.write(wavHeader(0, sampleRate));
413
+ };
414
+ let headerWritten = false;
415
+ let completed = false;
416
+ try {
417
+ for await (const event of events) {
418
+ if (event.type === "sentence") {
419
+ const line = {
420
+ type: "sentence",
421
+ index: event.index,
422
+ text: event.text,
423
+ kana: event.kana,
424
+ durationSeconds: Math.round(event.durationSeconds * 1000) / 1000,
425
+ startSeconds: sampleRate === 0 ? 0 : Math.round((event.startFrame / sampleRate) * 1000) / 1000,
426
+ warnings: event.warnings,
427
+ };
428
+ sentences.push(line);
429
+ if (format === "ndjson" && output === "-")
430
+ await writeWithBackpressure(stdout, `${JSON.stringify(line)}\n`, signal);
431
+ else if (values.progress)
432
+ await emit(line, true);
433
+ continue;
434
+ }
435
+ if (event.type === "audio") {
436
+ const block = event.block;
437
+ if (sampleRate === 0) {
438
+ sampleRate = block.sampleRate;
439
+ await openFile();
440
+ }
441
+ const bytes = pcm16Bytes(block.samples);
442
+ if (file !== undefined) {
443
+ await file.write(bytes);
444
+ fileFrames += block.samples.length;
445
+ continue;
446
+ }
447
+ if (format === "ndjson") {
448
+ 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);
449
+ continue;
450
+ }
451
+ if (format === "wav" && !headerWritten) {
452
+ await writeWithBackpressure(stdout, streamingWavHeader(sampleRate), signal);
453
+ headerWritten = true;
454
+ }
455
+ await writeWithBackpressure(stdout, bytes, signal);
456
+ continue;
457
+ }
458
+ completed = true;
459
+ if (output !== "-")
460
+ await openFile();
461
+ const summary = {
462
+ ok: true,
463
+ operation: "speak",
464
+ stream: true,
465
+ format,
466
+ output: output === "-" ? "stdout" : filePath,
467
+ ...(output === "-" ? {} : { written: true }),
468
+ sentences: event.sentences,
469
+ frames: event.frames,
470
+ durationSeconds: Math.round(event.durationSeconds * 1000) / 1000,
471
+ sampleRate: sampleRate === 0 ? null : sampleRate,
472
+ ...(values.progress || format === "ndjson" ? {} : { segments: sentences }),
473
+ };
474
+ if (format === "ndjson" && output === "-")
475
+ await writeWithBackpressure(stdout, `${JSON.stringify({ type: "end", ...summary })}\n`, signal);
476
+ else
477
+ await emit(summary, output === "-");
478
+ }
479
+ }
480
+ finally {
481
+ if (file !== undefined) {
482
+ const header = wavHeader(fileFrames, sampleRate);
483
+ await file.write(header, 0, 44, 0);
484
+ await file.close();
485
+ }
486
+ void completed;
487
+ }
274
488
  }
275
489
  async function runBatch(agent, values, signal, onFailure) {
276
490
  const input = string(values.input, "$flags.input", 1, 4096);
@@ -279,6 +493,7 @@ async function runBatch(agent, values, signal, onFailure) {
279
493
  const active = new Set();
280
494
  const ids = new Set();
281
495
  let line = 0;
496
+ let stop = false;
282
497
  const run = async (text, index) => {
283
498
  let id = null;
284
499
  try {
@@ -291,12 +506,22 @@ async function runBatch(agent, values, signal, onFailure) {
291
506
  invalid(`$line[${index}].id`, "Duplicate job id (case-insensitive).");
292
507
  ids.add(id.toLowerCase());
293
508
  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) });
509
+ const written = await writeAudioIdempotent(resolve(directory, `${id}.wav`), result.audio, values.force ?? false, signal);
510
+ await emit({
511
+ ok: true,
512
+ id,
513
+ line: index,
514
+ path: written.path,
515
+ written: written.written,
516
+ unchanged: written.unchanged,
517
+ ...metadata(result),
518
+ });
296
519
  }
297
520
  catch (error) {
298
521
  const failure = asKongyoroidError(error);
299
522
  onFailure();
523
+ if (values["stop-on-error"])
524
+ stop = true;
300
525
  process.exitCode = Math.max(Number(process.exitCode ?? 0), exitCodeOf(failure));
301
526
  await emit({ ok: false, id, line: index, error: failure.toJSON() });
302
527
  if (failure.code === "ABORTED")
@@ -305,6 +530,8 @@ async function runBatch(agent, values, signal, onFailure) {
305
530
  };
306
531
  try {
307
532
  for await (const text of readLines(input, signal)) {
533
+ if (stop)
534
+ break;
308
535
  line += 1;
309
536
  if (line > LIMITS.batchLines)
310
537
  invalid("$flags.input", `A batch is limited to ${LIMITS.batchLines} lines.`);
@@ -321,69 +548,231 @@ async function runBatch(agent, values, signal, onFailure) {
321
548
  }
322
549
  }
323
550
  async function runDict(agent, values, positionals, signal) {
324
- const action = literal(positionals[1], "$.dict", ["list", "add", "update", "delete"]);
551
+ const action = literal(positionals[1], "$.dict", ["list", "add", "update", "delete", "check"]);
552
+ const scope = literal(values.scope ?? "local", "$flags.scope", ["local", "voicevox"]);
553
+ const reading = values.reading ?? values.pronunciation;
554
+ if (scope === "voicevox") {
555
+ if (action === "check")
556
+ invalid("$flags.scope", "dict check is only available for the local dictionary.");
557
+ if (action === "list") {
558
+ await emit({ ok: true, operation: "dict.list", scope, words: await agent.voicevoxDictionary.list({ signal }) });
559
+ return;
560
+ }
561
+ if (action === "delete") {
562
+ const uuid = string(values.id ?? positionals[2], "$flags.id", 1, 200);
563
+ if (values["dry-run"]) {
564
+ await emit({ ok: true, operation: "dict.delete", scope, dryRun: true, uuid });
565
+ return;
566
+ }
567
+ await agent.voicevoxDictionary.remove(uuid, { signal });
568
+ await emit({ ok: true, operation: "dict.delete", scope, deleted: uuid });
569
+ return;
570
+ }
571
+ const priority = numberFlag(values, "priority");
572
+ const draft = {
573
+ surface: string(values.surface, "$flags.surface", 1, 200),
574
+ pronunciation: string(reading, "$flags.reading", 1, 200),
575
+ accentType: numberFlag(values, "accent") ?? invalid("$flags.accent", "Provide --accent N (0 = flat)."),
576
+ ...(values["word-type"] === undefined ? {} : { wordType: values["word-type"] }),
577
+ ...(priority === undefined ? {} : { priority }),
578
+ };
579
+ if (action === "add") {
580
+ if (values["dry-run"]) {
581
+ await emit({ ok: true, operation: "dict.add", scope, dryRun: true, word: draft });
582
+ return;
583
+ }
584
+ const uuid = await agent.voicevoxDictionary.add(draft, { signal });
585
+ await emit({ ok: true, operation: "dict.add", scope, uuid, word: draft });
586
+ return;
587
+ }
588
+ const uuid = string(values.id ?? positionals[2], "$flags.id", 1, 200);
589
+ await agent.voicevoxDictionary.update(uuid, draft, { signal });
590
+ await emit({ ok: true, operation: "dict.update", scope, updated: uuid, word: draft });
591
+ return;
592
+ }
593
+ const path = values.dictionary ?? env("KONGYOROID_DICTIONARY") ?? "kongyoroid-dictionary.json";
594
+ const dictionary = (await loadDictionaryFile(path, true)) ?? new LocalDictionary();
325
595
  if (action === "list") {
326
- await emit({ ok: true, words: await agent.dictionary.list({ signal }) });
596
+ await emit({
597
+ ok: true,
598
+ operation: "dict.list",
599
+ scope,
600
+ path: resolve(path),
601
+ entries: dictionary.list(),
602
+ digest: dictionary.digest(),
603
+ });
604
+ return;
605
+ }
606
+ if (action === "check") {
607
+ const text = await textInput(values, signal);
608
+ const merged = agent.dictionary.merge(dictionary);
609
+ const analysed = await agent.reading(text, { signal, dictionary: merged, engine: "formant", strict: false });
610
+ await emit({
611
+ ok: true,
612
+ operation: "dict.check",
613
+ scope,
614
+ path: resolve(path),
615
+ text,
616
+ kana: analysed.kana,
617
+ hits: analysed.dictionaryHits,
618
+ phrases: analysed.phrases.map((p) => ({ text: p.text, accent: p.accent, accentSource: p.accentSource })),
619
+ warnings: analysed.warnings,
620
+ });
327
621
  return;
328
622
  }
329
623
  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 });
624
+ const id = string(values.id ?? positionals[2], "$flags.id", 1, 100);
625
+ const entry = dictionary.get(id);
626
+ if (entry === undefined) {
627
+ await emit({
628
+ ok: true,
629
+ operation: "dict.delete",
630
+ scope,
631
+ path: resolve(path),
632
+ id,
633
+ deleted: false,
634
+ message: "No such entry: nothing to do.",
635
+ });
636
+ return;
637
+ }
638
+ if (values["dry-run"]) {
639
+ await emit({ ok: true, operation: "dict.delete", scope, path: resolve(path), dryRun: true, entry });
640
+ return;
641
+ }
642
+ dictionary.remove(id);
643
+ await writeTextFile(path, `${JSON.stringify(dictionary.toJSON(), null, 2)}\n`, true);
644
+ await emit({
645
+ ok: true,
646
+ operation: "dict.delete",
647
+ scope,
648
+ path: resolve(path),
649
+ id,
650
+ deleted: true,
651
+ digest: dictionary.digest(),
652
+ });
333
653
  return;
334
654
  }
655
+ const surface = string(values.surface, "$flags.surface", 1, 200);
656
+ const accent = numberFlag(values, "accent");
335
657
  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"] }),
658
+ const input = {
659
+ ...(values.id === undefined ? {} : { id: values.id }),
660
+ surface,
661
+ reading: string(reading, "$flags.reading", 1, 200),
662
+ ...(accent === undefined ? {} : { accent }),
663
+ ...(values.match === undefined ? {} : { match: literal(values.match, "$flags.match", ["word", "anywhere"]) }),
341
664
  ...(priority === undefined ? {} : { priority }),
342
665
  };
343
- if (action === "add") {
344
- const uuid = await agent.dictionary.add(draft, { signal });
345
- await emit({ ok: true, uuid, word: draft });
666
+ if (action === "update" && values.id === undefined)
667
+ invalid("$flags.id", "dict update needs --id ENTRY_ID.", { hint: "List ids with: kongyoroid dict list" });
668
+ if (action === "add" && values.id === undefined) {
669
+ const existing = dictionary
670
+ .find(surface)
671
+ .find((entry) => entry.reading === input.reading && (accent === undefined || entry.accent === accent));
672
+ if (existing !== undefined) {
673
+ await emit({
674
+ ok: true,
675
+ operation: "dict.add",
676
+ scope,
677
+ path: resolve(path),
678
+ entry: existing,
679
+ created: false,
680
+ unchanged: true,
681
+ });
682
+ return;
683
+ }
684
+ }
685
+ if (values["dry-run"]) {
686
+ await emit({ ok: true, operation: `dict.${action}`, scope, path: resolve(path), dryRun: true, entry: input });
346
687
  return;
347
688
  }
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,
689
+ const result = dictionary.upsert(input);
690
+ await writeTextFile(path, `${JSON.stringify(dictionary.toJSON(), null, 2)}\n`, true);
691
+ await emit({
692
+ ok: true,
693
+ operation: `dict.${action}`,
694
+ scope,
695
+ path: resolve(path),
696
+ entry: result.entry,
697
+ created: result.created,
698
+ digest: dictionary.digest(),
359
699
  });
360
- if (values.help) {
361
- await writeStream(process.stdout, HELP);
700
+ }
701
+ async function runCache(values, positionals) {
702
+ const action = literal(positionals[1], "$.cache", ["stats", "prune", "clear"]);
703
+ const directory = values["cache-dir"] ?? env("KONGYOROID_CACHE_DIR");
704
+ if (directory === undefined)
705
+ invalid("$flags.cache-dir", "Provide --cache-dir DIR (or set KONGYOROID_CACHE_DIR).", {
706
+ hint: "Example: kongyoroid cache stats --cache-dir ./cache",
707
+ });
708
+ const cache = new DiskCache(directory);
709
+ if (action === "stats") {
710
+ await emit({ ok: true, operation: "cache.stats", ...(await cache.stats()) });
711
+ return;
712
+ }
713
+ const maxBytes = numberFlag(values, "max-bytes");
714
+ const maxEntries = numberFlag(values, "max-entries");
715
+ if (action === "prune" && maxBytes === undefined && maxEntries === undefined) {
716
+ invalid("$flags.max-bytes", "cache prune needs --max-bytes N and/or --max-entries N.", {
717
+ hint: "Example: kongyoroid cache prune --cache-dir ./cache --max-bytes 200000000",
718
+ });
719
+ }
720
+ const before = await cache.stats();
721
+ if (values["dry-run"]) {
722
+ await emit({
723
+ ok: true,
724
+ operation: `cache.${action}`,
725
+ dryRun: true,
726
+ directory: cache.directory,
727
+ wouldRemove: action === "clear" ? before.entries : await cache.prune(maxBytes, maxEntries, true),
728
+ ...before,
729
+ });
362
730
  return;
363
731
  }
732
+ const removed = action === "clear" ? await cache.clear() : await cache.prune(maxBytes, maxEntries);
733
+ await emit({ ok: true, operation: `cache.${action}`, directory: cache.directory, removed, ...(await cache.stats()) });
734
+ }
735
+ async function main(argv) {
736
+ const { values, positionals } = parseCommandLine(argv);
737
+ const command = positionals[0];
364
738
  if (values.version) {
365
739
  await emit({ ok: true, name: PACKAGE_NAME, version: VERSION });
366
740
  return;
367
741
  }
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.");
742
+ if (command === "help") {
743
+ await writeStream(process.stdout, positionals[1] === undefined ? rootHelp() : commandHelp(positionals[1]));
744
+ return;
745
+ }
746
+ if (values.help || command === undefined) {
747
+ if (command === undefined && !values.help) {
748
+ await writeStream(process.stderr, rootHelp());
749
+ process.exitCode = EXIT_CODES.input;
750
+ return;
751
+ }
752
+ await writeStream(process.stdout, command === undefined ? rootHelp() : commandHelp(command));
753
+ return;
371
754
  }
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(", ")}.`);
755
+ if (!Object.hasOwn(ALLOWED, command)) {
756
+ invalid("$.command", `Unknown command ${JSON.stringify(command)}.`, {
757
+ hint: `Commands: ${Object.keys(ALLOWED)
758
+ .filter((name) => name !== "help")
759
+ .join(", ")}. Run kongyoroid --help.`,
760
+ });
376
761
  }
762
+ checkAllowed(command, values);
377
763
  if (command === "schema") {
378
- await emit(literal(values.kind ?? "request", "$flags.kind", ["request", "batch"]) === "batch"
379
- ? BATCH_JOB_SCHEMA
380
- : REQUEST_SCHEMA);
764
+ const kind = literal(values.kind ?? "request", "$flags.kind", ["request", "batch", "dictionary"]);
765
+ await emit(kind === "batch" ? BATCH_JOB_SCHEMA : kind === "dictionary" ? DICTIONARY_SCHEMA : REQUEST_SCHEMA);
381
766
  return;
382
767
  }
383
768
  if (command === "capabilities") {
384
769
  await emit(CAPABILITIES);
385
770
  return;
386
771
  }
772
+ if (command === "cache") {
773
+ await runCache(values, positionals);
774
+ return;
775
+ }
387
776
  const controller = new AbortController();
388
777
  const cancel = () => {
389
778
  controller.abort();
@@ -393,33 +782,92 @@ async function main(argv) {
393
782
  const signal = controller.signal;
394
783
  try {
395
784
  if (command === "play") {
396
- const path = string(positionals[1], "$.play.file", 1, 4096);
397
- await emit({ ok: true, path: resolve(path), ...(await playWav(resolve(path), signal)) });
785
+ const path = resolve(string(positionals[1], "$.play.file", 1, 4096));
786
+ await ensureReadableFile(path);
787
+ await emit({ ok: true, operation: "play", path, ...(await playWav(path, signal)) });
788
+ return;
789
+ }
790
+ if (command === "inspect") {
791
+ const path = string(positionals[1] ?? values.input, "$.inspect.file", 1, 4096);
792
+ const agent = new Kongyoroid({ cache: { enabled: false } });
793
+ const bytes = await readBytes(resolve(path));
794
+ const windowMs = numberFlag(values, "window-ms");
795
+ const result = agent.inspect(bytes, {
796
+ pitchTrack: values["pitch-track"] ?? false,
797
+ ...(windowMs === undefined ? {} : { windowMs }),
798
+ });
799
+ await emit({ ok: true, operation: "inspect", path: resolve(path), ...result });
398
800
  return;
399
801
  }
400
- const agent = createAgent(values);
802
+ if (positionals.length > 1 && command !== "dict") {
803
+ invalid("$.command", `Unexpected argument ${JSON.stringify(positionals[1])} for ${command}.`, {
804
+ hint: `Run: kongyoroid ${command} --help`,
805
+ });
806
+ }
807
+ const agent = await createAgent(values, command === "dict");
401
808
  switch (command) {
402
809
  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 === "-");
810
+ if (values.stream && !values["dry-run"]) {
811
+ await runSpeakStream(agent, values, signal);
812
+ return;
813
+ }
814
+ const request = await requestFromInputs(values, signal, "speak");
815
+ if (isObject(request) && request["kind"] !== "speech")
816
+ invalid("$.kind", "speak accepts only speech requests; use sing or render for songs.");
817
+ if (values["dry-run"]) {
818
+ await runValidateRequest(agent, request, values, signal);
819
+ return;
820
+ }
821
+ const result = await agent.render(request, { signal });
822
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
823
+ ? undefined
824
+ : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
825
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
405
826
  return;
406
827
  }
407
828
  case "sing": {
408
829
  const base = values.input === undefined ? {} : object(parseJson(await readText(values.input, signal)), "$");
409
830
  if (base["kind"] !== undefined && base["kind"] !== "song")
410
831
  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 === "-");
832
+ const request = songRequestFromFlags(values, base);
833
+ if (values["dry-run"]) {
834
+ await runValidateRequest(agent, request, values, signal);
835
+ return;
836
+ }
837
+ const result = await agent.render(request, { signal });
838
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
839
+ ? undefined
840
+ : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
841
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
413
842
  return;
414
843
  }
415
844
  case "render": {
416
845
  const input = string(values.input, "$flags.input", 1, 4096);
417
846
  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 === "-");
847
+ const withFlags = isObject(request)
848
+ ? {
849
+ ...request,
850
+ ...(values.engine === undefined ? {} : { engine: values.engine }),
851
+ ...(values.voice === undefined ? {} : { voice: values.voice }),
852
+ }
853
+ : request;
854
+ if (values["dry-run"]) {
855
+ await runValidateRequest(agent, withFlags, values, signal);
856
+ return;
857
+ }
858
+ const result = await agent.render(withFlags, { signal });
859
+ const plan = values["plan-out"] === undefined || result.engine !== "formant"
860
+ ? undefined
861
+ : (await agent.plan(withFlags, { signal, detail: "phonemes" })).plan;
862
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
421
863
  return;
422
864
  }
865
+ case "validate":
866
+ await runValidate(agent, values, signal);
867
+ return;
868
+ case "plan":
869
+ await runPlan(agent, values, signal);
870
+ return;
423
871
  case "batch": {
424
872
  let failures = 0;
425
873
  await runBatch(agent, values, signal, () => {
@@ -430,28 +878,60 @@ async function main(argv) {
430
878
  return;
431
879
  }
432
880
  case "reading": {
433
- const text = await textInput(values, signal);
881
+ const text = values.kana !== undefined && values.text === undefined && values.input === undefined
882
+ ? values.kana
883
+ : await textInput(values, signal);
434
884
  const speakerRef = styleFlag(values, "speaker");
885
+ const entries = parseDictEntries(values);
435
886
  const reading = await agent.reading(text, {
436
887
  signal,
437
888
  ...(values.kana === undefined ? {} : { kana: values.kana }),
438
889
  ...(speakerRef === undefined ? {} : { speaker: speakerRef }),
890
+ ...(values.engine === undefined ? {} : { engine: parseEngine(values.engine, "$flags.engine") }),
891
+ ...(entries.length === 0
892
+ ? {}
893
+ : {
894
+ dictionary: new LocalDictionary(entries.map((entry, index) => ({
895
+ id: `flag-${index + 1}`,
896
+ surface: entry.surface,
897
+ reading: entry.reading,
898
+ accent: entry.accent ?? null,
899
+ match: "word",
900
+ priority: 10,
901
+ caseSensitive: false,
902
+ }))),
903
+ }),
904
+ ...(values["strict-reading"] === false ? { strict: false } : {}),
439
905
  });
440
- await emit({ ok: true, text, ...reading });
906
+ await emit({ ok: true, operation: "reading", text, ...reading });
441
907
  return;
442
908
  }
443
909
  case "voices": {
444
910
  const kind = literal(values.kind ?? "all", "$flags.kind", ["speech", "song", "all"]);
911
+ const engine = literal(values.engine ?? "all", "$flags.engine", ["formant", "voicevox", "auto", "all"]);
445
912
  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 });
913
+ const result = await agent.voices({ signal, engine, kind });
914
+ const voices = result.voices.filter((voice) => query === undefined ||
915
+ `${voice.id} ${voice.name} ${voice.character ?? ""}`.normalize("NFKC").toLowerCase().includes(query));
916
+ await emit({
917
+ ok: true,
918
+ operation: "voices",
919
+ kind,
920
+ engine,
921
+ count: voices.length,
922
+ voices,
923
+ voicevox: result.voicevox,
924
+ });
448
925
  return;
449
926
  }
450
927
  case "doctor": {
451
- const diagnosis = await agent.doctor({ signal });
928
+ const engine = values.engine === undefined
929
+ ? undefined
930
+ : literal(values.engine, "$flags.engine", ["formant", "voicevox", "auto", "all"]);
931
+ const diagnosis = await agent.doctor({ signal, ...(engine === undefined ? {} : { engine }) });
452
932
  const speakerRef = styleFlag(values, "speaker");
453
933
  let style;
454
- if (diagnosis.ok && speakerRef !== undefined) {
934
+ if (diagnosis.voicevox?.ok === true && speakerRef !== undefined) {
455
935
  const all = await agent.styles.all({ signal });
456
936
  const selected = all.find((s) => s.id === speakerRef || `${s.character}/${s.name}` === speakerRef || s.character === speakerRef);
457
937
  if (selected === undefined)
@@ -460,7 +940,7 @@ async function main(argv) {
460
940
  await agent.client.initializeSpeaker(selected.id, true, { signal });
461
941
  style = { ...selected, initialized: await agent.client.isInitializedSpeaker(selected.id, { signal }) };
462
942
  }
463
- await emit({ ...diagnosis, formant: { available: true }, ...(style === undefined ? {} : { style }) });
943
+ await emit({ ...diagnosis, operation: "doctor", ...(style === undefined ? {} : { style }) });
464
944
  if (!diagnosis.ok)
465
945
  process.exitCode = EXIT_CODES.engine;
466
946
  return;
@@ -477,6 +957,12 @@ async function main(argv) {
477
957
  process.removeListener("SIGTERM", cancel);
478
958
  }
479
959
  }
960
+ async function runValidateRequest(agent, request, values, signal) {
961
+ const result = await agent.validate(request, { signal });
962
+ await reportDiagnostics(values, result.warnings, values.input ?? "<request>");
963
+ const { request: resolved, ...rest } = result;
964
+ await emit({ ...rest, dryRun: true, request: resolved });
965
+ }
480
966
  function isArgumentError(error) {
481
967
  return (error instanceof Error &&
482
968
  "code" in error &&
@@ -493,7 +979,7 @@ catch (error) {
493
979
  message: error.message,
494
980
  path: "$flags",
495
981
  retryable: false,
496
- hint: "Run kongyoroid --help.",
982
+ hint: "Run kongyoroid --help, or kongyoroid <command> --help for the accepted flags.",
497
983
  }
498
984
  : asKongyoroidError(error).toJSON();
499
985
  process.exitCode = exitCodeOf(new KongyoroidError(data));