@drakulavich/kesha-voice-kit 1.6.1 → 1.8.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/README.md CHANGED
@@ -69,6 +69,36 @@ kesha say "Hello, world" > hello.wav
69
69
  kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
70
70
  ```
71
71
 
72
+ **Russian abbreviations** (`ru-vosk-*` voices):
73
+
74
+ ```bash
75
+ # Auto-detect on by default — ФСБ reads as "эф-эс-бэ"
76
+ kesha say --voice ru-vosk-m02 'ФСБ объявила решение.'
77
+
78
+ # Force a literal reading (Vosk reads as "фсб")
79
+ kesha say --voice ru-vosk-m02 --no-expand-abbrev 'ФСБ.'
80
+
81
+ # Explicit SSML control (overrides the rule and the stop-list)
82
+ kesha say --voice ru-vosk-m02 --ssml \
83
+ '<speak><say-as interpret-as="characters">КОТ</say-as></speak>'
84
+ ```
85
+
86
+ Detection rule: auto-expand fires when the token cannot be pronounced as a natural Russian syllable — length ≤ 2 (ИП, ЕС), 0 vowels (ФСБ, СНГ), 2+ consecutive vowels (ОАЭ), or 2+ consecutive consonants (США, ЦСКА). Tokens with strict CVC/CVCV alternation pass through (ВОЗ → "воз", НАТО → "нато", ОПЕК → "опек"). Small stop-list for common short words (ОН, МЫ, ВЫ, КАК, ЧТО, …) and Ъ/Ь-containing tokens are always skipped. See [#232](https://github.com/drakulavich/kesha-voice-kit/issues/232).
87
+
88
+ **Russian word stress** (`ru-vosk-*` voices):
89
+
90
+ ```bash
91
+ # Caller provides `+` before the stressed vowel; engine passes it to Vosk
92
+ kesha say --voice ru-vosk-m02 --ssml \
93
+ '<speak><emphasis>дом+а</emphasis></speak>' # genitive до-МА́
94
+
95
+ # Suppress an inherited <emphasis> with level="none"
96
+ kesha say --voice ru-vosk-m02 --ssml \
97
+ '<speak><emphasis level="none">дом+а</emphasis></speak>' # default ДО́ма
98
+ ```
99
+
100
+ Vosk-TTS 0.9-multi honors a `+` placed BEFORE the target stressed vowel — but only when the marker shifts stress AWAY from the model's default (first-syllable). `+` agreeing with the default is a no-op. See [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
101
+
72
102
  macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
73
103
 
74
104
  ## Performance
package/SKILL.md CHANGED
@@ -74,6 +74,10 @@ kesha say "Hi" --format ogg-opus --bitrate 16000 --out tiny.ogg # tin
74
74
 
75
75
  Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OGG/Opus). `--bitrate` (6 000–510 000 bps) and `--sample-rate` (8 000 / 12 000 / 16 000 / 24 000 / 48 000 Hz) tune the encoder.
76
76
 
77
+ **Russian abbreviation handling** (`ru-vosk-*` only): all-uppercase Cyrillic tokens (length 2–5) auto-expand letter-by-letter when the token cannot be pronounced as a natural Russian syllable — `ФСБ объявила` → `эф-эс-бэ объявила`. Tokens with strict CVC/CVCV alternation pass through (ВОЗ → "воз", НАТО → "нато"). Disable per call with `--no-expand-abbrev`. Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always wins, even with `--no-expand-abbrev`). Stop-list for common short words (ОН, МЫ, ВЫ, КАК, ЧТО) prevents false positives. Closes [#232](https://github.com/drakulavich/kesha-voice-kit/issues/232).
78
+
79
+ **Russian word stress** (`ru-vosk-*` only): `<emphasis>сл+ово</emphasis>` shifts stress to the vowel marked with `+`. `<emphasis level="none">сл+ово</emphasis>` strips the `+` (cancel inherited emphasis). Other voices (`en-*`, `macos-*`) silently strip the `+` and warn once per process. Auto-stress dictionary not provided — caller writes the `+` manually. Closes [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
80
+
77
81
  ## Language detection standalone
78
82
 
79
83
  `kesha --json audio.ogg` includes both audio-based (`audioLanguage`) and text-based (`textLanguage`) detection. Use audio detection to identify the language before running language-specific logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.6.1",
3
+ "version": "1.8.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.6.0"
33
+ "version": "1.8.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
package/src/cli/say.ts CHANGED
@@ -82,6 +82,12 @@ export const sayCommand = defineCommand({
82
82
  description:
83
83
  "Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.",
84
84
  },
85
+ "no-expand-abbrev": {
86
+ type: "boolean",
87
+ description:
88
+ "Disable Russian acronym auto-expansion (ВОЗ → 'вэ о зэ') for ru-vosk-* voices. " +
89
+ "<say-as interpret-as='characters'> still works. No effect for non-ru-vosk voices.",
90
+ },
85
91
  verbose: {
86
92
  type: "boolean",
87
93
  description: "Log TTS synthesis time to stderr",
@@ -148,6 +154,7 @@ export const sayCommand = defineCommand({
148
154
  format,
149
155
  bitrate: args.bitrate ? Number(args.bitrate) : undefined,
150
156
  sampleRate: args["sample-rate"] ? Number(args["sample-rate"]) : undefined,
157
+ noExpandAbbrev: Boolean(args["no-expand-abbrev"]),
151
158
  };
152
159
 
153
160
  try {
package/src/say.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getEngineBinPath, isEngineInstalled } from "./engine";
1
+ import { getEngineBinPath, isEngineInstalled, getEngineCapabilities, type EngineCapabilities } from "./engine";
2
2
  import { log } from "./log";
3
3
 
4
4
  /**
@@ -38,10 +38,17 @@ export interface SayOptions {
38
38
  * Must be one of 8000, 12000, 16000, 24000, 48000. Default 24000.
39
39
  */
40
40
  sampleRate?: number;
41
+ /**
42
+ * Disable Russian acronym auto-expansion for `ru-vosk-*` voices.
43
+ * When true, passes `--no-expand-abbrev` to the engine (requires engine
44
+ * capability `tts.ru_acronym_expansion`; silently dropped for older engines).
45
+ * `<say-as interpret-as="characters">` still works regardless of this flag.
46
+ */
47
+ noExpandAbbrev?: boolean;
41
48
  }
42
49
 
43
50
  /** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
44
- export function buildSayArgs(o: SayOptions): string[] {
51
+ export function buildSayArgs(o: SayOptions, capabilities?: EngineCapabilities | null): string[] {
45
52
  const args: string[] = ["say"];
46
53
  if (o.voice) args.push("--voice", o.voice);
47
54
  if (o.lang) args.push("--lang", o.lang);
@@ -51,6 +58,16 @@ export function buildSayArgs(o: SayOptions): string[] {
51
58
  if (o.format) args.push("--format", o.format);
52
59
  if (o.bitrate !== undefined) args.push("--bitrate", String(o.bitrate));
53
60
  if (o.sampleRate !== undefined) args.push("--sample-rate", String(o.sampleRate));
61
+ if (o.noExpandAbbrev) {
62
+ const supportsRuExpand = capabilities?.features?.includes("tts.ru_acronym_expansion") ?? false;
63
+ if (supportsRuExpand) {
64
+ args.push("--no-expand-abbrev");
65
+ } else {
66
+ log.debug(
67
+ "kesha-engine does not advertise tts.ru_acronym_expansion; dropping --no-expand-abbrev",
68
+ );
69
+ }
70
+ }
54
71
  if (o.text !== undefined && o.text.length > 0) args.push(o.text);
55
72
  return args;
56
73
  }
@@ -78,7 +95,8 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
78
95
  "",
79
96
  );
80
97
  }
81
- const args = buildSayArgs({ ...opts, text: undefined });
98
+ const capabilities = opts.noExpandAbbrev ? await getEngineCapabilities() : null;
99
+ const args = buildSayArgs({ ...opts, text: undefined }, capabilities);
82
100
  const startedAt = performance.now();
83
101
  log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
84
102
  const proc = Bun.spawn([getEngineBinPath(), ...args], {