@drakulavich/kesha-voice-kit 1.8.1 → 1.9.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.8.1",
3
+ "version": "1.9.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.1"
33
+ "version": "1.9.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/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/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 };