@drakulavich/kesha-voice-kit 1.8.2 → 1.10.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
@@ -38,6 +38,7 @@ Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-m
38
38
  kesha audio.ogg # transcribe (plain text)
39
39
  kesha --format transcript audio.ogg # text + language/confidence
40
40
  kesha --format json audio.ogg # full JSON with lang fields
41
+ kesha --json --timestamps audio.ogg # JSON with timestamped segments
41
42
  kesha --toon audio.ogg # compact LLM-friendly TOON
42
43
  kesha --verbose audio.ogg # show language detection details
43
44
  kesha --lang en audio.ogg # warn if detected language differs
@@ -85,6 +86,27 @@ kesha say --voice ru-vosk-m02 --ssml \
85
86
 
86
87
  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
 
89
+ **English acronyms** (`en-*` voices, Kokoro-82M):
90
+
91
+ ```bash
92
+ # Auto-expand on by default — FBI reads as "ef bee eye"
93
+ kesha say --voice en-am_michael 'The FBI is investigating.'
94
+
95
+ # Force a literal reading (Kokoro fuses unknown caps tokens to one syllable)
96
+ kesha say --voice en-am_michael --no-expand-abbrev 'FBI.'
97
+
98
+ # Explicit SSML control (overrides the rule and the stop-list)
99
+ kesha say --voice en-am_michael --ssml \
100
+ '<speak><say-as interpret-as="characters">NASA</say-as></speak>'
101
+ ```
102
+
103
+ Three-table mechanism:
104
+ - **Letter-spell** — uppercase Latin tokens 2–5 chars (no digits, no mixed case) get expanded letter-by-letter (FBI → "ef bee eye"). Disable with `--no-expand-abbrev`.
105
+ - **`STOP_LIST`** (30 entries) — natural-English caps words pass through unchanged: NASA, NATO, AIDS, OPEC, IKEA, ASCII, NAFTA, LASER, RADAR, SCUBA + emphatic length-2 caps (OK, NO, GO, IT, IS, AS, AT, BY, IN, ON, OR, OF, TO, WE, US, MY, ME, HE, BE, DO).
106
+ - **`IPA_LEXICON`** (20 entries) — case-sensitive token → IPA-phoneme map that bypasses G2P entirely. Covers all-caps acronyms with industry pronunciations (EPAM, JSON, JPEG, SQL, ASAP, GIF, CRUD, JWT) AND mixed-case proper nouns (Anthropic, Microsoft, Claude, NVIDIA, Kubernetes, PostgreSQL, GraphQL, Linux, Tokio, macOS, Granola, OAuth). IPA hits fire even with `--no-expand-abbrev` — they're intent-explicit, parallel to `<say-as>`.
107
+
108
+ Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always letter-spells via the table). See [#244](https://github.com/drakulavich/kesha-voice-kit/issues/244).
109
+
88
110
  **Russian word stress** (`ru-vosk-*` voices):
89
111
 
90
112
  ```bash
package/SKILL.md CHANGED
@@ -76,6 +76,8 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
76
76
 
77
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
78
 
79
+ **English acronym handling** (`en-*` Kokoro-82M only). Three tables: (1) **letter-spell** for uppercase 2–5-char tokens not on the stop-list (FBI → "ef bee eye", HTTP → "aitch tee tee pee"); (2) **`STOP_LIST`** (30 entries) — natural-English caps words pass through (NASA, NATO, AIDS, OPEC, IKEA, ASCII, NAFTA, LASER, RADAR, SCUBA + 20 emphatic length-2 caps); (3) **`IPA_LEXICON`** (20 entries) — case-sensitive token → IPA-phoneme map that bypasses G2P entirely. Covers industry-pronunciation acronyms (EPAM /ˈiːpæm/, JSON /ˈdʒeɪsən/, JPEG, SQL, ASAP, GIF, CRUD, JWT, OAuth) AND mixed-case proper nouns (Anthropic /ænˈθrɒpɪk/, Microsoft, Claude, NVIDIA, Kubernetes, PostgreSQL, GraphQL, Linux, Tokio, macOS, Granola). Disable letter-spell with `--no-expand-abbrev` — IPA hits still fire (intent-explicit). Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always wins). Closes [#244](https://github.com/drakulavich/kesha-voice-kit/issues/244).
80
+
79
81
  **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
82
 
81
83
  ## Language detection standalone
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.8.2",
3
+ "version": "1.10.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.8.2"
33
+ "version": "1.10.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
package/src/cli/main.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { detect } from "tinyld";
3
- import { transcribe } from "../lib";
3
+ import { transcribeWithSegments } from "../transcribe";
4
4
  import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
5
5
  import type { LangDetectResult } from "../engine";
6
6
  import { log } from "../log";
@@ -23,6 +23,7 @@ interface MainCommandArgs {
23
23
  debug: boolean;
24
24
  vad: boolean;
25
25
  "no-vad": boolean;
26
+ timestamps: boolean;
26
27
  format?: string;
27
28
  lang?: string;
28
29
  }
@@ -57,6 +58,11 @@ export const mainCommand = defineCommand({
57
58
  description: "Output results as TOON (compact, LLM-friendly encoding of the same data as --json)",
58
59
  default: false,
59
60
  },
61
+ timestamps: {
62
+ type: "boolean",
63
+ description: "Include timestamped transcript segments in JSON/TOON output",
64
+ default: false,
65
+ },
60
66
  verbose: {
61
67
  type: "boolean",
62
68
  description: "Show language detection details",
@@ -99,6 +105,10 @@ export const mainCommand = defineCommand({
99
105
  log.error("--vad and --no-vad are mutually exclusive.");
100
106
  process.exit(2);
101
107
  }
108
+ if (args.timestamps && !(args.json || args.toon || args.format === "json")) {
109
+ log.error("--timestamps requires --json, --toon, or --format json.");
110
+ process.exit(2);
111
+ }
102
112
  const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
103
113
 
104
114
  if (files.length === 0) {
@@ -114,11 +124,12 @@ export const mainCommand = defineCommand({
114
124
  for (const file of files) {
115
125
  const startedAt = performance.now();
116
126
  try {
117
- // Run audio lang-id and transcription concurrently
118
- const [audioResult, text] = await Promise.all([
127
+ // Run audio lang-id and transcription concurrently.
128
+ const [audioResult, transcript] = await Promise.all([
119
129
  wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
120
- transcribe(file, { vad: vadMode }),
130
+ transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps }),
121
131
  ]);
132
+ const { text, segments } = transcript;
122
133
 
123
134
  let audioLanguage: LangDetectResult | undefined;
124
135
  if (audioResult && audioResult.code) {
@@ -145,14 +156,18 @@ export const mainCommand = defineCommand({
145
156
  const mismatchWarning = checkLanguageMismatch(args.lang, lang);
146
157
  if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
147
158
 
148
- results.push({
159
+ const result: TranscribeResult = {
149
160
  file,
150
161
  text,
151
162
  lang,
152
163
  audioLanguage,
153
164
  textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
154
165
  sttTimeMs: Math.round(performance.now() - startedAt),
155
- });
166
+ };
167
+ if (args.timestamps) {
168
+ result.segments = segments;
169
+ }
170
+ results.push(result);
156
171
  } catch (err: unknown) {
157
172
  hasError = true;
158
173
  const message = err instanceof Error ? err.message : String(err);
package/src/cli/say.ts CHANGED
@@ -86,7 +86,7 @@ export const sayCommand = defineCommand({
86
86
  type: "boolean",
87
87
  description:
88
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.",
89
+ "<say-as interpret-as='characters'> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.",
90
90
  },
91
91
  verbose: {
92
92
  type: "boolean",
package/src/engine.ts CHANGED
@@ -8,6 +8,17 @@ export interface LangDetectResult {
8
8
  confidence: number;
9
9
  }
10
10
 
11
+ export interface TranscriptionSegment {
12
+ start: number;
13
+ end: number;
14
+ text: string;
15
+ }
16
+
17
+ export interface TranscriptionOutput {
18
+ text: string;
19
+ segments: TranscriptionSegment[];
20
+ }
21
+
11
22
  const DEFAULT_ENGINE_BIN_PATH = join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
12
23
 
13
24
  /**
@@ -67,6 +78,57 @@ export async function transcribeEngine(
67
78
  return stdout;
68
79
  }
69
80
 
81
+ function parseTranscriptionOutput(stdout: string): TranscriptionOutput {
82
+ const parsed = JSON.parse(stdout);
83
+ if (typeof parsed?.text !== "string" || !Array.isArray(parsed?.segments)) {
84
+ throw new Error("Invalid transcription JSON returned by kesha-engine");
85
+ }
86
+
87
+ const segments = parsed.segments.map((segment: unknown) => {
88
+ const s = segment as Record<string, unknown>;
89
+ if (
90
+ typeof s.start !== "number" ||
91
+ typeof s.end !== "number" ||
92
+ typeof s.text !== "string"
93
+ ) {
94
+ throw new Error("Invalid transcription segment returned by kesha-engine");
95
+ }
96
+ return {
97
+ start: s.start,
98
+ end: s.end,
99
+ text: s.text,
100
+ };
101
+ });
102
+
103
+ return { text: parsed.text, segments };
104
+ }
105
+
106
+ export async function transcribeEngineWithSegments(
107
+ audioPath: string,
108
+ opts: TranscribeEngineOptions = {},
109
+ ): Promise<TranscriptionOutput> {
110
+ const caps = await getEngineCapabilities();
111
+ if (!caps?.features.includes("transcribe.segments")) {
112
+ throw new Error(
113
+ "Timestamped segments require a newer kesha-engine. Run `kesha install` after upgrading Kesha Voice Kit.",
114
+ );
115
+ }
116
+
117
+ const args = ["transcribe", audioPath, "--json"];
118
+ if (opts.vad === "on") args.push("--vad");
119
+ else if (opts.vad === "off") args.push("--no-vad");
120
+ const { stdout, stderr, exitCode } = await runEngine(args);
121
+ if (exitCode !== 0) {
122
+ throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
123
+ }
124
+ try {
125
+ return parseTranscriptionOutput(stdout);
126
+ } catch (err: unknown) {
127
+ const message = err instanceof Error ? err.message : String(err);
128
+ throw new Error(`${message}: ${stdout}`);
129
+ }
130
+ }
131
+
70
132
  export function parseLangResult(stdout: string): LangDetectResult | null {
71
133
  try {
72
134
  const parsed = JSON.parse(stdout);
@@ -99,12 +161,22 @@ export interface EngineCapabilities {
99
161
  features: string[];
100
162
  }
101
163
 
164
+ let cachedEngineCapabilities:
165
+ | { binPath: string; capabilities: EngineCapabilities }
166
+ | null = null;
167
+
102
168
  export async function getEngineCapabilities(): Promise<EngineCapabilities | null> {
103
- if (!isEngineInstalled()) return null;
169
+ const binPath = getEngineBinPath();
170
+ if (cachedEngineCapabilities?.binPath === binPath) {
171
+ return cachedEngineCapabilities.capabilities;
172
+ }
173
+ if (!existsSync(binPath)) return null;
104
174
  const { stdout, exitCode } = await runEngine(["--capabilities-json"]);
105
175
  if (exitCode !== 0) return null;
106
176
  try {
107
- return JSON.parse(stdout) as EngineCapabilities;
177
+ const capabilities = JSON.parse(stdout) as EngineCapabilities;
178
+ cachedEngineCapabilities = { binPath, capabilities };
179
+ return capabilities;
108
180
  } catch {
109
181
  return null;
110
182
  }
package/src/lib.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import { existsSync } from "fs";
2
- import { transcribe as internalTranscribe, type TranscribeOptions } from "./transcribe";
2
+ import {
3
+ transcribe as internalTranscribe,
4
+ transcribeWithSegments as internalTranscribeWithSegments,
5
+ type TranscribeOptions,
6
+ } from "./transcribe";
3
7
  import { downloadEngine } from "./engine-install";
4
8
 
5
9
  export type { TranscribeOptions };
10
+ export type { TranscriptionOutput, TranscriptionSegment } from "./engine";
6
11
  export { downloadEngine as downloadModel };
7
12
  export { say, type SayOptions, SayError } from "./say";
8
13
 
@@ -38,3 +43,18 @@ export async function transcribe(
38
43
 
39
44
  return internalTranscribe(audioPath, { ...options, silent: true });
40
45
  }
46
+
47
+ export async function transcribeWithSegments(
48
+ audioPath: string,
49
+ options: TranscribeOptions = {},
50
+ ) {
51
+ if (!existsSync(audioPath)) {
52
+ throw new Error(`File not found: ${audioPath}`);
53
+ }
54
+
55
+ return internalTranscribeWithSegments(audioPath, {
56
+ ...options,
57
+ timestamps: true,
58
+ silent: true,
59
+ });
60
+ }
package/src/say.ts CHANGED
@@ -39,9 +39,9 @@ export interface SayOptions {
39
39
  */
40
40
  sampleRate?: number;
41
41
  /**
42
- * Disable Russian acronym auto-expansion for `ru-vosk-*` voices.
42
+ * Disable acronym auto-expansion for `ru-vosk-*` and `en-*` voices.
43
43
  * When true, passes `--no-expand-abbrev` to the engine (requires engine
44
- * capability `tts.ru_acronym_expansion`; silently dropped for older engines).
44
+ * capability `tts.ru_acronym_expansion` or `tts.en_acronym_expansion`; silently dropped for older engines).
45
45
  * `<say-as interpret-as="characters">` still works regardless of this flag.
46
46
  */
47
47
  noExpandAbbrev?: boolean;
@@ -59,12 +59,14 @@ export function buildSayArgs(o: SayOptions, capabilities?: EngineCapabilities |
59
59
  if (o.bitrate !== undefined) args.push("--bitrate", String(o.bitrate));
60
60
  if (o.sampleRate !== undefined) args.push("--sample-rate", String(o.sampleRate));
61
61
  if (o.noExpandAbbrev) {
62
- const supportsRuExpand = capabilities?.features?.includes("tts.ru_acronym_expansion") ?? false;
63
- if (supportsRuExpand) {
62
+ const supportsExpand = capabilities?.features?.some(
63
+ (f) => f === "tts.ru_acronym_expansion" || f === "tts.en_acronym_expansion",
64
+ ) ?? false;
65
+ if (supportsExpand) {
64
66
  args.push("--no-expand-abbrev");
65
67
  } else {
66
68
  log.debug(
67
- "kesha-engine does not advertise tts.ru_acronym_expansion; dropping --no-expand-abbrev",
69
+ "kesha-engine does not advertise tts.ru_acronym_expansion or tts.en_acronym_expansion; dropping --no-expand-abbrev",
68
70
  );
69
71
  }
70
72
  }
package/src/transcribe.ts CHANGED
@@ -1,14 +1,30 @@
1
- import { isEngineInstalled, transcribeEngine, type VadMode } from "./engine";
1
+ import {
2
+ isEngineInstalled,
3
+ transcribeEngine,
4
+ transcribeEngineWithSegments,
5
+ type TranscriptionOutput,
6
+ type VadMode,
7
+ } from "./engine";
2
8
 
3
9
  export type { VadMode };
10
+ export type { TranscriptionOutput };
4
11
 
5
12
  export interface TranscribeOptions {
6
13
  silent?: boolean;
7
14
  /** Silero VAD preprocessing selector. Defaults to `"auto"`. */
8
15
  vad?: VadMode;
16
+ /** Request timestamped transcript segments from the engine. */
17
+ timestamps?: boolean;
9
18
  }
10
19
 
11
20
  export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
21
+ return (await transcribeWithSegments(audioPath, opts)).text;
22
+ }
23
+
24
+ export async function transcribeWithSegments(
25
+ audioPath: string,
26
+ opts: TranscribeOptions = {},
27
+ ): Promise<TranscriptionOutput> {
12
28
  if (!isEngineInstalled()) {
13
29
  throw new Error(
14
30
  "Error: No transcription backend is installed\n\n" +
@@ -20,5 +36,10 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
20
36
  );
21
37
  }
22
38
 
23
- return transcribeEngine(audioPath, { vad: opts.vad });
39
+ if (opts.timestamps) {
40
+ return transcribeEngineWithSegments(audioPath, { vad: opts.vad });
41
+ }
42
+
43
+ const text = await transcribeEngine(audioPath, { vad: opts.vad });
44
+ return { text, segments: [] };
24
45
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LangDetectResult } from "./engine";
1
+ import type { LangDetectResult, TranscriptionSegment } from "./engine";
2
2
 
3
3
  /**
4
4
  * One row of `kesha --json` / `kesha --toon` output. Canonical output shape
@@ -11,8 +11,10 @@ export type TranscribeResult = {
11
11
  lang: string;
12
12
  audioLanguage?: LangDetectResult;
13
13
  textLanguage?: LangDetectResult;
14
+ /** Timestamped transcript segments when requested via `--timestamps`. */
15
+ segments?: TranscriptionSegment[];
14
16
  /** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
15
17
  sttTimeMs?: number;
16
18
  };
17
19
 
18
- export type { LangDetectResult };
20
+ export type { LangDetectResult, TranscriptionSegment };