@kongyo2/kongyoroid 2.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 (60) hide show
  1. package/README.md +14 -8
  2. package/dist/cache.d.ts +1 -1
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/cache.js +3 -2
  5. package/dist/cache.js.map +1 -1
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +17 -7
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli.js +194 -74
  10. package/dist/cli.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/io.d.ts +1 -0
  16. package/dist/io.d.ts.map +1 -1
  17. package/dist/io.js +13 -2
  18. package/dist/io.js.map +1 -1
  19. package/dist/kongyoroid.d.ts +2 -2
  20. package/dist/kongyoroid.d.ts.map +1 -1
  21. package/dist/kongyoroid.js +15 -6
  22. package/dist/kongyoroid.js.map +1 -1
  23. package/dist/player.d.ts.map +1 -1
  24. package/dist/player.js +18 -22
  25. package/dist/player.js.map +1 -1
  26. package/dist/schema.js +1 -1
  27. package/dist/schema.js.map +1 -1
  28. package/dist/song/mml.js +1 -1
  29. package/dist/song/mml.js.map +1 -1
  30. package/dist/synth/prosody.d.ts +8 -0
  31. package/dist/synth/prosody.d.ts.map +1 -1
  32. package/dist/synth/prosody.js +74 -0
  33. package/dist/synth/prosody.js.map +1 -1
  34. package/dist/synth/speech.d.ts.map +1 -1
  35. package/dist/synth/speech.js +15 -11
  36. package/dist/synth/speech.js.map +1 -1
  37. package/dist/synth/stream.js +3 -3
  38. package/dist/synth/stream.js.map +1 -1
  39. package/dist/synth/textstream.d.ts +1 -1
  40. package/dist/synth/textstream.d.ts.map +1 -1
  41. package/dist/synth/textstream.js +102 -20
  42. package/dist/synth/textstream.js.map +1 -1
  43. package/dist/text/frontend.d.ts +1 -0
  44. package/dist/text/frontend.d.ts.map +1 -1
  45. package/dist/text/frontend.js +10 -1
  46. package/dist/text/frontend.js.map +1 -1
  47. package/dist/text/normalize.d.ts.map +1 -1
  48. package/dist/text/normalize.js +8 -0
  49. package/dist/text/normalize.js.map +1 -1
  50. package/dist/text/sentences.d.ts +1 -0
  51. package/dist/text/sentences.d.ts.map +1 -1
  52. package/dist/text/sentences.js +14 -7
  53. package/dist/text/sentences.js.map +1 -1
  54. package/dist/version.js +2 -2
  55. package/dist/wav.d.ts +1 -0
  56. package/dist/wav.d.ts.map +1 -1
  57. package/dist/wav.js +8 -6
  58. package/dist/wav.js.map +1 -1
  59. package/docs/cli.md +29 -19
  60. package/package.json +1 -3
package/dist/cli.js CHANGED
@@ -5,21 +5,21 @@ import { commandHelp, rootHelp } from "./cli/help.js";
5
5
  import { ALLOWED, assign, checkAllowed, env, numberFlag, parseCommandLine, parseDictEntries, styleFlag, } from "./cli/flags.js";
6
6
  import { DiskCache } from "./cache.js";
7
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";
9
- import { LIMITS } from "./limits.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";
10
10
  import { Kongyoroid } from "./kongyoroid.js";
11
11
  import { playWav } from "./player.js";
12
12
  import { parseEngine, parseJson } from "./request.js";
13
13
  import { BATCH_JOB_SCHEMA, CAPABILITIES, DICTIONARY_SCHEMA, REQUEST_SCHEMA } from "./schema.js";
14
14
  import { pcm16Bytes, streamingWavHeader } from "./synth/stream.js";
15
15
  import { LocalDictionary } from "./text/dictionary.js";
16
- import { isObject, keys, literal, object, string } from "./validate.js";
16
+ import { integer, isObject, keys, literal, object, string } from "./validate.js";
17
17
  import { PACKAGE_NAME, VERSION } from "./version.js";
18
18
  import { wavHeader } from "./wav.js";
19
19
  async function emit(value, error = false) {
20
20
  await writeStream(error ? process.stderr : process.stdout, `${JSON.stringify(value)}\n`);
21
21
  }
22
- function metadata(result) {
22
+ function metadata(result, extraWarnings = []) {
23
23
  return {
24
24
  engine: result.engine,
25
25
  engineVersion: result.engineVersion,
@@ -39,21 +39,30 @@ function metadata(result) {
39
39
  ...(result.peak === undefined ? {} : { peak: result.peak, rms: result.rms, limitedSamples: result.limitedSamples }),
40
40
  elapsedMs: result.elapsedMs,
41
41
  timings: result.timings,
42
- warnings: result.warnings,
42
+ warnings: extraWarnings.length === 0 ? result.warnings : [...result.warnings, ...extraWarnings],
43
43
  adjustments: result.adjustments,
44
44
  };
45
45
  }
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
+ };
46
53
  async function reportDiagnostics(values, warnings, location) {
47
54
  if (values.diagnostics !== "compact")
48
55
  return;
49
56
  for (const warning of warnings)
50
57
  await writeStream(process.stderr, `${formatDiagnostic(warning, location)}\n`);
51
58
  }
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);
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);
55
64
  }
