@drakulavich/kesha-voice-kit 1.28.0-alpha.1 → 1.28.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 -1
- package/SKILL.md +4 -2
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +4 -0
- package/completions/kesha.zsh +4 -0
- package/man/kesha.1 +12 -0
- package/package.json +4 -3
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/install.ts +6 -0
- package/src/cli/main.ts +89 -59
- package/src/cli/record.ts +97 -3
- package/src/cli/say.ts +12 -5
- package/src/cli.ts +1 -0
- package/src/doctor.ts +1 -0
- package/src/engine-health.ts +61 -10
- package/src/engine-install.ts +140 -30
- package/src/engine.ts +94 -6
- package/src/error-codes.ts +1 -1
- package/src/install-lock.ts +261 -0
- package/src/lib.ts +2 -2
- package/src/stdout-pipe.ts +26 -0
- package/src/synth.ts +18 -10
- package/src/transcribe.ts +6 -1
- package/src/types.ts +21 -1
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ $ kesha freedom.ogg tahiti.ogg
|
|
|
94
94
|
- **Long / silence-heavy audio:** install VAD (`kesha install --vad`); Kesha auto-uses it past 120 s. Without VAD, long audio falls back to fixed ASR chunks. See [docs/vad.md](docs/vad.md).
|
|
95
95
|
- **Speaker diarization** (darwin-arm64): `kesha install --diarize` (which installs VAD too), then `kesha --json --speakers meeting.m4a` stamps each segment with a `speaker` id. `--speakers` engages VAD windowing itself at any duration, so it cannot be combined with `--no-vad`. Linux/Windows return a clear "darwin-arm64 only" error ([#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
|
|
96
96
|
- **Word-level timestamps** (every platform): `kesha --json --timestamps audio.ogg` adds a `words` array to each segment — `{ "word": "email", "start": 0.72, "end": 1.12 }` — on the same file-relative clock as the segment, so a word always lies inside the segment carrying it. Read them off the decoder's own frame grid, so: times are quantised to 0.08 s, consecutive spans may overlap (each `end` is a per-word duration prediction, not the next word's `start`), `end >= start` rather than strictly greater, and punctuation stays attached to its word. The key is simply absent where a segment has none — any segment `--itn` rewrote, for one — so check the `transcribe.words` capability rather than expecting an empty array ([#720](https://github.com/drakulavich/kesha-voice-kit/issues/720)).
|
|
97
|
-
- **Written-form numbers:** `--itn` rewrites what the model spells out — `"two hundred thirty two"` → `"232"`, `"five dollars and fifty cents"` → `"$5.50"`. Opt-in, every platform, timestamps untouched. English-only in practice; Russian and the rest pass through unchanged. Spoken punctuation names stay words (`"dot"`, `"comma"`, `"the period of growth"`) because Kesha transcribes speech rather than dictation — so `"example dot com"` keeps its words too ([#822](https://github.com/drakulavich/kesha-voice-kit/issues/822)).
|
|
97
|
+
- **Written-form numbers:** `--itn` rewrites what the model spells out — `"two hundred thirty two"` → `"232"`, `"five dollars and fifty cents"` → `"$5.50"`. Opt-in, every platform, timestamps untouched. English-only in practice; Russian and the rest pass through unchanged. Spoken punctuation names stay words (`"dot"`, `"comma"`, `"the period of growth"`) because Kesha transcribes speech rather than dictation — so `"example dot com"` keeps its words too ([#822](https://github.com/drakulavich/kesha-voice-kit/issues/822)). A sentence `"and"` survives the number that follows it (`"cats and three dogs"` → `"cats and 3 dogs"`), while an `"and"` the number owns still joins it (`"three hundred and five"` → `"305"`) ([#1000](https://github.com/drakulavich/kesha-voice-kit/issues/1000)) — and no longer splits the number around it (`"two hundred and thirty two"` → `"232"`, not `"230 2"`) ([#1006](https://github.com/drakulavich/kesha-voice-kit/issues/1006)). A hyphenated number reads the same as the spaced form (`"twenty-five apples"` → `"25 apples"`), while a hyphen between ordinary words is left alone (`"well-known"`, `"state-of-the-art"`, `"twenty-something"`) ([#1004](https://github.com/drakulavich/kesha-voice-kit/issues/1004)).
|
|
98
98
|
|
|
99
99
|
## Text-to-speech
|
|
100
100
|
|
package/SKILL.md
CHANGED
|
@@ -130,12 +130,14 @@ kesha --json voice.ogg
|
|
|
130
130
|
"text": "Привет, как дела?",
|
|
131
131
|
"lang": "ru",
|
|
132
132
|
"audioLanguage": { "code": "ru", "confidence": 0.98 },
|
|
133
|
-
"textLanguage": { "code": "ru", "confidence": 0.99 }
|
|
133
|
+
"textLanguage": { "code": "ru", "confidence": 0.99, "source": "engine" }
|
|
134
134
|
}]
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
Use `lang` (or the more detailed `audioLanguage`/`textLanguage`) to decide how to respond.
|
|
138
138
|
|
|
139
|
+
**Read `textLanguage.source` before you compare its `confidence` to a threshold.** `"engine"` (macOS `NLLanguageRecognizer`) and `"tinyld"` (the CLI fallback used on Linux/Windows) score on different scales — the same correct detection reads ~0.98 from the engine and ~0.2 from `tinyld`, so a fixed `> 0.5` gate discards every correct fallback result. `audioLanguage` has one detector and carries no `source`.
|
|
140
|
+
|
|
139
141
|
Need timestamped transcript segments for navigation, chapters, or downstream editing:
|
|
140
142
|
|
|
141
143
|
```bash
|
|
@@ -204,7 +206,7 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
|
|
|
204
206
|
|
|
205
207
|
## Language detection standalone
|
|
206
208
|
|
|
207
|
-
`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.
|
|
209
|
+
`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. `textLanguage` also names its detector in `source` — see the threshold rule under [STT](#stt-transcribe-audio).
|
|
208
210
|
|
|
209
211
|
## Install
|
|
210
212
|
|
package/completions/kesha.bash
CHANGED
|
@@ -25,7 +25,7 @@ _kesha_completion() {
|
|
|
25
25
|
logs) opts="--help -h --json" ;;
|
|
26
26
|
manpage) opts="--help -h" ;;
|
|
27
27
|
mcp) opts="--help -h" ;;
|
|
28
|
-
record) opts="--help -h --out --live --max-seconds --debug" ;;
|
|
28
|
+
record) opts="--help -h --out --live --max-seconds --auto-stop --auto-stop-silence-ms --auto-stop-threshold --auto-stop-min-speech-ms --debug" ;;
|
|
29
29
|
say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
|
|
30
30
|
stats) opts="--help -h --format" ;;
|
|
31
31
|
status) opts="--help -h --disk --json" ;;
|
package/completions/kesha.fish
CHANGED
|
@@ -68,6 +68,10 @@ complete -c kesha -n '__fish_seen_subcommand_from record' -s h -d 'Show help'
|
|
|
68
68
|
complete -c kesha -n '__fish_seen_subcommand_from record' -l out -r -d 'Write recorded WAV audio to this path'
|
|
69
69
|
complete -c kesha -n '__fish_seen_subcommand_from record' -l live -d 'Transcribe the microphone live and print the transcript to stdout (CoreML on Apple Silicon only)'
|
|
70
70
|
complete -c kesha -n '__fish_seen_subcommand_from record' -l max-seconds -r -d 'Maximum recording duration in seconds'
|
|
71
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop -d 'End a live recording after trailing silence (requires `kesha install --vad`)'
|
|
72
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-silence-ms -r -d 'Trailing silence before auto-stop (default: 1000)'
|
|
73
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-threshold -r -d 'Silero speech threshold for auto-stop (default: 0.5)'
|
|
74
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-min-speech-ms -r -d 'Minimum speech before auto-stop may end a recording (default: 250)'
|
|
71
75
|
complete -c kesha -n '__fish_seen_subcommand_from record' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
72
76
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
|
|
73
77
|
complete -c kesha -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
|
package/completions/kesha.zsh
CHANGED
|
@@ -98,6 +98,10 @@ _kesha() {
|
|
|
98
98
|
'--out=[Write recorded WAV audio to this path]:out:' \
|
|
99
99
|
'--live[Transcribe the microphone live and print the transcript to stdout (CoreML on Apple Silicon only)]' \
|
|
100
100
|
'--max-seconds=[Maximum recording duration in seconds]:max seconds:' \
|
|
101
|
+
'--auto-stop[End a live recording after trailing silence (requires `kesha install --vad`)]' \
|
|
102
|
+
'--auto-stop-silence-ms=[Trailing silence before auto-stop (default: 1000)]:auto stop silence ms:' \
|
|
103
|
+
'--auto-stop-threshold=[Silero speech threshold for auto-stop (default: 0.5)]:auto stop threshold:' \
|
|
104
|
+
'--auto-stop-min-speech-ms=[Minimum speech before auto-stop may end a recording (default: 250)]:auto stop min speech ms:' \
|
|
101
105
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]'
|
|
102
106
|
;;
|
|
103
107
|
say)
|
package/man/kesha.1
CHANGED
|
@@ -162,6 +162,18 @@ Transcribe the microphone live and print the transcript to stdout (CoreML on App
|
|
|
162
162
|
.B \-\-max\-seconds
|
|
163
163
|
Maximum recording duration in seconds
|
|
164
164
|
.TP
|
|
165
|
+
.B \-\-auto\-stop
|
|
166
|
+
End a live recording after trailing silence (requires `kesha install \-\-vad`)
|
|
167
|
+
.TP
|
|
168
|
+
.B \-\-auto\-stop\-silence\-ms
|
|
169
|
+
Trailing silence before auto\-stop (default: 1000)
|
|
170
|
+
.TP
|
|
171
|
+
.B \-\-auto\-stop\-threshold
|
|
172
|
+
Silero speech threshold for auto\-stop (default: 0.5)
|
|
173
|
+
.TP
|
|
174
|
+
.B \-\-auto\-stop\-min\-speech\-ms
|
|
175
|
+
Minimum speech before auto\-stop may end a recording (default: 250)
|
|
176
|
+
.TP
|
|
165
177
|
.B \-\-debug
|
|
166
178
|
Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
167
179
|
.SS say
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.28.0
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"keshaEngine": {
|
|
43
|
-
"version": "1.24.
|
|
43
|
+
"version": "1.24.10"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "bun test --timeout 30000",
|
|
47
|
+
"conveyor": "bun scripts/backlog.ts",
|
|
47
48
|
"test:watch": "bun test --timeout 30000 --watch",
|
|
48
49
|
"test:unit": "bun test --timeout 30000 tests/unit/",
|
|
49
|
-
"test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/say.test.ts",
|
|
50
|
+
"test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/error-codes-cli.test.ts tests/integration/say.test.ts",
|
|
50
51
|
"test:integration": "bun test --timeout 30000 tests/integration/",
|
|
51
52
|
"coverage:ts": "bun run test:cli-fast --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/ts",
|
|
52
53
|
"coverage:check:ts": "bun .github/scripts/check-coverage.ts --preset=ts --lcov=coverage/ts/lcov.info --summary=coverage/ts/summary.md",
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runMain, type CommandDef } from "citty";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { log } from "../log";
|
|
4
|
+
import { guardStdoutWrites } from "../stdout-pipe";
|
|
4
5
|
import { suggestCommand } from "../suggest-command";
|
|
5
6
|
import { applyCliContext, resolveCliContext } from "./context";
|
|
6
7
|
|
|
@@ -89,6 +90,7 @@ export function unknownCommandMessages(token: string, subcommandKeys: string[]):
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
93
|
+
guardStdoutWrites();
|
|
92
94
|
const context = resolveCliContext(argv);
|
|
93
95
|
applyCliContext(context);
|
|
94
96
|
|
package/src/cli/install.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { log } from "../log";
|
|
|
9
9
|
import { packageVersion } from "../package-info";
|
|
10
10
|
import { isSemver } from "../semver.mjs";
|
|
11
11
|
import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
|
|
12
|
+
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
12
13
|
import type { SharedInstallArgs } from "./types";
|
|
13
14
|
|
|
14
15
|
export interface InstallCommandArgs extends SharedInstallArgs {
|
|
@@ -239,6 +240,11 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
239
240
|
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
240
241
|
finishInstallDiagnostic(diagnosticLog, startedAt, "success");
|
|
241
242
|
} catch (err: unknown) {
|
|
243
|
+
const signalExitCode = getPendingSignalExitCode();
|
|
244
|
+
if (signalExitCode !== null) {
|
|
245
|
+
await waitForPendingSignalCleanup();
|
|
246
|
+
process.exit(signalExitCode);
|
|
247
|
+
}
|
|
242
248
|
const message = errorMessage(err);
|
|
243
249
|
finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
|
|
244
250
|
log.error(message);
|
package/src/cli/main.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { existsSync, statSync } from "fs";
|
|
4
|
-
import {
|
|
4
|
+
import { detectAll } from "tinyld";
|
|
5
5
|
import { preflightTranscribeWithSegments, transcribeWithSegments } from "../transcribe";
|
|
6
6
|
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
|
|
7
7
|
import type { LangDetectResult } from "../engine";
|
|
8
8
|
import { log } from "../log";
|
|
9
|
-
import type { TranscribeErrorRecord, TranscribeResult } from "../types";
|
|
9
|
+
import type { TextLangDetectResult, TranscribeErrorRecord, TranscribeResult } from "../types";
|
|
10
10
|
import {
|
|
11
11
|
formatJsonOutput,
|
|
12
12
|
formatTextOutput,
|
|
@@ -33,8 +33,6 @@ interface MainCommandArgs {
|
|
|
33
33
|
debug: boolean;
|
|
34
34
|
vad: boolean;
|
|
35
35
|
"no-vad": boolean;
|
|
36
|
-
noVad?: boolean;
|
|
37
|
-
no_vad?: boolean;
|
|
38
36
|
timestamps: boolean;
|
|
39
37
|
speakers: boolean;
|
|
40
38
|
itn: boolean;
|
|
@@ -45,9 +43,21 @@ interface MainCommandArgs {
|
|
|
45
43
|
"no-color": boolean;
|
|
46
44
|
}
|
|
47
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The CLI-side `tinyld` fallback, tagged `source: "tinyld"` so a consumer can
|
|
48
|
+
* tell it apart from an Engine detection instead of reading the placeholder
|
|
49
|
+
* `confidence: 0` it used to carry as a genuine zero (#941). The score is
|
|
50
|
+
* tinyld's own and is not on the Engine's scale.
|
|
51
|
+
*/
|
|
52
|
+
export function detectTextLanguageFallback(text: string): TextLangDetectResult | undefined {
|
|
53
|
+
if (!text) return undefined;
|
|
54
|
+
const [best] = detectAll(text);
|
|
55
|
+
if (!best) return undefined;
|
|
56
|
+
return { code: best.lang, confidence: best.accuracy, source: "tinyld" };
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
export function detectLanguage(text: string): string {
|
|
49
|
-
|
|
50
|
-
return detect(text);
|
|
60
|
+
return detectTextLanguageFallback(text)?.code ?? "";
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
/** True when `path` exists and is a directory. Used to reject directory positionals before any progress/engine spawn. */
|
|
@@ -65,31 +75,31 @@ export function isDirectoryPath(path: string): boolean {
|
|
|
65
75
|
* with its own clearer error first.
|
|
66
76
|
*/
|
|
67
77
|
export type ResolvedOutputFormat =
|
|
68
|
-
| {
|
|
69
|
-
ok: true;
|
|
70
|
-
wantsJson: boolean;
|
|
71
|
-
wantsToon: boolean;
|
|
72
|
-
wantsTranscript: boolean;
|
|
73
|
-
}
|
|
78
|
+
| { ok: true; format: "json" | "toon" | "transcript" | "text" }
|
|
74
79
|
| { ok: false; error: string };
|
|
75
80
|
|
|
76
81
|
const SUPPORTED_FORMATS = ["transcript", "json", "toon"] as const;
|
|
77
82
|
|
|
83
|
+
function isSupportedFormat(format: string): format is (typeof SUPPORTED_FORMATS)[number] {
|
|
84
|
+
return SUPPORTED_FORMATS.some((supported) => supported === format);
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
export function resolveOutputFormat(input: {
|
|
79
88
|
json?: boolean;
|
|
80
89
|
toon?: boolean;
|
|
81
90
|
format?: string;
|
|
82
91
|
}): ResolvedOutputFormat {
|
|
83
|
-
if (input.format !== undefined && !
|
|
92
|
+
if (input.format !== undefined && !isSupportedFormat(input.format)) {
|
|
84
93
|
return {
|
|
85
94
|
ok: false,
|
|
86
95
|
error: `unknown --format '${input.format}'. supported: ${SUPPORTED_FORMATS.join(", ")}`,
|
|
87
96
|
};
|
|
88
97
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
if (
|
|
99
|
+
(input.json && input.toon) ||
|
|
100
|
+
(input.json && input.format === "toon") ||
|
|
101
|
+
(input.toon && input.format === "json")
|
|
102
|
+
) {
|
|
93
103
|
return {
|
|
94
104
|
ok: false,
|
|
95
105
|
error: "--json and --toon are mutually exclusive (pick one output format).",
|
|
@@ -100,7 +110,7 @@ export function resolveOutputFormat(input: {
|
|
|
100
110
|
// dispatch checked wantsJson/wantsToon first). Greptile P2 on #300
|
|
101
111
|
// flagged the silent override. Fail loudly with the same shape as
|
|
102
112
|
// the json/toon mutex — symmetric across all three formats.
|
|
103
|
-
if (
|
|
113
|
+
if (input.format === "transcript" && (input.json || input.toon)) {
|
|
104
114
|
return {
|
|
105
115
|
ok: false,
|
|
106
116
|
error:
|
|
@@ -108,7 +118,7 @@ export function resolveOutputFormat(input: {
|
|
|
108
118
|
"(pick one output format).",
|
|
109
119
|
};
|
|
110
120
|
}
|
|
111
|
-
return { ok: true,
|
|
121
|
+
return { ok: true, format: input.json ? "json" : input.toon ? "toon" : (input.format ?? "text") };
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
|
|
@@ -151,6 +161,19 @@ export type ValidatedTranscribeArgs = {
|
|
|
151
161
|
outputFormat: "json" | "toon" | "transcript" | "text";
|
|
152
162
|
};
|
|
153
163
|
|
|
164
|
+
type MainVadArgs = Pick<MainCommandArgs, "vad" | "no-vad">;
|
|
165
|
+
|
|
166
|
+
export function normalizeMainCommandArgs<T extends MainVadArgs>(
|
|
167
|
+
args: T,
|
|
168
|
+
rawArgs: string[],
|
|
169
|
+
): T & { noVad: boolean } {
|
|
170
|
+
return {
|
|
171
|
+
...args,
|
|
172
|
+
vad: rawArgs.includes("--vad") || args.vad,
|
|
173
|
+
noVad: rawArgs.includes("--no-vad") || args["no-vad"] === true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
154
177
|
export type TranscribeArgsValidation =
|
|
155
178
|
| ({ ok: true } & ValidatedTranscribeArgs)
|
|
156
179
|
| { ok: false; error: string };
|
|
@@ -161,20 +184,16 @@ export function validateTranscribeArgs(
|
|
|
161
184
|
rawArgs: string[],
|
|
162
185
|
fmt: ResolvedOutputFormat & { ok: true },
|
|
163
186
|
): TranscribeArgsValidation {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
// citty treats --no-vad as the negated form of --vad, so read rawArgs
|
|
167
|
-
// to distinguish "off" from the default auto mode and to catch both flags.
|
|
168
|
-
const vad = rawArgs.includes("--vad") || Boolean(args.vad);
|
|
169
|
-
const noVad = rawArgs.includes("--no-vad") || Boolean(args["no-vad"] ?? args.noVad ?? args.no_vad);
|
|
187
|
+
const normalizedArgs = normalizeMainCommandArgs(args, rawArgs);
|
|
188
|
+
const { vad, noVad } = normalizedArgs;
|
|
170
189
|
|
|
171
190
|
if (vad && noVad) {
|
|
172
191
|
return { ok: false, error: "--vad and --no-vad are mutually exclusive." };
|
|
173
192
|
}
|
|
174
|
-
if (args.timestamps &&
|
|
193
|
+
if (args.timestamps && fmt.format !== "json" && fmt.format !== "toon") {
|
|
175
194
|
return { ok: false, error: "--timestamps requires --json, --toon, or --format {json,toon}." };
|
|
176
195
|
}
|
|
177
|
-
if (args.speakers &&
|
|
196
|
+
if (args.speakers && fmt.format !== "json" && fmt.format !== "toon") {
|
|
178
197
|
return { ok: false, error: "--speakers requires --json, --toon, or --format {json,toon}." };
|
|
179
198
|
}
|
|
180
199
|
// #768: diarization labels VAD-windowed segments, so --no-vad leaves nothing to label.
|
|
@@ -187,20 +206,12 @@ export function validateTranscribeArgs(
|
|
|
187
206
|
"(VAD engages automatically for --speakers), or drop --speakers.",
|
|
188
207
|
};
|
|
189
208
|
}
|
|
190
|
-
if (args["include-errors"] &&
|
|
209
|
+
if (args["include-errors"] && fmt.format !== "json" && fmt.format !== "toon") {
|
|
191
210
|
return { ok: false, error: "--include-errors requires --json, --toon, or --format {json,toon}." };
|
|
192
211
|
}
|
|
193
212
|
|
|
194
213
|
const vadMode: ValidatedTranscribeArgs["vadMode"] = vad ? "on" : noVad ? "off" : "auto";
|
|
195
|
-
|
|
196
|
-
? "json"
|
|
197
|
-
: wantsToon
|
|
198
|
-
? "toon"
|
|
199
|
-
: wantsTranscript
|
|
200
|
-
? "transcript"
|
|
201
|
-
: "text";
|
|
202
|
-
|
|
203
|
-
return { ok: true, vadMode, outputFormat };
|
|
214
|
+
return { ok: true, vadMode, outputFormat: fmt.format };
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
async function detectLanguages(
|
|
@@ -215,7 +226,7 @@ async function detectLanguages(
|
|
|
215
226
|
},
|
|
216
227
|
): Promise<{
|
|
217
228
|
audioLanguage: LangDetectResult | undefined;
|
|
218
|
-
textLanguage:
|
|
229
|
+
textLanguage: TextLangDetectResult | undefined;
|
|
219
230
|
lang: string;
|
|
220
231
|
ranAudioLangId: boolean;
|
|
221
232
|
}> {
|
|
@@ -243,17 +254,17 @@ async function detectLanguages(
|
|
|
243
254
|
}
|
|
244
255
|
}
|
|
245
256
|
|
|
246
|
-
const
|
|
247
|
-
let textLanguage:
|
|
257
|
+
const tinyldResult = wantsLangId ? detectTextLanguageFallback(text) : undefined;
|
|
258
|
+
let textLanguage: TextLangDetectResult | undefined;
|
|
248
259
|
|
|
249
260
|
if (wantsLangId) {
|
|
250
261
|
const engineTextResult = await stats.timeStage("lang_id_text", () => detectTextLanguageEngine(text));
|
|
251
262
|
if (engineTextResult && engineTextResult.code) {
|
|
252
|
-
textLanguage = engineTextResult;
|
|
263
|
+
textLanguage = { ...engineTextResult, source: "engine" };
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
266
|
|
|
256
|
-
const lang = textLanguage?.code ||
|
|
267
|
+
const lang = textLanguage?.code || tinyldResult?.code || "";
|
|
257
268
|
|
|
258
269
|
const mismatchWarning = checkLanguageMismatch(expectedLang, lang);
|
|
259
270
|
if (mismatchWarning) {
|
|
@@ -262,7 +273,7 @@ async function detectLanguages(
|
|
|
262
273
|
|
|
263
274
|
return {
|
|
264
275
|
audioLanguage,
|
|
265
|
-
textLanguage: textLanguage ??
|
|
276
|
+
textLanguage: textLanguage ?? tinyldResult,
|
|
266
277
|
lang,
|
|
267
278
|
ranAudioLangId: audioResult !== null,
|
|
268
279
|
};
|
|
@@ -295,20 +306,20 @@ async function processFile(
|
|
|
295
306
|
command: "transcribe",
|
|
296
307
|
error_code: TS_NATIVE_CODES.INPUT_NOT_FOUND,
|
|
297
308
|
});
|
|
298
|
-
log.error(`${file}: File not found`);
|
|
309
|
+
log.error(`${file}: error [${TS_NATIVE_CODES.INPUT_NOT_FOUND}]: File not found`);
|
|
299
310
|
return { ok: false, error: { file, code: TS_NATIVE_CODES.INPUT_NOT_FOUND, message: "File not found" } };
|
|
300
311
|
}
|
|
301
312
|
|
|
302
313
|
if (isDirectoryPath(file)) {
|
|
303
|
-
stats.recordError("input", new Error("is a directory"),
|
|
314
|
+
stats.recordError("input", new Error("is a directory"), TS_NATIVE_CODES.INVALID_ARG);
|
|
304
315
|
diagnosticLog.event("input.invalid", {
|
|
305
316
|
command: "transcribe",
|
|
306
|
-
error_code:
|
|
317
|
+
error_code: TS_NATIVE_CODES.INVALID_ARG,
|
|
307
318
|
});
|
|
308
|
-
log.error(`${file}: is a directory (expected an audio file)`);
|
|
319
|
+
log.error(`${file}: error [${TS_NATIVE_CODES.INVALID_ARG}]: is a directory (expected an audio file)`);
|
|
309
320
|
return {
|
|
310
321
|
ok: false,
|
|
311
|
-
error: { file, code:
|
|
322
|
+
error: { file, code: TS_NATIVE_CODES.INVALID_ARG, message: "is a directory (expected an audio file)" },
|
|
312
323
|
};
|
|
313
324
|
}
|
|
314
325
|
|
|
@@ -324,6 +335,13 @@ async function processFile(
|
|
|
324
335
|
|
|
325
336
|
const startedAt = performance.now();
|
|
326
337
|
let progress: ReturnType<typeof createPercentProgress> | null = null;
|
|
338
|
+
// #1002: the bar sitting at 0% through a ~100 s diarization model load reads as a hang,
|
|
339
|
+
// so the engine's progress has to land while it happens, not once the run is over.
|
|
340
|
+
const showProgressLine = (line: string) => {
|
|
341
|
+
const write = () => process.stderr.write(`${line}\n`);
|
|
342
|
+
if (progress) progress.interrupt(write);
|
|
343
|
+
else write();
|
|
344
|
+
};
|
|
327
345
|
try {
|
|
328
346
|
await preflightTranscribeWithSegments({ vad: vadMode, timestamps, speakers, itn });
|
|
329
347
|
progress = reportProgress
|
|
@@ -332,7 +350,13 @@ async function processFile(
|
|
|
332
350
|
})
|
|
333
351
|
: null;
|
|
334
352
|
const transcript = await stats.timeStage("transcribe", () =>
|
|
335
|
-
transcribeWithSegments(file, {
|
|
353
|
+
transcribeWithSegments(file, {
|
|
354
|
+
vad: vadMode,
|
|
355
|
+
timestamps,
|
|
356
|
+
speakers,
|
|
357
|
+
itn,
|
|
358
|
+
onProgressLine: showProgressLine,
|
|
359
|
+
}),
|
|
336
360
|
);
|
|
337
361
|
const { text, segments } = transcript;
|
|
338
362
|
const transcriptDurationSeconds = estimateTranscriptDurationSeconds(segments);
|
|
@@ -409,6 +433,21 @@ function writeOutput(
|
|
|
409
433
|
}
|
|
410
434
|
|
|
411
435
|
/** The transcribe command, bound to the global flags dispatch resolved before citty. */
|
|
436
|
+
export const MAIN_VAD_ARGS = {
|
|
437
|
+
vad: {
|
|
438
|
+
type: "boolean",
|
|
439
|
+
description:
|
|
440
|
+
"Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.",
|
|
441
|
+
default: false,
|
|
442
|
+
},
|
|
443
|
+
"no-vad": {
|
|
444
|
+
type: "boolean",
|
|
445
|
+
description:
|
|
446
|
+
"Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
|
|
447
|
+
default: false,
|
|
448
|
+
},
|
|
449
|
+
} as const;
|
|
450
|
+
|
|
412
451
|
export function createMainCommand(context: CliContext = { quiet: false, disableColor: false }) {
|
|
413
452
|
return defineCommand({
|
|
414
453
|
meta: {
|
|
@@ -486,16 +525,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
486
525
|
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
487
526
|
default: false,
|
|
488
527
|
},
|
|
489
|
-
|
|
490
|
-
type: "boolean",
|
|
491
|
-
description: "Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.",
|
|
492
|
-
default: false,
|
|
493
|
-
},
|
|
494
|
-
"no-vad": {
|
|
495
|
-
type: "boolean",
|
|
496
|
-
description: "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
|
|
497
|
-
default: false,
|
|
498
|
-
},
|
|
528
|
+
...MAIN_VAD_ARGS,
|
|
499
529
|
// quiet and no-color are resolved before citty in dispatch.ts (so they
|
|
500
530
|
// apply to every command, not just transcribe); declared here only so they
|
|
501
531
|
// appear in `kesha --help`.
|
package/src/cli/record.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isEngineInstalled,
|
|
5
|
+
preflightRecordLive,
|
|
6
|
+
recordEngine,
|
|
7
|
+
type LiveAutoStopOptions,
|
|
8
|
+
type RecordTarget,
|
|
9
|
+
} from "../engine";
|
|
4
10
|
import { installHint } from "../install-hint";
|
|
5
11
|
import { log } from "../log";
|
|
6
12
|
|
|
@@ -8,6 +14,10 @@ export interface RecordArgs {
|
|
|
8
14
|
out?: string;
|
|
9
15
|
live?: boolean;
|
|
10
16
|
"max-seconds"?: string | number;
|
|
17
|
+
"auto-stop"?: boolean;
|
|
18
|
+
"auto-stop-silence-ms"?: string | number;
|
|
19
|
+
"auto-stop-threshold"?: string | number;
|
|
20
|
+
"auto-stop-min-speech-ms"?: string | number;
|
|
11
21
|
debug?: boolean;
|
|
12
22
|
}
|
|
13
23
|
|
|
@@ -17,6 +27,9 @@ export type ResolvedRecordArgs =
|
|
|
17
27
|
|
|
18
28
|
const DEFAULT_MAX_SECONDS = 120;
|
|
19
29
|
const MAX_RECORD_SECONDS = 3600;
|
|
30
|
+
const DEFAULT_AUTO_STOP_SILENCE_MS = 1000;
|
|
31
|
+
const DEFAULT_AUTO_STOP_THRESHOLD = 0.5;
|
|
32
|
+
const DEFAULT_AUTO_STOP_MIN_SPEECH_MS = 250;
|
|
20
33
|
|
|
21
34
|
type Rejected = { ok: false; error: string };
|
|
22
35
|
|
|
@@ -57,7 +70,71 @@ export function resolveRecordArgs(args: RecordArgs): ResolvedRecordArgs {
|
|
|
57
70
|
if (!target.ok) return target;
|
|
58
71
|
const max = resolveMaxSeconds(args["max-seconds"]);
|
|
59
72
|
if (!max.ok) return max;
|
|
60
|
-
|
|
73
|
+
const autoStop = resolveAutoStop(args, target.target);
|
|
74
|
+
if (!autoStop.ok) return autoStop;
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
target: autoStop.options ? { live: true, autoStop: autoStop.options } : target.target,
|
|
78
|
+
maxSeconds: max.maxSeconds,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveAutoStop(
|
|
83
|
+
args: RecordArgs,
|
|
84
|
+
target: RecordTarget,
|
|
85
|
+
): { ok: true; options?: LiveAutoStopOptions } | Rejected {
|
|
86
|
+
const enabled = args["auto-stop"] === true;
|
|
87
|
+
const hasSettings =
|
|
88
|
+
args["auto-stop-silence-ms"] !== undefined ||
|
|
89
|
+
args["auto-stop-threshold"] !== undefined ||
|
|
90
|
+
args["auto-stop-min-speech-ms"] !== undefined;
|
|
91
|
+
if (!enabled && hasSettings) {
|
|
92
|
+
return { ok: false, error: "auto-stop settings require --auto-stop." };
|
|
93
|
+
}
|
|
94
|
+
if (enabled && !target.live) {
|
|
95
|
+
return { ok: false, error: "--auto-stop requires --live." };
|
|
96
|
+
}
|
|
97
|
+
if (!enabled) return { ok: true };
|
|
98
|
+
|
|
99
|
+
const silenceMs = resolvePositiveInteger(
|
|
100
|
+
args["auto-stop-silence-ms"],
|
|
101
|
+
DEFAULT_AUTO_STOP_SILENCE_MS,
|
|
102
|
+
"--auto-stop-silence-ms",
|
|
103
|
+
);
|
|
104
|
+
if (!silenceMs.ok) return silenceMs;
|
|
105
|
+
const minSpeechMs = resolvePositiveInteger(
|
|
106
|
+
args["auto-stop-min-speech-ms"],
|
|
107
|
+
DEFAULT_AUTO_STOP_MIN_SPEECH_MS,
|
|
108
|
+
"--auto-stop-min-speech-ms",
|
|
109
|
+
);
|
|
110
|
+
if (!minSpeechMs.ok) return minSpeechMs;
|
|
111
|
+
const threshold = resolveThreshold(args["auto-stop-threshold"]);
|
|
112
|
+
if (!threshold.ok) return threshold;
|
|
113
|
+
return { ok: true, options: { silenceMs: silenceMs.value, threshold: threshold.value, minSpeechMs: minSpeechMs.value } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolvePositiveInteger(
|
|
117
|
+
rawValue: string | number | undefined,
|
|
118
|
+
defaultValue: number,
|
|
119
|
+
flag: string,
|
|
120
|
+
): { ok: true; value: number } | Rejected {
|
|
121
|
+
const raw = String(rawValue ?? defaultValue).trim();
|
|
122
|
+
const value = Number(raw);
|
|
123
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
124
|
+
return { ok: false, error: `${flag} must be a positive integer.` };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, value };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveThreshold(
|
|
130
|
+
rawValue: string | number | undefined,
|
|
131
|
+
): { ok: true; value: number } | Rejected {
|
|
132
|
+
const raw = String(rawValue ?? DEFAULT_AUTO_STOP_THRESHOLD).trim();
|
|
133
|
+
const value = Number(raw);
|
|
134
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1) {
|
|
135
|
+
return { ok: false, error: "--auto-stop-threshold must be greater than 0 and at most 1." };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, value };
|
|
61
138
|
}
|
|
62
139
|
|
|
63
140
|
/** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
|
|
@@ -91,6 +168,23 @@ export const recordCommand = defineCommand({
|
|
|
91
168
|
description: "Maximum recording duration in seconds",
|
|
92
169
|
default: String(DEFAULT_MAX_SECONDS),
|
|
93
170
|
},
|
|
171
|
+
"auto-stop": {
|
|
172
|
+
type: "boolean",
|
|
173
|
+
description: "End a live recording after trailing silence (requires `kesha install --vad`)",
|
|
174
|
+
default: false,
|
|
175
|
+
},
|
|
176
|
+
"auto-stop-silence-ms": {
|
|
177
|
+
type: "string",
|
|
178
|
+
description: "Trailing silence before auto-stop (default: 1000)",
|
|
179
|
+
},
|
|
180
|
+
"auto-stop-threshold": {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "Silero speech threshold for auto-stop (default: 0.5)",
|
|
183
|
+
},
|
|
184
|
+
"auto-stop-min-speech-ms": {
|
|
185
|
+
type: "string",
|
|
186
|
+
description: "Minimum speech before auto-stop may end a recording (default: 250)",
|
|
187
|
+
},
|
|
94
188
|
debug: {
|
|
95
189
|
type: "boolean",
|
|
96
190
|
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
@@ -109,7 +203,7 @@ export const recordCommand = defineCommand({
|
|
|
109
203
|
process.exit(1);
|
|
110
204
|
}
|
|
111
205
|
try {
|
|
112
|
-
if (resolved.target.live) await preflightRecordLive();
|
|
206
|
+
if (resolved.target.live) await preflightRecordLive(resolved.target.autoStop !== undefined);
|
|
113
207
|
await recordEngine(resolved.target, resolved.maxSeconds);
|
|
114
208
|
} catch (err) {
|
|
115
209
|
log.error(errorMessage(err));
|
package/src/cli/say.ts
CHANGED
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
import { installHint } from "../install-hint";
|
|
10
10
|
import { registerProcessTree } from "../process-tree";
|
|
11
11
|
import { log } from "../log";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
say,
|
|
14
|
+
SayError,
|
|
15
|
+
SUPPORTED_SAMPLE_RATES,
|
|
16
|
+
type SayFormat,
|
|
17
|
+
type SayOptions,
|
|
18
|
+
} from "../synth";
|
|
13
19
|
import { artifactFromBytes, artifactFromFile, type StatsRecorder } from "../stats";
|
|
14
20
|
import { resolveSayVoice } from "../voice-routing";
|
|
15
21
|
import { diagnosticCharBucket, diagnosticSizeBucket } from "../diagnostic-events";
|
|
@@ -68,12 +74,13 @@ function parseBitrateFlag(value: unknown): Parsed<number> {
|
|
|
68
74
|
return bitrate;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
const SUPPORTED_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000];
|
|
72
|
-
|
|
73
77
|
function parseSampleRateFlag(value: unknown): Parsed<number> {
|
|
74
78
|
const sampleRate = parseFiniteNumberFlag("--sample-rate", value);
|
|
75
79
|
if (!sampleRate.ok || sampleRate.value === undefined) return sampleRate;
|
|
76
|
-
if (
|
|
80
|
+
if (
|
|
81
|
+
!Number.isInteger(sampleRate.value) ||
|
|
82
|
+
!SUPPORTED_SAMPLE_RATES.includes(sampleRate.value as (typeof SUPPORTED_SAMPLE_RATES)[number])
|
|
83
|
+
) {
|
|
77
84
|
return {
|
|
78
85
|
ok: false,
|
|
79
86
|
error: `--sample-rate must be one of ${SUPPORTED_SAMPLE_RATES.join(", ")}.`,
|
|
@@ -189,7 +196,7 @@ async function synthesizeAndEmit(
|
|
|
189
196
|
const voiceLabel = opts.voice ?? "default voice";
|
|
190
197
|
log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
|
|
191
198
|
}
|
|
192
|
-
const audio = await stats.timeStage("tts", () => say(opts));
|
|
199
|
+
const audio = await stats.timeStage("tts", () => say(opts as SayOptions));
|
|
193
200
|
const ttsTimeMs = Math.round(performance.now() - startedAt);
|
|
194
201
|
if (opts.out) {
|
|
195
202
|
log.status(`Saved ${opts.out} (${ttsTimeMs}ms)`);
|
package/src/cli.ts
CHANGED