@drakulavich/kesha-voice-kit 1.8.2 → 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 +1 -0
- package/package.json +2 -2
- package/src/cli/main.ts +21 -6
- package/src/engine.ts +74 -2
- package/src/lib.ts +21 -1
- package/src/transcribe.ts +23 -2
- package/src/types.ts +4 -2
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.
|
|
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.
|
|
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 {
|
|
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,
|
|
127
|
+
// Run audio lang-id and transcription concurrently.
|
|
128
|
+
const [audioResult, transcript] = await Promise.all([
|
|
119
129
|
wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 };
|