56
- await reportDiagnostics(values, result.warnings, values.input ?? "<request>");
65
+ await reportDiagnostics(values, [...result.warnings, ...extraWarnings], values.input ?? "<request>");
57
66
  if (values.output === "-") {
58
67
  if (values.play)
59
68
  invalid("$flags.play", "--play cannot be combined with --output -.", {
@@ -63,8 +72,8 @@ async function deliver(agent, result, values, signal, plan) {
63
72
  return {
64
73
  ok: true,
65
74
  output: "stdout",
66
- ...metadata(result),
67
- ...(plan === undefined || values["plan-out"] !== "-" ? {} : { plan }),
75
+ ...metadata(result, extraWarnings),
76
+ ...(plan === undefined || planOut !== "-" ? {} : { plan }),
68
77
  };
69
78
  }
70
79
  const path = values.output ?? `kongyoroid-${result.kind}-${result.sha256.slice(0, 12)}.wav`;
@@ -75,28 +84,45 @@ async function deliver(agent, result, values, signal, plan) {
75
84
  path: written.path,
76
85
  written: written.written,
77
86
  unchanged: written.unchanged,
78
- ...metadata(result),
87
+ ...metadata(result, extraWarnings),
79
88
  ...(played === undefined ? {} : { player: played.player }),
80
- ...(plan === undefined || values["plan-out"] !== "-" ? {} : { plan }),
89
+ ...(plan === undefined || planOut !== "-" ? {} : { plan }),
81
90
  };
82
91
  }
83
92
  function speechRequestFromFlags(values, text) {
84
- 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" };
85
111
  assign(request, "engine", values.engine);
86
112
  assign(request, "voice", values.voice);
87
113
  assign(request, "kana", values.kana);
88
114
  const entries = parseDictEntries(values);
89
- if (entries.length > 0)
90
- request["dictionary"] = entries;
115
+ if (entries.length > 0) {
116
+ const existing = Array.isArray(base["dictionary"]) ? base["dictionary"] : [];
117
+ request["dictionary"] = [...existing, ...entries];
118
+ }
91
119
  if (values["strict-reading"] === false)
92
120
  request["strictReading"] = false;
93
121
  assign(request, "speaker", styleFlag(values, "speaker"));
94
122
  assign(request, "speed", numberFlag(values, "speed"));
95
- assign(request, "pitch", numberFlag(values, "pitch"));
96
- assign(request, "pitchSemitones", numberFlag(values, "pitch-semitones"));
123
+ assignExclusive(request, values, { flag: "pitch", key: "pitch" }, { flag: "pitch-semitones", key: "pitchSemitones" });
97
124
  assign(request, "intonation", numberFlag(values, "intonation"));
98
- assign(request, "volume", numberFlag(values, "volume"));
99
- assign(request, "gainDb", numberFlag(values, "gain-db"));
125
+ assignExclusive(request, values, { flag: "volume", key: "volume" }, { flag: "gain-db", key: "gainDb" });
100
126
  assign(request, "breathiness", numberFlag(values, "breathiness"));
101
127
  assign(request, "prePause", numberFlag(values, "pre-pause"));
102
128
  assign(request, "postPause", numberFlag(values, "post-pause"));
@@ -109,6 +135,30 @@ function speechRequestFromFlags(values, text) {
109
135
  assign(request, "seed", numberFlag(values, "seed"));
110
136
  return request;
111
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
+ }
112
162
  function songRequestFromFlags(values, base) {
113
163
  const request = { ...base, kind: "song" };
114
164
  if (values.mml !== undefined) {
@@ -130,29 +180,20 @@ function songRequestFromFlags(values, base) {
130
180
  hint: 'Example: --lyrics "ドレミ" --melody "C4 D4 E4" --beats "1 1 2"',
131
181
  });
132
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
+ });
187
+ }
133
188
  assign(request, "engine", values.engine);
134
189
  assign(request, "voice", values.voice);
135
190
  assign(request, "tempo", numberFlag(values, "tempo"));
136
191
  assign(request, "singer", styleFlag(values, "singer"));
137
192
  assign(request, "teacher", styleFlag(values, "teacher"));
138
193
  assign(request, "transpose", numberFlag(values, "transpose"));
139
- assign(request, "volume", numberFlag(values, "volume"));
140
- assign(request, "gainDb", numberFlag(values, "gain-db"));
194
+ assignExclusive(request, values, { flag: "volume", key: "volume" }, { flag: "gain-db", key: "gainDb" });
141
195
  assign(request, "breathiness", numberFlag(values, "breathiness"));
142
- assign(request, "vibratoDepth", numberFlag(values, "vibrato-depth"));
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
- }
196
+ applyVibratoFlags(request, values, base);
156
197
  assign(request, "portamentoMs", numberFlag(values, "portamento-ms"));
