@drakulavich/kesha-voice-kit 1.4.1 → 1.4.3
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 +25 -135
- package/package.json +5 -2
- package/src/status.ts +86 -1
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<p align="center"><b>Open-source voice toolkit.</b> Optimized for Apple Silicon (CoreML), works on any platform (ONNX fallback).<br>A collection of small, fast, open-source audio models — packaged as CLI tools and an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill for LLM agents.</p>
|
|
15
15
|
|
|
16
16
|
- **Speech-to-text** — 25 languages, ~15x faster than Whisper on Apple Silicon, ~2.5x on CPU
|
|
17
|
-
- **
|
|
17
|
+
- **Text-to-speech** — Kokoro (EN) + Piper (RU) + macOS system voices, SSML preview
|
|
18
18
|
- **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
|
|
19
19
|
- **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
|
|
20
20
|
|
|
@@ -30,52 +30,17 @@ kesha install # downloads engine + models
|
|
|
30
30
|
kesha audio.ogg # transcript to stdout
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
## Speech-to-text
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
export KESHA_MODEL_MIRROR=https://models.corp.internal/kesha
|
|
39
|
-
kesha install # ASR + lang-id + TTS models fetch from your mirror
|
|
40
|
-
kesha status # confirms the active Mirror URL
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Unset / empty falls back to `huggingface.co` with no regression. The engine binary itself still comes from GitHub Releases — this env var only redirects model downloads.
|
|
44
|
-
|
|
45
|
-
## OpenClaw Integration
|
|
46
|
-
|
|
47
|
-
Kesha Voice Kit ships as a plugin for [OpenClaw](https://github.com/openclaw/openclaw) — give your LLM agent ears. No API keys, everything runs locally on your machine.
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
bun add -g @drakulavich/kesha-voice-kit && kesha install
|
|
51
|
-
openclaw plugins install @drakulavich/kesha-voice-kit
|
|
52
|
-
openclaw config set tools.media.audio.models \
|
|
53
|
-
'[{"type":"cli","command":"kesha","args":["--format","transcript","{{MediaPath}}"],"timeoutSeconds":15}]'
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
> If audio transcription is not already enabled: `openclaw config set tools.media.audio.enabled true`
|
|
57
|
-
|
|
58
|
-
Your agent receives a voice message in Telegram/WhatsApp/Slack, Kesha transcribes it locally, and the agent sees enriched context:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
|
|
62
|
-
[lang: ru, confidence: 1.00]
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Manage the plugin with `openclaw plugins list`, `openclaw plugins disable kesha-voice-kit`, or `openclaw plugins uninstall kesha-voice-kit`.
|
|
66
|
-
|
|
67
|
-
## CLI Tools
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
kesha install # download engine and models
|
|
71
38
|
kesha audio.ogg # transcribe (plain text)
|
|
72
39
|
kesha --format transcript audio.ogg # text + language/confidence
|
|
73
40
|
kesha --format json audio.ogg # full JSON with lang fields
|
|
74
|
-
kesha --
|
|
75
|
-
kesha --toon audio.ogg # compact LLM-friendly TOON (same data as --json)
|
|
41
|
+
kesha --toon audio.ogg # compact LLM-friendly TOON
|
|
76
42
|
kesha --verbose audio.ogg # show language detection details
|
|
77
43
|
kesha --lang en audio.ogg # warn if detected language differs
|
|
78
|
-
kesha --vad lecture.m4a # segment with Silero VAD first (long/silence-heavy audio)
|
|
79
44
|
kesha status # show installed backend info
|
|
80
45
|
```
|
|
81
46
|
|
|
@@ -92,136 +57,61 @@ $ kesha freedom.ogg tahiti.ogg
|
|
|
92
57
|
|
|
93
58
|
Stdout: transcript. Stderr: errors. Pipe-friendly. Also available as `parakeet` command (backward-compatible alias).
|
|
94
59
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
For meetings, lectures, and podcasts, enable Silero VAD so Parakeet only sees the speech bits. Segment boundaries land at natural speech starts/ends instead of arbitrary cuts, and long silences are skipped entirely.
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
kesha install --vad # one-time, ~2.3MB
|
|
101
|
-
kesha lecture.m4a # auto-on when audio ≥ 120s and VAD installed
|
|
102
|
-
kesha --vad short-clip.ogg # force VAD on any input
|
|
103
|
-
kesha --no-vad meeting.m4a # force VAD off even on long audio
|
|
104
|
-
```
|
|
60
|
+
For long / silence-heavy audio, use `--vad` (auto-on past 120 s). Details: [docs/vad.md](docs/vad.md).
|
|
105
61
|
|
|
106
|
-
|
|
62
|
+
## Text-to-speech
|
|
107
63
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Kesha speaks back via Kokoro-82M (English) and Piper (Russian). Voice is auto-picked from the input text's language — `en` routes to Kokoro, `ru` to Piper. Pass `--voice` to override.
|
|
64
|
+
Kesha speaks back via Kokoro-82M (English) and Piper (Russian) — voice auto-picks from the text's language:
|
|
111
65
|
|
|
112
66
|
```bash
|
|
113
|
-
kesha install --tts
|
|
67
|
+
kesha install --tts # ~490MB (Kokoro + Piper RU + ONNX G2P, opt-in)
|
|
114
68
|
kesha say "Hello, world" > hello.wav
|
|
115
|
-
kesha say "Привет, мир" > privet.wav
|
|
116
|
-
echo "long text" | kesha say > reply.wav
|
|
117
|
-
kesha say --out reply.wav "text"
|
|
118
|
-
kesha say --voice en-af_heart "text" # explicit voice overrides auto-routing
|
|
119
|
-
kesha say --list-voices
|
|
69
|
+
kesha say "Привет, мир" > privet.wav # auto-routes to ru-denis
|
|
120
70
|
```
|
|
121
71
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
**Supported voices:**
|
|
125
|
-
- English: `en-af_heart` (default), plus any Kokoro voice you download into `~/.cache/kesha/models/kokoro-82m/voices/`
|
|
126
|
-
- Russian: `ru-denis` (default). More speakers (dmitri, irina, ruslan) are ready to drop in once needed.
|
|
127
|
-
- macOS system voices: `macos-<identifier-or-language>` routes to `AVSpeechSynthesizer`. Zero install, any of the 180+ voices already on your Mac.
|
|
128
|
-
|
|
129
|
-
### macOS system voices
|
|
72
|
+
macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
|
|
130
73
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
kesha say --list-voices | grep ^macos- # discover installed voices
|
|
135
|
-
kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello" > out.wav
|
|
136
|
-
kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav # language-code fallback
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Voice id format: `macos-<id>` where `<id>` is either a full Apple identifier (`com.apple.voice.compact.en-US.Samantha`) or a language code (`en-US`, `ru-RU`) — the Swift helper tries the identifier first and falls back to the language. Output is mono float32 @ 22050 Hz, structurally identical to Piper.
|
|
140
|
-
|
|
141
|
-
Quality tradeoff is honest: macOS system voices are notification-grade. Use them when you want zero-install TTS on macOS; keep Kokoro/Piper for anything that needs to sound good.
|
|
142
|
-
|
|
143
|
-
### SSML (preview)
|
|
74
|
+
## Performance
|
|
144
75
|
|
|
145
|
-
|
|
76
|
+
> **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
|
|
146
77
|
|
|
147
|
-
|
|
148
|
-
kesha say --ssml '<speak>Hello <break time="500ms"/> world.</speak>'
|
|
149
|
-
kesha say --ssml --voice ru-denis '<speak>Привет <break time="1s"/> мир.</speak>'
|
|
150
|
-
```
|
|
78
|
+
Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
|
|
151
79
|
|
|
152
|
-
|
|
153
|
-
|---|---|
|
|
154
|
-
| `<speak>` | ✅ required root |
|
|
155
|
-
| `<break time="Nms"\|"Ns"\|default>` | ✅ inserts silence of the given duration |
|
|
156
|
-
| plain text inside `<speak>` | ✅ synthesized via the selected engine |
|
|
157
|
-
| `<emphasis>`, `<prosody>`, `<phoneme>`, `<say-as>` | ⚠️ stripped with a stderr warning (contained text still synthesized); tracked in [#122](https://github.com/drakulavich/kesha-voice-kit/issues/122) |
|
|
158
|
-
| `<!DOCTYPE>` | ❌ rejected (hardening against XXE) |
|
|
80
|
+

|
|
159
81
|
|
|
160
|
-
|
|
82
|
+
See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + English).
|
|
161
83
|
|
|
162
84
|
## What's Inside
|
|
163
85
|
|
|
164
|
-
Kesha Voice Kit bundles open-source models optimized for on-device inference:
|
|
165
|
-
|
|
166
86
|
| Model | Task | Size | Source |
|
|
167
87
|
|---|---|---|---|
|
|
168
88
|
| NVIDIA Parakeet TDT 0.6B v3 | Speech-to-text | ~2.5GB | [HuggingFace](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) |
|
|
169
89
|
| SpeechBrain ECAPA-TDNN | Audio language detection | ~86MB | [HuggingFace](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa) |
|
|
170
90
|
| Apple NLLanguageRecognizer | Text language detection | built-in | macOS system framework |
|
|
171
91
|
| Silero VAD v5 (opt-in) | Voice activity detection | ~2.3MB | [snakers4/silero-vad](https://github.com/snakers4/silero-vad) |
|
|
92
|
+
| Kokoro-82M / Piper (opt-in) | Text-to-speech | ~490MB | [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M) · [Piper](https://github.com/rhasspy/piper) |
|
|
172
93
|
|
|
173
94
|
All models run through `kesha-engine` — a Rust binary using [FluidAudio](https://github.com/FluidInference/FluidAudio) (CoreML) on Apple Silicon and [ort](https://github.com/pykeio/ort) (ONNX Runtime) on other platforms.
|
|
174
95
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
> **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
|
|
178
|
-
|
|
179
|
-
Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
|
|
180
|
-
|
|
181
|
-

|
|
182
|
-
|
|
183
|
-
<details>
|
|
184
|
-
<summary>Full results with per-file breakdown</summary>
|
|
185
|
-
|
|
186
|
-
See [BENCHMARK.md](BENCHMARK.md) — includes Russian (real voice messages) and English transcription results with all four engines.
|
|
187
|
-
|
|
188
|
-
</details>
|
|
96
|
+
Audio decoding via [symphonia](https://github.com/pdeljanov/Symphonia) — WAV, MP3, OGG/Opus, FLAC, AAC, M4A. No ffmpeg.
|
|
189
97
|
|
|
190
|
-
##
|
|
98
|
+
## Languages
|
|
191
99
|
|
|
192
|
-
|
|
100
|
+
- **Speech-to-text (25):** Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian.
|
|
101
|
+
- **Audio language detection (107):** [full list](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa).
|
|
193
102
|
|
|
194
|
-
|
|
195
|
-
|---|---|
|
|
196
|
-
| WAV | `.wav` |
|
|
197
|
-
| MP3 | `.mp3` |
|
|
198
|
-
| OGG Vorbis/Opus | `.ogg`, `.opus` |
|
|
199
|
-
| FLAC | `.flac` |
|
|
200
|
-
| AAC / M4A | `.aac`, `.m4a` |
|
|
103
|
+
## Integrations
|
|
201
104
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
**Speech-to-text (25):** :bulgaria: Bulgarian, :croatia: Croatian, :czech_republic: Czech, :denmark: Danish, :netherlands: Dutch, :gb: English, :estonia: Estonian, :finland: Finnish, :fr: French, :de: German, :greece: Greek, :hungary: Hungarian, :it: Italian, :latvia: Latvian, :lithuania: Lithuanian, :malta: Maltese, :poland: Polish, :portugal: Portuguese, :romania: Romanian, :ru: Russian, :slovakia: Slovak, :slovenia: Slovenian, :es: Spanish, :sweden: Swedish, :ukraine: Ukrainian
|
|
205
|
-
|
|
206
|
-
**Audio language detection (107):** Full list at [speechbrain/lang-id-voxlingua107-ecapa](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa)
|
|
207
|
-
|
|
208
|
-
## Architecture
|
|
209
|
-
|
|
210
|
-
```
|
|
211
|
-
kesha audio.ogg
|
|
212
|
-
→ kesha-engine (Rust binary)
|
|
213
|
-
├── Apple Silicon? → FluidAudio (CoreML / Neural Engine)
|
|
214
|
-
└── Other? → ort (ONNX Runtime / CPU)
|
|
215
|
-
→ transcript to stdout
|
|
216
|
-
```
|
|
105
|
+
- **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
|
|
106
|
+
- **Raycast** (macOS) — transcribe selected audio & speak clipboard from the launcher. Source + install: [`raycast/`](raycast/).
|
|
217
107
|
|
|
218
108
|
## Programmatic API
|
|
219
109
|
|
|
220
110
|
```typescript
|
|
221
111
|
import { transcribe, downloadModel } from "@drakulavich/kesha-voice-kit/core";
|
|
222
112
|
|
|
223
|
-
await downloadModel();
|
|
224
|
-
const text = await transcribe("audio.ogg");
|
|
113
|
+
await downloadModel(); // install engine + models
|
|
114
|
+
const text = await transcribe("audio.ogg"); // transcribe
|
|
225
115
|
```
|
|
226
116
|
|
|
227
117
|
## Requirements
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "Open-source voice toolkit
|
|
3
|
+
"version": "1.4.3",
|
|
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 + Piper + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"kesha": "bin/kesha.js",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"test": "bun test",
|
|
37
37
|
"test:unit": "bun test tests/unit/",
|
|
38
38
|
"test:integration": "bun test tests/integration/",
|
|
39
|
+
"lint": "bunx tsc --noEmit",
|
|
40
|
+
"lint:tsgo": "bunx tsgo --noEmit",
|
|
39
41
|
"postinstall": "node scripts/postinstall.cjs"
|
|
40
42
|
},
|
|
41
43
|
"keywords": [
|
|
@@ -65,6 +67,7 @@
|
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
|
67
69
|
"@types/bun": "latest",
|
|
70
|
+
"@typescript/native-preview": "^7.0.0-dev.20260423.1",
|
|
68
71
|
"fast-xml-parser": "^5.5.10",
|
|
69
72
|
"typescript": "^6.0.2"
|
|
70
73
|
},
|
package/src/status.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
-
import { readdirSync } from "fs";
|
|
1
|
+
import { readdirSync, statSync } from "fs";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
|
|
5
5
|
import { log } from "./log";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
8
|
+
function humanBytes(bytes: number): string {
|
|
9
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
10
|
+
const units = ["KB", "MB", "GB"];
|
|
11
|
+
let n = bytes / 1024;
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
14
|
+
n /= 1024;
|
|
15
|
+
i++;
|
|
16
|
+
}
|
|
17
|
+
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dirSizeBytes(path: string): number {
|
|
21
|
+
let total = 0;
|
|
22
|
+
try {
|
|
23
|
+
const st = statSync(path);
|
|
24
|
+
if (st.isFile()) return st.size;
|
|
25
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
26
|
+
const p = join(path, entry.name);
|
|
27
|
+
total += entry.isDirectory() ? dirSizeBytes(p) : statSync(p).size;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
/* missing path — component not installed */
|
|
31
|
+
}
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
export function formatStatusLine(
|
|
9
36
|
label: string,
|
|
10
37
|
path: string | null,
|
|
@@ -53,6 +80,8 @@ export async function showStatus(): Promise<void> {
|
|
|
53
80
|
}
|
|
54
81
|
log.info("");
|
|
55
82
|
}
|
|
83
|
+
|
|
84
|
+
showDiskUsage(binPath);
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
if (!installed) {
|
|
@@ -60,6 +89,62 @@ export async function showStatus(): Promise<void> {
|
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
|
|
92
|
+
function showDiskUsage(binPath: string): void {
|
|
93
|
+
const cache = kesheCacheDir();
|
|
94
|
+
// Engine binary lives under `<cache>/engine/bin/` (managed by the TS CLI's
|
|
95
|
+
// engine-install) while all models live under `<cache>/models/` (managed by
|
|
96
|
+
// the Rust engine). Point at `engine/` (two levels up from the binary) so
|
|
97
|
+
// any future sibling files under that root (metadata, hooks, etc.) are
|
|
98
|
+
// counted too.
|
|
99
|
+
const engineDir = join(binPath, "..", "..");
|
|
100
|
+
|
|
101
|
+
const components: Array<{ label: string; path: string }> = [
|
|
102
|
+
{ label: "Engine", path: engineDir },
|
|
103
|
+
{ label: "ASR (Parakeet)", path: join(cache, "models/parakeet-tdt-v3") },
|
|
104
|
+
{ label: "Language ID", path: join(cache, "models/lang-id-ecapa") },
|
|
105
|
+
{ label: "VAD (Silero)", path: join(cache, "models/silero-vad") },
|
|
106
|
+
{ label: "TTS (Kokoro)", path: join(cache, "models/kokoro-82m") },
|
|
107
|
+
{ label: "TTS (Piper)", path: join(cache, "models/piper-ru") },
|
|
108
|
+
{ label: "TTS (G2P)", path: join(cache, "models/g2p") },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const rows: Array<{ label: string; size: number }> = [];
|
|
112
|
+
for (const c of components) {
|
|
113
|
+
const size = dirSizeBytes(c.path);
|
|
114
|
+
if (size > 0) rows.push({ label: c.label, size });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (rows.length === 0) return;
|
|
118
|
+
|
|
119
|
+
// Total counts everything under both the model cache root AND the engine
|
|
120
|
+
// dir (which may live outside the cache when `KESHA_ENGINE_BIN` overrides
|
|
121
|
+
// the default layout). That way the number matches what the `rm -rf` hint
|
|
122
|
+
// below would actually free, including any temp downloads or future
|
|
123
|
+
// components not in the per-row list.
|
|
124
|
+
const componentTotal = rows.reduce((n, r) => n + r.size, 0);
|
|
125
|
+
const cacheTotal = dirSizeBytes(cache);
|
|
126
|
+
const engineOutsideCache = engineDir.startsWith(cache)
|
|
127
|
+
? 0
|
|
128
|
+
: dirSizeBytes(engineDir);
|
|
129
|
+
const total = cacheTotal + engineOutsideCache;
|
|
130
|
+
|
|
131
|
+
log.info(`Disk usage (${cache}):`);
|
|
132
|
+
const labelWidth = Math.max(...rows.map((r) => r.label.length), "Total".length);
|
|
133
|
+
for (const r of rows) {
|
|
134
|
+
const pad = " ".repeat(labelWidth - r.label.length + 2);
|
|
135
|
+
log.info(` ${r.label}:${pad}${humanBytes(r.size)}`);
|
|
136
|
+
}
|
|
137
|
+
const totalPad = " ".repeat(labelWidth - "Total".length + 2);
|
|
138
|
+
log.info(` ${pc.bold("Total")}:${totalPad}${pc.bold(humanBytes(total))}`);
|
|
139
|
+
if (total > componentTotal) {
|
|
140
|
+
const other = total - componentTotal;
|
|
141
|
+
log.info(pc.dim(` (includes ${humanBytes(other)} of other cache files)`));
|
|
142
|
+
}
|
|
143
|
+
log.info("");
|
|
144
|
+
log.info(pc.dim(` To reset cache: rm -rf ${cache} — next \`kesha install\` re-downloads.`));
|
|
145
|
+
log.info("");
|
|
146
|
+
}
|
|
147
|
+
|
|
63
148
|
function kesheCacheDir(): string {
|
|
64
149
|
return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
|
|
65
150
|
}
|