@drakulavich/kesha-voice-kit 1.5.0 → 1.6.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.
package/NOTICES.md CHANGED
@@ -29,7 +29,9 @@ Text-to-speech (English). Apache 2.0. ONNX export by [onnx-community/Kokoro-82M-
29
29
 
30
30
  ### Vosk-TTS (Russian, multi-speaker)
31
31
 
32
- Text-to-speech (Russian, 5 baked-in speakers). Apache 2.0. Model: [alphacep/vosk-tts](https://huggingface.co/alphacep/vosk-tts) (`vosk-model-tts-ru-0.9-multi`). Integrated via [`vosk-tts-rs`](https://github.com/alphacep/vosk-tts-rs).
32
+ Text-to-speech (Russian, 5 baked-in speakers). Apache 2.0. Model: [alphacep/vosk-tts](https://huggingface.co/alphacep/vosk-tts) (`vosk-model-tts-ru-0.9-multi`).
33
+
34
+ **Runtime code:** Originally pulled from `crates.io` as `vosk-tts-rs 0.1.0`. As of #216 the runtime subset is **vendored** under `rust/vendor/vosk-tts/` (Apache 2.0; copied from [`andreytkachenko/vosk-tts-rs`](https://github.com/andreytkachenko/vosk-tts-rs) — see `rust/vendor/vosk-tts/LICENSE` and `rust/vendor/vosk-tts/NOTICE`). The vendored copy drops the upstream gRPC server/CLI/HTTP-model-fetch modules and replaces the `tokenizers` crate with a small inline BERT WordPiece tokenizer, eliminating the `prost`, `tonic`, `tokio`, `axum`, `hyper`, `reqwest`, `tokenizers`, `onig_sys`, `esaxx-rs`, `bzip2-sys`, `lzma-sys`, and `zstd-sys` transitive dependencies that broke Windows MSVC linkage.
33
35
 
34
36
  ### misaki-rs (English G2P)
35
37
 
package/README.md CHANGED
@@ -117,8 +117,7 @@ const text = await transcribe("audio.ogg"); // transcribe
117
117
  ## Requirements
118
118
 
119
119
  - [Bun](https://bun.sh) >= 1.3
120
- - macOS arm64 or Linux x64
121
- - Windows x64 temporarily unsupported in v1.5.0 — Vosk-TTS native deps trigger MSVC link conflicts. Tracked in [#216](https://github.com/drakulavich/kesha-voice-kit/issues/216).
120
+ - macOS arm64, Linux x64, or Windows x64
122
121
 
123
122
  ## Contributing
124
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  ]
31
31
  },
32
32
  "keshaEngine": {
33
- "version": "1.5.0"
33
+ "version": "1.6.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
@@ -58,7 +58,7 @@
58
58
  "type": "git",
59
59
  "url": "git+https://github.com/drakulavich/kesha-voice-kit.git"
60
60
  },
61
- "homepage": "https://github.com/drakulavich/kesha-voice-kit#readme",
61
+ "homepage": "https://drakulavich.github.io/kesha-voice-kit/",
62
62
  "bugs": {
63
63
  "url": "https://github.com/drakulavich/kesha-voice-kit/issues"
64
64
  },
package/src/cli/say.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
3
  import { log } from "../log";
4
- import { say, SayError } from "../say";
4
+ import { say, SayError, type SayFormat } from "../say";
5
5
 
6
6
  /**
7
7
  * Darwin defaults to AVSpeech Milena — zero install, no model download required.
@@ -54,19 +54,34 @@ async function resolveText(inline: string | undefined): Promise<string> {
54
54
  export const sayCommand = defineCommand({
55
55
  meta: {
56
56
  name: "say",
57
- description: "Synthesize speech from text (TTS). Writes WAV to stdout (or --out file).",
57
+ description:
58
+ "Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.",
58
59
  },
59
60
  args: {
60
61
  text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
61
62
  voice: { type: "string", description: "Voice id, e.g. en-am_michael" },
62
63
  lang: { type: "string", description: "BCP 47 language code (default en-us)" },
63
- out: { type: "string", description: "Write WAV to file instead of stdout" },
64
+ out: { type: "string", description: "Write audio to file instead of stdout" },
64
65
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
65
66
  "list-voices": { type: "boolean", description: "List installed voices and exit" },
66
67
  ssml: {
67
68
  type: "boolean",
68
69
  description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
69
70
  },
71
+ format: {
72
+ type: "string",
73
+ description:
74
+ "Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.",
75
+ },
76
+ bitrate: {
77
+ type: "string",
78
+ description: "Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.",
79
+ },
80
+ "sample-rate": {
81
+ type: "string",
82
+ description:
83
+ "Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.",
84
+ },
70
85
  verbose: {
71
86
  type: "boolean",
72
87
  description: "Log TTS synthesis time to stderr",
@@ -94,6 +109,35 @@ export const sayCommand = defineCommand({
94
109
  const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
95
110
  const voice = explicitVoice ?? (await autoRouteVoice(text));
96
111
 
112
+ // Validate --format up front so we surface a clear error before spawning
113
+ // the engine subprocess. The engine repeats the check authoritatively, but
114
+ // catching it here gives the user a faster failure mode in scripts.
115
+ const fmtArg = typeof args.format === "string" ? args.format.toLowerCase() : undefined;
116
+ let format: SayFormat | undefined;
117
+ if (fmtArg) {
118
+ if (fmtArg === "wav" || fmtArg === "ogg-opus") {
119
+ format = fmtArg;
120
+ } else if (fmtArg === "opus" || fmtArg === "ogg") {
121
+ format = "ogg-opus";
122
+ } else {
123
+ log.error(`unknown --format '${args.format}'. supported: wav, ogg-opus`);
124
+ process.exit(2);
125
+ }
126
+ }
127
+
128
+ // Reject --bitrate / --sample-rate with WAV up front to surface the error fast.
129
+ const hasOpusOnlyFlag = Boolean(args.bitrate) || Boolean(args["sample-rate"]);
130
+ if (hasOpusOnlyFlag) {
131
+ const outExt = typeof args.out === "string"
132
+ ? args.out.split(".").pop()?.toLowerCase()
133
+ : undefined;
134
+ const impliesOpus = outExt && ["ogg", "opus", "oga"].includes(outExt);
135
+ if (format === "wav" || (format === undefined && !impliesOpus)) {
136
+ log.error("--bitrate and --sample-rate are only valid with --format ogg-opus");
137
+ process.exit(2);
138
+ }
139
+ }
140
+
97
141
  const opts = {
98
142
  text,
99
143
  voice,
@@ -101,18 +145,21 @@ export const sayCommand = defineCommand({
101
145
  out: typeof args.out === "string" ? args.out : undefined,
102
146
  rate: args.rate ? Number(args.rate) : undefined,
103
147
  ssml: Boolean(args.ssml),
148
+ format,
149
+ bitrate: args.bitrate ? Number(args.bitrate) : undefined,
150
+ sampleRate: args["sample-rate"] ? Number(args["sample-rate"]) : undefined,
104
151
  };
105
152
 
106
153
  try {
107
154
  const startedAt = performance.now();
108
- const wav = await say(opts);
155
+ const audio = await say(opts);
109
156
  const ttsTimeMs = Math.round(performance.now() - startedAt);
110
157
  if (args.verbose) {
111
- // stderr — stdout may carry raw WAV bytes when --out is omitted.
158
+ // stderr — stdout may carry raw audio bytes when --out is omitted.
112
159
  console.error(`TTS time: ${ttsTimeMs}ms`);
113
160
  }
114
161
  if (!opts.out) {
115
- process.stdout.write(wav);
162
+ process.stdout.write(audio);
116
163
  }
117
164
  } catch (err) {
118
165
  if (err instanceof SayError) {
package/src/say.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { getEngineBinPath, isEngineInstalled } from "./engine";
2
2
  import { log } from "./log";
3
3
 
4
+ /**
5
+ * Wire format for the synthesized audio. Matches the engine's `--format` flag.
6
+ * - `wav` (default): RIFF WAV at the engine's native sample rate.
7
+ * - `ogg-opus`: OGG-encapsulated Opus, mono. The format Telegram, WhatsApp,
8
+ * Signal, and Discord render as native voice messages. See #223.
9
+ */
10
+ export type SayFormat = "wav" | "ogg-opus";
11
+
4
12
  export interface SayOptions {
5
13
  /**
6
14
  * Text to synthesize. Required for programmatic callers — `say()` does not
@@ -18,6 +26,18 @@ export interface SayOptions {
18
26
  rate?: number;
19
27
  /** Parse `text` as SSML (`<speak>…<break time="500ms"/>…</speak>`). See issue #122. */
20
28
  ssml?: boolean;
29
+ /**
30
+ * Output audio format. Defaults to `wav` (or inferred from the `out`
31
+ * extension when omitted: `.wav` → wav, `.ogg`/`.opus` → ogg-opus).
32
+ */
33
+ format?: SayFormat;
34
+ /** Opus bitrate in bits/second. Only valid with `format: "ogg-opus"`. Default 32000. */
35
+ bitrate?: number;
36
+ /**
37
+ * Encoder sample rate in Hz. Only valid with `format: "ogg-opus"`.
38
+ * Must be one of 8000, 12000, 16000, 24000, 48000. Default 24000.
39
+ */
40
+ sampleRate?: number;
21
41
  }
22
42
 
23
43
  /** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
@@ -28,6 +48,9 @@ export function buildSayArgs(o: SayOptions): string[] {
28
48
  if (o.out) args.push("--out", o.out);
29
49
  if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
30
50
  if (o.ssml) args.push("--ssml");
51
+ if (o.format) args.push("--format", o.format);
52
+ if (o.bitrate !== undefined) args.push("--bitrate", String(o.bitrate));
53
+ if (o.sampleRate !== undefined) args.push("--sample-rate", String(o.sampleRate));
31
54
  if (o.text !== undefined && o.text.length > 0) args.push(o.text);
32
55
  return args;
33
56
  }