157
198
  assign(request, "scoopCents", numberFlag(values, "scoop-cents"));
158
199
  assign(request, "scoopMs", numberFlag(values, "scoop-ms"));
@@ -164,38 +205,53 @@ function songRequestFromFlags(values, base) {
164
205
  assign(request, "seed", numberFlag(values, "seed"));
165
206
  return request;
166
207
  }
167
- async function textInput(values, signal) {
208
+ function rejectTextAndInput(values) {
168
209
  if (values.text !== undefined && values.input !== undefined)
169
210
  invalid("$flags", "Use either --text or --input, not both.");
211
+ }
212
+ async function textInput(values, signal) {
213
+ rejectTextAndInput(values);
170
214
  if (values.text !== undefined)
171
215
  return values.text;
172
216
  if (values.input !== undefined)
173
217
  return (await readText(values.input, signal)).replace(/\r?\n$/u, "");
218
+ if (values.kana !== undefined)
219
+ return values.kana;
174
220
  return invalid("$flags.text", "Provide --text or --input FILE (use - for standard input).", {
175
221
  hint: 'Example: kongyoroid speak --text "こんにちは" -o hello.wav',
176
222
  });
177
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;
236
+ }
178
237
  async function requestFromInputs(values, signal, command) {
238
+ rejectTextAndInput(values);
179
239
  const hasSpeech = values.text !== undefined || values.kana !== undefined;
180
240
  const hasSong = values.lyrics !== undefined || values.melody !== undefined || values.mml !== undefined;
181
241
  if (values.input !== undefined) {
182
242
  const raw = await readText(values.input, signal);
183
- const trimmed = raw.trim();
184
- if (hasSpeech && !hasSong && !trimmed.startsWith("{"))
243
+ const looksLikeJson = raw.trim().startsWith("{");
244
+ if (!looksLikeJson && !hasSong)
185
245
  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;
246
+ return overlayRequest(values, parseJson(raw));
192
247
  }
193
248
  if (hasSong)
194
249
  return songRequestFromFlags(values, {});
195
- if (hasSpeech || values.text !== undefined)
250
+ if (hasSpeech)
196
251
  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 "こんにちは"`,
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`,
199
255
  });
200
256
  }
201
257
  async function loadDictionaryFile(path, tolerateMissing) {
@@ -264,33 +320,84 @@ async function runPlan(agent, values, signal) {
264
320
  const result = await agent.plan(request, { signal, detail: planDetail(values) });
265
321
  await emit(result);
266
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
+ }
267
375
  async function runSpeakStream(agent, values, signal) {
268
376
  if (values.play)
269
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);
270
383
  const format = literal(values.format ?? "wav", "$flags.format", ["wav", "pcm", "ndjson"]);
271
384
  const output = values.output ?? "-";
272
385
  if (output !== "-" && format !== "wav")
273
386
  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 });
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 }) });
282
391
  const stdout = process.stdout;
283
392
  let sampleRate = 0;
284
393
  let file;
285
394
  let fileFrames = 0;
286
- let fileBytes = 0;
287
- let filePath = "";
395
+ const filePath = output === "-" ? "" : resolve(output);
288
396
  const sentences = [];
289
397
  const openFile = async () => {
290
- if (output === "-")
398
+ if (output === "-" || file !== undefined)
291
399
  return;
292
400
  const { open } = await import("node:fs/promises");
293
- filePath = resolve(output);
294
401
  if (!values.force) {
295
402
  const existing = await existingFileSha256(filePath);
296
403
  if (existing !== undefined)
@@ -299,11 +406,13 @@ async function runSpeakStream(agent, values, signal) {
299
406
  repairOptions: [{ action: "use-force", description: "Overwrite with --force." }],
300
407
  });
301
408
  }
409
+ if (sampleRate === 0)
410
+ sampleRate = numberFlag(values, "sample-rate") ?? DEFAULT_SAMPLE_RATE;
302
411
  file = await open(filePath, "w", 0o644);
303
412
  await file.write(wavHeader(0, sampleRate));
304
- fileBytes = 44;
305
413
  };
306
414
  let headerWritten = false;
