@crafter/trx 0.1.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/bin/trx.ts +35 -0
- package/biome.json +6 -0
- package/package.json +42 -0
- package/schemas/init.json +45 -0
- package/schemas/transcribe.json +77 -0
- package/skills/trx/SKILL.md +108 -0
- package/skills/trx/references/whisper-fixes.md +88 -0
- package/src/commands/doctor.ts +90 -0
- package/src/commands/init.ts +171 -0
- package/src/commands/schema.ts +24 -0
- package/src/commands/transcribe.ts +131 -0
- package/src/core/audio.ts +28 -0
- package/src/core/download.ts +37 -0
- package/src/core/pipeline.ts +71 -0
- package/src/core/whisper.ts +67 -0
- package/src/utils/config.ts +72 -0
- package/src/utils/output.ts +49 -0
- package/src/utils/spawn.ts +27 -0
- package/src/validation/input.ts +189 -0
- package/tests/e2e.test.ts +373 -0
- package/tests/fixtures/silence.txt +2 -0
- package/tests/fixtures/silence.wav +0 -0
- package/tests/fixtures/silence.wav.srt +4 -0
- package/tsconfig.json +19 -0
package/bin/trx.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createDoctorCommand } from "../src/commands/doctor.ts";
|
|
4
|
+
import { createInitCommand } from "../src/commands/init.ts";
|
|
5
|
+
import { createSchemaCommand } from "../src/commands/schema.ts";
|
|
6
|
+
import { createTranscribeCommand } from "../src/commands/transcribe.ts";
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("trx")
|
|
12
|
+
.description("Agent-first CLI for audio/video transcription via Whisper")
|
|
13
|
+
.version("0.1.0")
|
|
14
|
+
.option("-o, --output <format>", "output format (json, table, auto)", "auto")
|
|
15
|
+
.hook("preAction", (thisCommand) => {
|
|
16
|
+
const opts = thisCommand.opts();
|
|
17
|
+
if (opts.output === "auto") {
|
|
18
|
+
opts.output = process.stdout.isTTY ? "table" : "json";
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
program.addCommand(createInitCommand());
|
|
23
|
+
program.addCommand(createTranscribeCommand());
|
|
24
|
+
program.addCommand(createDoctorCommand());
|
|
25
|
+
program.addCommand(createSchemaCommand());
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const subcommands = ["init", "transcribe", "doctor", "schema", "help", "--help", "-h", "--version", "-V"];
|
|
29
|
+
const firstArg = args[0];
|
|
30
|
+
|
|
31
|
+
if (firstArg && !firstArg.startsWith("-") && !subcommands.includes(firstArg)) {
|
|
32
|
+
process.argv.splice(2, 0, "transcribe");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
program.parse();
|
package/biome.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
|
3
|
+
"linter": { "enabled": true, "rules": { "recommended": true } },
|
|
4
|
+
"formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 120 },
|
|
5
|
+
"assist": { "actions": { "source": { "organizeImports": { "level": "on" } } } }
|
|
6
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crafter/trx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent-first CLI for audio/video transcription via Whisper",
|
|
5
|
+
"module": "bin/trx.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/crafter-station/trx",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/crafter-station/trx"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"transcription",
|
|
15
|
+
"whisper",
|
|
16
|
+
"stt",
|
|
17
|
+
"speech-to-text",
|
|
18
|
+
"cli",
|
|
19
|
+
"agent",
|
|
20
|
+
"subtitles",
|
|
21
|
+
"srt",
|
|
22
|
+
"audio",
|
|
23
|
+
"video",
|
|
24
|
+
"yt-dlp",
|
|
25
|
+
"ffmpeg"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"trx": "bin/trx.ts"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "bun run bin/trx.ts",
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"check": "biome check --write ."
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@clack/prompts": "^1.1.0",
|
|
37
|
+
"commander": "^14.0.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "latest"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"command": "init",
|
|
3
|
+
"description": "Install dependencies (whisper-cli, yt-dlp, ffmpeg) and download Whisper model",
|
|
4
|
+
"flags": {
|
|
5
|
+
"--model": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"enum": ["tiny", "base", "small", "medium", "large"],
|
|
8
|
+
"default": "small",
|
|
9
|
+
"description": "Whisper model size to download"
|
|
10
|
+
},
|
|
11
|
+
"--language": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "auto",
|
|
14
|
+
"description": "Default language for transcription (ISO 639-1 code or 'auto')"
|
|
15
|
+
},
|
|
16
|
+
"--output": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"enum": ["json", "table", "auto"],
|
|
19
|
+
"default": "auto",
|
|
20
|
+
"description": "Output format"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"whisper-cli": {
|
|
25
|
+
"install": "brew install whisper-cpp",
|
|
26
|
+
"purpose": "Local speech-to-text transcription engine"
|
|
27
|
+
},
|
|
28
|
+
"yt-dlp": {
|
|
29
|
+
"install": "brew install yt-dlp",
|
|
30
|
+
"purpose": "Download video/audio from URLs (YouTube, Twitter, etc.)"
|
|
31
|
+
},
|
|
32
|
+
"ffmpeg": {
|
|
33
|
+
"install": "brew install ffmpeg",
|
|
34
|
+
"purpose": "Audio cleaning, conversion, silence removal, noise reduction"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"output": {
|
|
38
|
+
"success": "boolean",
|
|
39
|
+
"model": "string",
|
|
40
|
+
"language": "string",
|
|
41
|
+
"modelPath": "string",
|
|
42
|
+
"config": "TrxConfig object"
|
|
43
|
+
},
|
|
44
|
+
"examples": ["trx init", "trx init --model small --language es", "trx init --model large --output json"]
|
|
45
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"command": "transcribe",
|
|
3
|
+
"description": "Transcribe audio/video from URL or local file using Whisper",
|
|
4
|
+
"arguments": {
|
|
5
|
+
"input": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"required": true,
|
|
8
|
+
"description": "URL (https://...) or local file path (.mp4, .m4a, .ogg, .wav, .webm, .mkv, .avi, .mov, .flac, .mp3)"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"flags": {
|
|
12
|
+
"--language": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "auto",
|
|
15
|
+
"description": "ISO 639-1 language code or 'auto' for auto-detection"
|
|
16
|
+
},
|
|
17
|
+
"--model": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"enum": ["tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large"],
|
|
20
|
+
"description": "Override whisper model size"
|
|
21
|
+
},
|
|
22
|
+
"--output": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["json", "table", "auto"],
|
|
25
|
+
"default": "auto",
|
|
26
|
+
"description": "Output format. 'auto' uses 'table' for TTY, 'json' when piped"
|
|
27
|
+
},
|
|
28
|
+
"--fields": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Comma-separated fields to include: text, srt, metadata, files"
|
|
31
|
+
},
|
|
32
|
+
"--dry-run": {
|
|
33
|
+
"type": "boolean",
|
|
34
|
+
"default": false,
|
|
35
|
+
"description": "Validate input and show execution plan without transcribing"
|
|
36
|
+
},
|
|
37
|
+
"--json": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Raw JSON payload: {\"input\": \"...\", \"language\": \"...\", \"model\": \"...\"}"
|
|
40
|
+
},
|
|
41
|
+
"--output-dir": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"default": ".",
|
|
44
|
+
"description": "Directory for output files"
|
|
45
|
+
},
|
|
46
|
+
"--no-download": {
|
|
47
|
+
"type": "boolean",
|
|
48
|
+
"default": false,
|
|
49
|
+
"description": "Skip yt-dlp download step (input must be local file)"
|
|
50
|
+
},
|
|
51
|
+
"--no-clean": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"default": false,
|
|
54
|
+
"description": "Skip ffmpeg audio cleaning/normalization"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"output": {
|
|
58
|
+
"success": "boolean",
|
|
59
|
+
"input": "string",
|
|
60
|
+
"files": {
|
|
61
|
+
"wav": "string (path)",
|
|
62
|
+
"srt": "string (path)",
|
|
63
|
+
"txt": "string (path)"
|
|
64
|
+
},
|
|
65
|
+
"metadata": {
|
|
66
|
+
"language": "string",
|
|
67
|
+
"model": "string"
|
|
68
|
+
},
|
|
69
|
+
"text": "string (full transcript)"
|
|
70
|
+
},
|
|
71
|
+
"examples": [
|
|
72
|
+
"trx transcribe recording.mp4 --output json",
|
|
73
|
+
"trx transcribe https://youtube.com/watch?v=abc --language es --output json",
|
|
74
|
+
"trx transcribe video.mp4 --fields text --output json",
|
|
75
|
+
"trx transcribe video.mp4 --dry-run --output json"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trx
|
|
3
|
+
description: |
|
|
4
|
+
Transcribe audio/video using trx CLI and post-process results with agent corrections.
|
|
5
|
+
Use when: (1) user wants to transcribe a video or audio file, (2) user shares a
|
|
6
|
+
YouTube/Twitter/Instagram URL for transcription, (3) user says "transcribe",
|
|
7
|
+
"subtitles", "srt", "transcript", (4) user wants to fix/clean up a whisper
|
|
8
|
+
transcription, (5) user asks to extract text from a video.
|
|
9
|
+
metadata:
|
|
10
|
+
author: Railly Hugo
|
|
11
|
+
version: "0.1.0"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# trx -- Agent-First Transcription CLI
|
|
15
|
+
|
|
16
|
+
Install: `npx skills add crafter-station/trx -g`
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
Check setup: `trx doctor --output json`. If dependencies missing, run `trx init`.
|
|
21
|
+
|
|
22
|
+
Install: `bun add -g @crafter/trx`
|
|
23
|
+
|
|
24
|
+
## Workflow
|
|
25
|
+
|
|
26
|
+
### 1. Dry-run first (always)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
trx transcribe <input> --dry-run --output json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Validates input, checks dependencies, shows execution plan without running.
|
|
33
|
+
|
|
34
|
+
### 2. Transcribe
|
|
35
|
+
|
|
36
|
+
For URLs (YouTube, Twitter, Instagram, etc.):
|
|
37
|
+
```bash
|
|
38
|
+
trx transcribe "https://youtube.com/watch?v=..." --output json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local files:
|
|
42
|
+
```bash
|
|
43
|
+
trx transcribe ./recording.mp4 --output json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Agent-optimized (text only, saves tokens):
|
|
47
|
+
```bash
|
|
48
|
+
trx transcribe <input> --fields text --output json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Post-process (fix whisper mistakes)
|
|
52
|
+
|
|
53
|
+
After transcription, read the `.txt` output and apply corrections. Read [whisper-fixes.md](references/whisper-fixes.md) for common patterns.
|
|
54
|
+
|
|
55
|
+
**Correction checklist:**
|
|
56
|
+
1. **Punctuation**: Whisper drops periods at paragraph boundaries and misplaces commas. Fix sentence boundaries.
|
|
57
|
+
2. **Accents** (Spanish): Whisper often drops diacritics. Restore: como -> como/cmo, esta -> esta/est, mas -> mas/ms.
|
|
58
|
+
3. **Technical terms**: Whisper misspells domain-specific words. Ask user for a glossary or infer from context.
|
|
59
|
+
4. **Repeated phrases**: Whisper sometimes stutters on word boundaries. Remove exact consecutive duplicates.
|
|
60
|
+
5. **Speaker attribution**: If user provides speaker names, insert `[Speaker Name]:` markers.
|
|
61
|
+
6. **Filler words**: Remove "um", "uh", "este", "o sea" if user wants clean output.
|
|
62
|
+
7. **Timestamp alignment**: If editing `.srt`, preserve the timestamp structure. Only modify text between timestamps.
|
|
63
|
+
|
|
64
|
+
### 4. Schema introspection
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
trx schema transcribe
|
|
68
|
+
trx schema init
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
| Command | Example |
|
|
74
|
+
|---------|---------|
|
|
75
|
+
| `init` | `trx init --model small` |
|
|
76
|
+
| `transcribe` | `trx transcribe <url-or-file> --output json` |
|
|
77
|
+
| `doctor` | `trx doctor --output json` |
|
|
78
|
+
| `schema` | `trx schema transcribe` |
|
|
79
|
+
|
|
80
|
+
## Shorthand
|
|
81
|
+
|
|
82
|
+
`trx <input>` is equivalent to `trx transcribe <input>`.
|
|
83
|
+
|
|
84
|
+
## Output format
|
|
85
|
+
|
|
86
|
+
- `--output json`: Machine-readable (default when piped)
|
|
87
|
+
- `--output table`: Human-readable with progress (default when TTY)
|
|
88
|
+
- `--fields text`: Only return transcript text (saves tokens)
|
|
89
|
+
- `--fields metadata`: Only return metadata (language, model)
|
|
90
|
+
- `--dry-run`: Validate without executing
|
|
91
|
+
|
|
92
|
+
## Flags reference
|
|
93
|
+
|
|
94
|
+
| Flag | Description | Default |
|
|
95
|
+
|------|-------------|---------|
|
|
96
|
+
| `--language <code>` | ISO 639-1 language code | `auto` (from config) |
|
|
97
|
+
| `--model <size>` | Override model: tiny, base, small, medium, large | from config |
|
|
98
|
+
| `--output-dir <dir>` | Output directory | `.` (cwd) |
|
|
99
|
+
| `--no-download` | Skip yt-dlp (local files only) | false |
|
|
100
|
+
| `--no-clean` | Skip ffmpeg audio cleaning | false |
|
|
101
|
+
| `--json <payload>` | Raw JSON input | - |
|
|
102
|
+
|
|
103
|
+
## Edge cases
|
|
104
|
+
|
|
105
|
+
- **yt-dlp extension mismatch**: yt-dlp sometimes outputs `.mp4.webm` instead of `.mp4`. The CLI handles this by scanning for the downloaded file by prefix.
|
|
106
|
+
- **Large files (>1hr)**: Whisper processes in segments. Works but is slow on CPU. Consider `--model tiny` for speed.
|
|
107
|
+
- **No GPU**: whisper-cli uses CPU by default. Acceptable for tiny/base/small models.
|
|
108
|
+
- **Auto-detect language**: When `--language auto`, Whisper detects the language from the first 30 seconds. For multilingual content, specify the primary language.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Common Whisper Transcription Mistakes
|
|
2
|
+
|
|
3
|
+
Reference for post-processing agent corrections. Grouped by language and category.
|
|
4
|
+
|
|
5
|
+
## Spanish
|
|
6
|
+
|
|
7
|
+
### Accent marks (most common)
|
|
8
|
+
Whisper frequently drops diacritics. Restore based on grammatical context:
|
|
9
|
+
- "como" -> "cmo" (when meaning "how/what")
|
|
10
|
+
- "esta" -> "est" (when it's a verb, not demonstrative)
|
|
11
|
+
- "mas" -> "ms" (when meaning "more", not "but")
|
|
12
|
+
- "si" -> "s" (when meaning "yes", not "if")
|
|
13
|
+
- "el" -> "l" (when it's a pronoun, not article)
|
|
14
|
+
- "que" -> "qu" (in questions: "Qu haces?")
|
|
15
|
+
- "cuando" -> "cundo" (in questions)
|
|
16
|
+
- "numero" -> "nmero"
|
|
17
|
+
- "tambien" -> "tambin"
|
|
18
|
+
- "informacion" -> "informacin"
|
|
19
|
+
|
|
20
|
+
### Question/exclamation marks
|
|
21
|
+
Whisper almost never generates opening marks:
|
|
22
|
+
- Add "" at the start of questions
|
|
23
|
+
- Add "" at the start of exclamations
|
|
24
|
+
|
|
25
|
+
### Run-on sentences
|
|
26
|
+
Whisper often produces long sentences without periods. Split when:
|
|
27
|
+
- Topic changes
|
|
28
|
+
- Speaker changes
|
|
29
|
+
- Natural pause in the audio (check SRT timestamps for gaps > 1.5s)
|
|
30
|
+
|
|
31
|
+
### Common confusions
|
|
32
|
+
- "coma" vs "como" (comma vs how)
|
|
33
|
+
- "haber" vs "a ver" (to have vs let's see)
|
|
34
|
+
- "echo" vs "hecho" (thrown vs done/fact)
|
|
35
|
+
- "hay" vs "ah" vs "ay" (there is vs interjection)
|
|
36
|
+
|
|
37
|
+
## English
|
|
38
|
+
|
|
39
|
+
### Homophones
|
|
40
|
+
- "their" / "there" / "they're"
|
|
41
|
+
- "your" / "you're"
|
|
42
|
+
- "its" / "it's"
|
|
43
|
+
- "to" / "too" / "two"
|
|
44
|
+
- "then" / "than"
|
|
45
|
+
|
|
46
|
+
### Capitalization
|
|
47
|
+
Whisper inconsistently capitalizes:
|
|
48
|
+
- Proper nouns (names, companies, places)
|
|
49
|
+
- Sentence beginnings after periods
|
|
50
|
+
- Acronyms (API, CLI, AI, ML)
|
|
51
|
+
|
|
52
|
+
### Technical terms (common misspellings)
|
|
53
|
+
- "typescript" -> "TypeScript"
|
|
54
|
+
- "javascript" -> "JavaScript"
|
|
55
|
+
- "react" -> "React" (when referring to the framework)
|
|
56
|
+
- "next js" / "nextjs" -> "Next.js"
|
|
57
|
+
- "node js" -> "Node.js"
|
|
58
|
+
- "github" -> "GitHub"
|
|
59
|
+
- "vercel" -> "Vercel"
|
|
60
|
+
- "anthropic" -> "Anthropic"
|
|
61
|
+
- "openai" -> "OpenAI"
|
|
62
|
+
- "kubernetes" -> "Kubernetes"
|
|
63
|
+
- "docker" -> "Docker"
|
|
64
|
+
|
|
65
|
+
### Filler words (remove if user wants clean output)
|
|
66
|
+
- "um", "uh", "like", "you know", "I mean", "basically", "actually", "right"
|
|
67
|
+
|
|
68
|
+
## Both Languages
|
|
69
|
+
|
|
70
|
+
### Repeated words
|
|
71
|
+
Whisper sometimes outputs the same word/phrase twice at segment boundaries:
|
|
72
|
+
```
|
|
73
|
+
the the quick brown fox
|
|
74
|
+
```
|
|
75
|
+
Remove exact consecutive duplicates.
|
|
76
|
+
|
|
77
|
+
### Numbers
|
|
78
|
+
Whisper alternates between spelled-out and numeric forms inconsistently:
|
|
79
|
+
- Prefer numeric for: dates, times, measurements, code references
|
|
80
|
+
- Prefer spelled-out for: small numbers in natural speech (one, two, three)
|
|
81
|
+
|
|
82
|
+
### Timestamps (SRT editing rules)
|
|
83
|
+
When editing .srt files:
|
|
84
|
+
1. Never modify timestamp lines (lines with `-->`)
|
|
85
|
+
2. Never modify sequence numbers
|
|
86
|
+
3. Only edit the text content between timestamps
|
|
87
|
+
4. Keep the same number of subtitle blocks
|
|
88
|
+
5. Preserve blank lines between blocks
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { getConfigPath, getModelsDir, readConfig } from "../utils/config.ts";
|
|
4
|
+
import { type OutputFormat, output, outputError } from "../utils/output.ts";
|
|
5
|
+
import { spawn } from "../utils/spawn.ts";
|
|
6
|
+
|
|
7
|
+
interface DepStatus {
|
|
8
|
+
installed: boolean;
|
|
9
|
+
version: string | null;
|
|
10
|
+
path: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function checkBinary(name: string): Promise<DepStatus> {
|
|
14
|
+
const which = await spawn(["which", name]);
|
|
15
|
+
if (which.exitCode !== 0) {
|
|
16
|
+
return { installed: false, version: null, path: null };
|
|
17
|
+
}
|
|
18
|
+
const binPath = which.stdout.trim();
|
|
19
|
+
|
|
20
|
+
const ver = await spawn([name, "--version"]);
|
|
21
|
+
const version = ver.exitCode === 0 ? ver.stdout.split("\n")[0].trim() : null;
|
|
22
|
+
|
|
23
|
+
return { installed: true, version, path: binPath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createDoctorCommand(): Command {
|
|
27
|
+
return new Command("doctor").description("Check dependencies and configuration status").action(async (_, cmd) => {
|
|
28
|
+
const format: OutputFormat = cmd.optsWithGlobals().output;
|
|
29
|
+
|
|
30
|
+
const [whisper, ytdlp, ffmpeg] = await Promise.all([
|
|
31
|
+
checkBinary("whisper-cli"),
|
|
32
|
+
checkBinary("yt-dlp"),
|
|
33
|
+
checkBinary("ffmpeg"),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const config = readConfig();
|
|
37
|
+
const configPath = getConfigPath();
|
|
38
|
+
const modelsDir = getModelsDir();
|
|
39
|
+
const modelExists = config ? existsSync(config.modelPath) : false;
|
|
40
|
+
|
|
41
|
+
const allInstalled = whisper.installed && ytdlp.installed && ffmpeg.installed;
|
|
42
|
+
|
|
43
|
+
const data = {
|
|
44
|
+
healthy: allInstalled && !!config && modelExists,
|
|
45
|
+
dependencies: { "whisper-cli": whisper, "yt-dlp": ytdlp, ffmpeg },
|
|
46
|
+
config: {
|
|
47
|
+
exists: !!config,
|
|
48
|
+
path: configPath,
|
|
49
|
+
modelsDir,
|
|
50
|
+
...(config
|
|
51
|
+
? {
|
|
52
|
+
modelSize: config.modelSize,
|
|
53
|
+
modelPath: config.modelPath,
|
|
54
|
+
modelExists,
|
|
55
|
+
language: config.language,
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (format === "json") {
|
|
62
|
+
output(format, { json: data });
|
|
63
|
+
} else {
|
|
64
|
+
console.log("\ntrx doctor\n");
|
|
65
|
+
const deps = [
|
|
66
|
+
["whisper-cli", whisper],
|
|
67
|
+
["yt-dlp", ytdlp],
|
|
68
|
+
["ffmpeg", ffmpeg],
|
|
69
|
+
] as const;
|
|
70
|
+
for (const [name, dep] of deps) {
|
|
71
|
+
const status = dep.installed ? "\u2713" : "\u2717";
|
|
72
|
+
const ver = dep.version ? ` (${dep.version})` : "";
|
|
73
|
+
console.log(` ${status} ${name}${ver}`);
|
|
74
|
+
}
|
|
75
|
+
console.log();
|
|
76
|
+
if (config) {
|
|
77
|
+
console.log(` Config: ${configPath}`);
|
|
78
|
+
console.log(` Model: ${config.modelSize} ${modelExists ? "\u2713" : "\u2717 (not downloaded)"}`);
|
|
79
|
+
console.log(` Language: ${config.language}`);
|
|
80
|
+
} else {
|
|
81
|
+
console.log(' Config: not found. Run "trx init" to set up.');
|
|
82
|
+
}
|
|
83
|
+
console.log();
|
|
84
|
+
|
|
85
|
+
if (!allInstalled) {
|
|
86
|
+
outputError('Missing dependencies. Run "trx init" to install.', "table");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { defaultConfig, ensureTrxDir, getModelsDir, writeConfig } from "../utils/config.ts";
|
|
5
|
+
import { type OutputFormat, output, outputError } from "../utils/output.ts";
|
|
6
|
+
import { spawn, spawnOrThrow } from "../utils/spawn.ts";
|
|
7
|
+
import { validateLanguage, validateModel } from "../validation/input.ts";
|
|
8
|
+
|
|
9
|
+
const MODELS = [
|
|
10
|
+
{ value: "tiny", label: "tiny (~75 MB)", hint: "fastest, lowest accuracy" },
|
|
11
|
+
{ value: "base", label: "base (~142 MB)", hint: "fast, decent accuracy" },
|
|
12
|
+
{ value: "small", label: "small (~466 MB)", hint: "balanced speed/accuracy (recommended)" },
|
|
13
|
+
{ value: "medium", label: "medium (~1.5 GB)", hint: "slow, high accuracy" },
|
|
14
|
+
{ value: "large", label: "large (~3 GB)", hint: "slowest, best accuracy" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const HF_BASE = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
|
|
18
|
+
|
|
19
|
+
async function checkAndInstallDep(name: string, brewPackage: string, isTTY: boolean): Promise<boolean> {
|
|
20
|
+
const which = await spawn(["which", name]);
|
|
21
|
+
if (which.exitCode === 0) return true;
|
|
22
|
+
|
|
23
|
+
if (!isTTY) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const install = await p.confirm({
|
|
28
|
+
message: `${name} is not installed. Install via Homebrew (brew install ${brewPackage})?`,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (p.isCancel(install) || !install) {
|
|
32
|
+
p.log.warn(`Skipped ${name}. Install manually: brew install ${brewPackage}`);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
p.log.step(`Installing ${brewPackage}...`);
|
|
38
|
+
await spawnOrThrow(["brew", "install", brewPackage], `brew install ${brewPackage}`);
|
|
39
|
+
p.log.success(`${name} installed`);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
p.log.error(`Failed to install ${brewPackage}: ${(e as Error).message}`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function downloadModel(modelSize: string, modelsDir: string, isTTY: boolean): Promise<string> {
|
|
48
|
+
const modelFile = `ggml-${modelSize}.bin`;
|
|
49
|
+
const modelPath = `${modelsDir}/${modelFile}`;
|
|
50
|
+
|
|
51
|
+
if (existsSync(modelPath)) {
|
|
52
|
+
if (isTTY) p.log.success(`Model ${modelSize} already downloaded`);
|
|
53
|
+
return modelPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const url = `${HF_BASE}/${modelFile}`;
|
|
57
|
+
if (isTTY) p.log.step(`Downloading ${modelFile} from Hugging Face...`);
|
|
58
|
+
|
|
59
|
+
await spawnOrThrow(["curl", "-L", "--progress-bar", "-o", modelPath, url], `Download model ${modelSize}`);
|
|
60
|
+
|
|
61
|
+
if (!existsSync(modelPath)) {
|
|
62
|
+
throw new Error(`Model download completed but file not found: ${modelPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return modelPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function installSkill(isTTY: boolean): Promise<boolean> {
|
|
69
|
+
if (!isTTY) return false;
|
|
70
|
+
|
|
71
|
+
const install = await p.confirm({
|
|
72
|
+
message: "Install agent skill? (lets AI agents use trx with post-processing)",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (p.isCancel(install) || !install) {
|
|
76
|
+
p.log.info("Skipped. Install later: npx skills add crafter-station/trx -g");
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const proc = Bun.spawn(["npx", "skills", "add", "crafter-station/trx", "-g"], {
|
|
82
|
+
stdin: "inherit",
|
|
83
|
+
stdout: "inherit",
|
|
84
|
+
stderr: "inherit",
|
|
85
|
+
});
|
|
86
|
+
const exitCode = await proc.exited;
|
|
87
|
+
return exitCode === 0;
|
|
88
|
+
} catch {
|
|
89
|
+
p.log.warn("npx skills not available. Install manually: npx skills add crafter-station/trx -g");
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createInitCommand(): Command {
|
|
95
|
+
return new Command("init")
|
|
96
|
+
.description("Install dependencies and download Whisper model")
|
|
97
|
+
.option("-m, --model <size>", "whisper model size", "small")
|
|
98
|
+
.option("-l, --language <code>", "default language", "auto")
|
|
99
|
+
.action(async (opts, cmd) => {
|
|
100
|
+
const format: OutputFormat = cmd.optsWithGlobals().output;
|
|
101
|
+
const isTTY = process.stdout.isTTY && format !== "json";
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const modelSize = validateModel(opts.model);
|
|
105
|
+
const language = validateLanguage(opts.language);
|
|
106
|
+
|
|
107
|
+
if (isTTY) {
|
|
108
|
+
p.intro("trx init");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ensureTrxDir();
|
|
112
|
+
|
|
113
|
+
if (isTTY) p.log.step("Checking dependencies...");
|
|
114
|
+
|
|
115
|
+
const [hasWhisper, hasYtdlp, hasFfmpeg] = await Promise.all([
|
|
116
|
+
checkAndInstallDep("whisper-cli", "whisper-cpp", isTTY),
|
|
117
|
+
checkAndInstallDep("yt-dlp", "yt-dlp", isTTY),
|
|
118
|
+
checkAndInstallDep("ffmpeg", "ffmpeg", isTTY),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
if (!hasWhisper || !hasYtdlp || !hasFfmpeg) {
|
|
122
|
+
const missing = [!hasWhisper && "whisper-cli", !hasYtdlp && "yt-dlp", !hasFfmpeg && "ffmpeg"]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join(", ");
|
|
125
|
+
outputError(`Missing dependencies: ${missing}`, format);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let selectedModel = modelSize;
|
|
130
|
+
if (isTTY && !cmd.getOptionValueSource("model")) {
|
|
131
|
+
const choice = await p.select({
|
|
132
|
+
message: "Select Whisper model:",
|
|
133
|
+
options: MODELS,
|
|
134
|
+
initialValue: "small",
|
|
135
|
+
});
|
|
136
|
+
if (p.isCancel(choice)) {
|
|
137
|
+
p.cancel("Init cancelled");
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
selectedModel = validateModel(choice as string);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const modelsDir = getModelsDir();
|
|
144
|
+
const modelPath = await downloadModel(selectedModel, modelsDir, isTTY);
|
|
145
|
+
|
|
146
|
+
const config = defaultConfig(selectedModel, language);
|
|
147
|
+
config.modelPath = modelPath;
|
|
148
|
+
writeConfig(config);
|
|
149
|
+
|
|
150
|
+
if (isTTY) p.log.step("Agent skill setup...");
|
|
151
|
+
const skillInstalled = await installSkill(isTTY);
|
|
152
|
+
|
|
153
|
+
if (isTTY) {
|
|
154
|
+
p.outro("trx is ready. Run: trx <url-or-file>");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
output(format, {
|
|
158
|
+
json: {
|
|
159
|
+
success: true,
|
|
160
|
+
model: selectedModel,
|
|
161
|
+
language,
|
|
162
|
+
modelPath,
|
|
163
|
+
skillInstalled,
|
|
164
|
+
config,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
} catch (e) {
|
|
168
|
+
outputError((e as Error).message, format);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|