415
+ let completed = false;
307
416
  try {
308
417
  for await (const event of events) {
309
418
  if (event.type === "sentence") {
@@ -333,7 +442,6 @@ async function runSpeakStream(agent, values, signal) {
333
442
  if (file !== undefined) {
334
443
  await file.write(bytes);
335
444
  fileFrames += block.samples.length;
336
- fileBytes += bytes.length;
337
445
  continue;
338
446
  }
339
447
  if (format === "ndjson") {
@@ -347,12 +455,16 @@ async function runSpeakStream(agent, values, signal) {
347
455
  await writeWithBackpressure(stdout, bytes, signal);
348
456
  continue;
349
457
  }
458
+ completed = true;
459
+ if (output !== "-")
460
+ await openFile();
350
461
  const summary = {
351
462
  ok: true,
352
463
  operation: "speak",
353
464
  stream: true,
354
465
  format,
355
466
  output: output === "-" ? "stdout" : filePath,
467
+ ...(output === "-" ? {} : { written: true }),
356
468
  sentences: event.sentences,
357
469
  frames: event.frames,
358
470
  durationSeconds: Math.round(event.durationSeconds * 1000) / 1000,
@@ -370,8 +482,8 @@ async function runSpeakStream(agent, values, signal) {
370
482
  const header = wavHeader(fileFrames, sampleRate);
371
483
  await file.write(header, 0, 44, 0);
372
484
  await file.close();
373
- void fileBytes;
374
485
  }
486
+ void completed;
375
487
  }
376
488
  }
377
489
  async function runBatch(agent, values, signal, onFailure) {
@@ -598,6 +710,13 @@ async function runCache(values, positionals) {
598
710
  await emit({ ok: true, operation: "cache.stats", ...(await cache.stats()) });
599
711
  return;
600
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
+ }
601
720
  const before = await cache.stats();
602
721
  if (values["dry-run"]) {
603
722
  await emit({
@@ -605,14 +724,12 @@ async function runCache(values, positionals) {
605
724
  operation: `cache.${action}`,
606
725
  dryRun: true,
607
726
  directory: cache.directory,
608
- wouldRemove: action === "clear" ? before.entries : null,
727
+ wouldRemove: action === "clear" ? before.entries : await cache.prune(maxBytes, maxEntries, true),
609
728
  ...before,
610
729
  });
611
730
  return;
612
731
  }
613
- const removed = action === "clear"
614
- ? await cache.clear()
615
- : await cache.prune(numberFlag(values, "max-bytes"), numberFlag(values, "max-entries"));
732
+ const removed = action === "clear" ? await cache.clear() : await cache.prune(maxBytes, maxEntries);
616
733
  await emit({ ok: true, operation: `cache.${action}`, directory: cache.directory, removed, ...(await cache.stats()) });
617
734
  }
618
735
  async function main(argv) {
@@ -665,8 +782,9 @@ async function main(argv) {
665
782
  const signal = controller.signal;
666
783
  try {
667
784
  if (command === "play") {
668
- const path = string(positionals[1], "$.play.file", 1, 4096);
669
- await emit({ ok: true, operation: "play", 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)) });
670
788
  return;
671
789
  }
672
790
  if (command === "inspect") {
@@ -689,11 +807,13 @@ async function main(argv) {
689
807
  const agent = await createAgent(values, command === "dict");
690
808
  switch (command) {
691
809
  case "speak": {
692
- if (values.stream) {
810
+ if (values.stream && !values["dry-run"]) {
693
811
  await runSpeakStream(agent, values, signal);
694
812
  return;
695
813
  }
696
- const request = speechRequestFromFlags(values, await textInput(values, signal));
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.");
697
817
  if (values["dry-run"]) {
698
818
  await runValidateRequest(agent, request, values, signal);
699
819
  return;
@@ -702,7 +822,7 @@ async function main(argv) {
702
822
  const plan = values["plan-out"] === undefined || result.engine !== "formant"
703
823
  ? undefined
704
824
  : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
705
- await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
825
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
706
826
  return;
707
827
  }
708
828
  case "sing": {
@@ -718,7 +838,7 @@ async function main(argv) {
718
838
  const plan = values["plan-out"] === undefined || result.engine !== "formant"
719
839
  ? undefined
720
840
  : (await agent.plan(request, { signal, detail: "phonemes" })).plan;
721
- await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
841
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
722
842
  return;
723
843
  }
724
844
  case "render": {
@@ -739,7 +859,7 @@ async function main(argv) {
739
859
  const plan = values["plan-out"] === undefined || result.engine !== "formant"
740
860
  ? undefined
741
861
  : (await agent.plan(withFlags, { signal, detail: "phonemes" })).plan;
742
- await emit(await deliver(agent, result, values, signal, plan), values.output === "-");
862
+ await emit(await deliver(result, values, signal, plan), values.output === "-");
743
863
  return;
744
864
  }
745
865
  case "validate":