whspr 1.0.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/.claude/settings.json +32 -0
- package/CLAUDE.md +61 -0
- package/README.md +69 -0
- package/bin/whspr.js +2 -0
- package/package.json +34 -0
- package/src/index.ts +132 -0
- package/src/postprocess.ts +37 -0
- package/src/recorder.ts +248 -0
- package/src/transcribe.ts +14 -0
- package/src/types.ts +9 -0
- package/src/utils/clipboard.ts +5 -0
- package/src/utils/groq.ts +3 -0
- package/src/utils/retry.ts +19 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Read",
|
|
5
|
+
"Edit",
|
|
6
|
+
"Write",
|
|
7
|
+
"Glob",
|
|
8
|
+
"Grep",
|
|
9
|
+
"Bash(npm:*)",
|
|
10
|
+
"Bash(npx:*)",
|
|
11
|
+
"Bash(git:*)",
|
|
12
|
+
"Bash(whspr:*)"
|
|
13
|
+
],
|
|
14
|
+
"deny": [
|
|
15
|
+
"Bash(rm -rf:*)",
|
|
16
|
+
"Bash(sudo:*)"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"hooks": {
|
|
20
|
+
"PostToolUse": [
|
|
21
|
+
{
|
|
22
|
+
"matcher": "Edit|Write",
|
|
23
|
+
"hooks": [
|
|
24
|
+
{
|
|
25
|
+
"type": "command",
|
|
26
|
+
"command": "npx prettier --write $CLAUDE_FILE_PATHS"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# whspr
|
|
2
|
+
|
|
3
|
+
A CLI tool that records audio from your microphone, transcribes it using Groq's Whisper API, and post-processes with AI to fix errors.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- Language: TypeScript (ES2022, NodeNext modules)
|
|
8
|
+
- Runtime: Node.js 18+
|
|
9
|
+
- Package manager: npm
|
|
10
|
+
- External: FFmpeg (required for audio recording)
|
|
11
|
+
|
|
12
|
+
## Structure
|
|
13
|
+
|
|
14
|
+
- `src/` - Main source code
|
|
15
|
+
- `index.ts` - CLI entry point and main flow
|
|
16
|
+
- `recorder.ts` - FFmpeg audio recording with waveform TUI
|
|
17
|
+
- `transcribe.ts` - Groq Whisper API integration
|
|
18
|
+
- `postprocess.ts` - AI post-processing for corrections
|
|
19
|
+
- `utils/` - Shared utilities (retry, clipboard, groq client)
|
|
20
|
+
- `bin/whspr.js` - CLI entrypoint
|
|
21
|
+
- `dist/` - Compiled output
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Install dependencies
|
|
27
|
+
npm install
|
|
28
|
+
|
|
29
|
+
# Build
|
|
30
|
+
npm run build
|
|
31
|
+
|
|
32
|
+
# Development (run without build)
|
|
33
|
+
npm run dev
|
|
34
|
+
|
|
35
|
+
# Link globally after build
|
|
36
|
+
npm link
|
|
37
|
+
|
|
38
|
+
# Run the CLI
|
|
39
|
+
whspr
|
|
40
|
+
whspr --verbose
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Environment
|
|
44
|
+
|
|
45
|
+
Requires `GROQ_API_KEY` environment variable.
|
|
46
|
+
|
|
47
|
+
## Key Conventions
|
|
48
|
+
|
|
49
|
+
- Uses Groq SDK for both Whisper transcription and AI post-processing
|
|
50
|
+
- Recording uses FFmpeg's avfoundation (macOS) with ebur128 for volume levels
|
|
51
|
+
- Max recording duration: 15 minutes
|
|
52
|
+
- Failed recordings are saved to `~/.whspr/recordings/` for recovery
|
|
53
|
+
- Custom vocabulary via `WHSPR.md` in current directory
|
|
54
|
+
|
|
55
|
+
## API Flow
|
|
56
|
+
|
|
57
|
+
1. Record audio → WAV file (FFmpeg)
|
|
58
|
+
2. Convert WAV → MP3
|
|
59
|
+
3. Transcribe MP3 → text (Groq Whisper)
|
|
60
|
+
4. Post-process text → fixed text (Groq AI)
|
|
61
|
+
5. Copy result to clipboard
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# whspr
|
|
2
|
+
|
|
3
|
+
A CLI tool that records audio from your microphone, transcribes it using Groq's Whisper API, and post-processes the transcription with AI to fix errors and apply custom vocabulary.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Live audio waveform visualization in the terminal
|
|
8
|
+
- 15-minute max recording time
|
|
9
|
+
- Transcription via Groq Whisper API
|
|
10
|
+
- AI-powered post-processing to fix transcription errors
|
|
11
|
+
- Custom vocabulary support via `WHSPR.md`
|
|
12
|
+
- Automatic clipboard copy
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Node.js 18+
|
|
17
|
+
- FFmpeg (`brew install ffmpeg` on macOS)
|
|
18
|
+
- Groq API key
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install
|
|
24
|
+
npm run build
|
|
25
|
+
npm link
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Set your API key
|
|
32
|
+
export GROQ_API_KEY="your-api-key"
|
|
33
|
+
|
|
34
|
+
# Run the tool
|
|
35
|
+
whspr
|
|
36
|
+
|
|
37
|
+
# With verbose output
|
|
38
|
+
whspr --verbose
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Press **Enter** to stop recording.
|
|
42
|
+
|
|
43
|
+
## Custom Vocabulary
|
|
44
|
+
|
|
45
|
+
Create a `WHSPR.md` file in your current directory to provide custom vocabulary, names, or instructions for the AI post-processor:
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
# Custom Vocabulary
|
|
49
|
+
|
|
50
|
+
- PostgreSQL (not "post crest QL")
|
|
51
|
+
- Kubernetes (not "cooper netties")
|
|
52
|
+
- My colleague's name is "Priya" not "Maria"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How It Works
|
|
56
|
+
|
|
57
|
+
1. Records audio from your default microphone using FFmpeg
|
|
58
|
+
2. Displays a live waveform visualization based on audio levels
|
|
59
|
+
3. Converts the recording to MP3
|
|
60
|
+
4. Sends audio to Groq's Whisper API for transcription
|
|
61
|
+
5. Reads `WHSPR.md` from current directory (if exists)
|
|
62
|
+
6. Sends transcription + custom vocabulary to AI for post-processing
|
|
63
|
+
7. Prints result and copies to clipboard
|
|
64
|
+
|
|
65
|
+
If transcription fails, the recording is saved to `~/.whspr/recordings/` for manual recovery.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/bin/whspr.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whspr",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool for audio transcription with Groq Whisper API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"whspr": "./bin/whspr.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["whisper", "transcription", "audio", "cli", "groq"],
|
|
10
|
+
"author": "Merkie",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/Merkie/whspr"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsx src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@ai-sdk/groq": "^1.x",
|
|
22
|
+
"ai": "^4.x",
|
|
23
|
+
"chalk": "^5.x",
|
|
24
|
+
"clipboardy": "^4.x",
|
|
25
|
+
"groq-sdk": "^0.x",
|
|
26
|
+
"zod": "^3.x"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.x",
|
|
30
|
+
"prettier": "^3.8.0",
|
|
31
|
+
"tsx": "^4.x",
|
|
32
|
+
"typescript": "^5.x"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { record, convertToMp3, RecordingResult } from "./recorder.js";
|
|
3
|
+
import { transcribe } from "./transcribe.js";
|
|
4
|
+
import { postprocess } from "./postprocess.js";
|
|
5
|
+
import { copyToClipboard } from "./utils/clipboard.js";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import os from "os";
|
|
10
|
+
|
|
11
|
+
const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
12
|
+
|
|
13
|
+
function status(message: string) {
|
|
14
|
+
process.stdout.write(`\x1b[2K\r${chalk.blue(message)}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function clearStatus() {
|
|
18
|
+
process.stdout.write("\x1b[2K\r");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatDuration(seconds: number): string {
|
|
22
|
+
const mins = Math.floor(seconds / 60);
|
|
23
|
+
const secs = seconds % 60;
|
|
24
|
+
if (mins > 0) {
|
|
25
|
+
return `${mins}m ${secs}s`;
|
|
26
|
+
}
|
|
27
|
+
return `${secs}s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
try {
|
|
32
|
+
// 1. Record audio
|
|
33
|
+
const recording = await record(verbose);
|
|
34
|
+
const processStart = Date.now();
|
|
35
|
+
|
|
36
|
+
// 2. Convert to MP3
|
|
37
|
+
status("Converting to MP3...");
|
|
38
|
+
const mp3Path = await convertToMp3(recording.path);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// 3. Transcribe with Whisper
|
|
42
|
+
status("Transcribing...");
|
|
43
|
+
const rawText = await transcribe(mp3Path);
|
|
44
|
+
|
|
45
|
+
if (verbose) {
|
|
46
|
+
clearStatus();
|
|
47
|
+
console.log(chalk.gray(`Raw: ${rawText}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 4. Read WHSPR.md or WHISPER.md if exists
|
|
51
|
+
const whsprMdPath = path.join(process.cwd(), "WHSPR.md");
|
|
52
|
+
const whisperMdPath = path.join(process.cwd(), "WHISPER.md");
|
|
53
|
+
let customPrompt: string | null = null;
|
|
54
|
+
let vocabFile: string | null = null;
|
|
55
|
+
|
|
56
|
+
if (fs.existsSync(whsprMdPath)) {
|
|
57
|
+
customPrompt = fs.readFileSync(whsprMdPath, "utf-8");
|
|
58
|
+
vocabFile = "WHSPR.md";
|
|
59
|
+
} else if (fs.existsSync(whisperMdPath)) {
|
|
60
|
+
customPrompt = fs.readFileSync(whisperMdPath, "utf-8");
|
|
61
|
+
vocabFile = "WHISPER.md";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (customPrompt && verbose) {
|
|
65
|
+
console.log(chalk.gray(`Using custom vocabulary from ${vocabFile}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 5. Post-process
|
|
69
|
+
status("Post-processing...");
|
|
70
|
+
const fixedText = await postprocess(rawText, customPrompt);
|
|
71
|
+
|
|
72
|
+
// 6. Output and copy
|
|
73
|
+
clearStatus();
|
|
74
|
+
const processTime = ((Date.now() - processStart) / 1000).toFixed(1);
|
|
75
|
+
const wordCount = fixedText.trim().split(/\s+/).filter(w => w.length > 0).length;
|
|
76
|
+
const charCount = fixedText.length;
|
|
77
|
+
|
|
78
|
+
// Log stats
|
|
79
|
+
console.log(
|
|
80
|
+
chalk.dim("Audio: ") + chalk.white(formatDuration(recording.durationSeconds)) +
|
|
81
|
+
chalk.dim(" • Processing: ") + chalk.white(processTime + "s")
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Draw box
|
|
85
|
+
const termWidth = Math.min(process.stdout.columns || 60, 80);
|
|
86
|
+
const lineWidth = termWidth - 2;
|
|
87
|
+
const label = " TRANSCRIPT ";
|
|
88
|
+
console.log(chalk.dim("┌─") + chalk.cyan(label) + chalk.dim("─".repeat(lineWidth - label.length - 1) + "┐"));
|
|
89
|
+
const lines = fixedText.split("\n");
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
// Wrap long lines
|
|
92
|
+
let remaining = line;
|
|
93
|
+
while (remaining.length > 0) {
|
|
94
|
+
const chunk = remaining.slice(0, lineWidth - 2);
|
|
95
|
+
remaining = remaining.slice(lineWidth - 2);
|
|
96
|
+
console.log(chalk.dim("│ ") + chalk.white(chunk.padEnd(lineWidth - 2)) + chalk.dim(" │"));
|
|
97
|
+
}
|
|
98
|
+
if (line.length === 0) {
|
|
99
|
+
console.log(chalk.dim("│ " + " ".repeat(lineWidth - 2) + " │"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const stats = ` ${wordCount} words • ${charCount} chars `;
|
|
103
|
+
const bottomLine = "─".repeat(lineWidth - stats.length - 1) + " ";
|
|
104
|
+
console.log(chalk.dim("└" + bottomLine) + chalk.dim(stats) + chalk.dim("┘"));
|
|
105
|
+
await copyToClipboard(fixedText);
|
|
106
|
+
console.log(chalk.green("✓") + chalk.gray(" Copied to clipboard"));
|
|
107
|
+
|
|
108
|
+
// 7. Clean up
|
|
109
|
+
fs.unlinkSync(mp3Path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
clearStatus();
|
|
112
|
+
// Save recording on failure
|
|
113
|
+
const backupDir = path.join(os.homedir(), ".whspr", "recordings");
|
|
114
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
115
|
+
const backupPath = path.join(backupDir, `recording-${Date.now()}.mp3`);
|
|
116
|
+
fs.renameSync(mp3Path, backupPath);
|
|
117
|
+
console.error(chalk.red(`Error: ${error}`));
|
|
118
|
+
console.log(chalk.yellow(`Recording saved to: ${backupPath}`));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
clearStatus();
|
|
123
|
+
// Silent exit on user cancel
|
|
124
|
+
if (error instanceof Error && error.message === "cancelled") {
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
console.error(chalk.red(`Recording error: ${error}`));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { generateObject } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withRetry } from "./utils/retry.js";
|
|
4
|
+
import { groq } from "./utils/groq.js";
|
|
5
|
+
|
|
6
|
+
const MODEL = "openai/gpt-oss-120b";
|
|
7
|
+
|
|
8
|
+
const outputSchema = z.object({
|
|
9
|
+
fixed_transcription: z.string(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export async function postprocess(
|
|
13
|
+
rawTranscription: string,
|
|
14
|
+
customPrompt: string | null
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const result = await withRetry(async () => {
|
|
17
|
+
const response = await generateObject({
|
|
18
|
+
model: groq(MODEL),
|
|
19
|
+
schema: outputSchema,
|
|
20
|
+
messages: [
|
|
21
|
+
{
|
|
22
|
+
role: "system",
|
|
23
|
+
content: "Your task is to clean up/fix transcribed text generated from mic input by the user according to the user's own prompt, this prompt may contain custom vocabulary, instructions, etc. Please return the user's transcription with the fixes made (e.g. the AI might hear \"PostgreSQL\" as \"post crest QL\" you need to use your own reasoning to fix these mistakes in the transcription)"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
role: "user",
|
|
27
|
+
content: customPrompt
|
|
28
|
+
? `Here's my custom user prompt:\n\`\`\`\n${customPrompt}\n\`\`\`\n\nHere's my raw transcription output that I need you to edit:\n\`\`\`\n${rawTranscription}\n\`\`\``
|
|
29
|
+
: `Here's my raw transcription output that I need you to edit:\n\`\`\`\n${rawTranscription}\n\`\`\``
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
return response.object;
|
|
34
|
+
}, 3, "postprocess");
|
|
35
|
+
|
|
36
|
+
return result.fixed_transcription;
|
|
37
|
+
}
|
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
const MAX_DURATION_SECONDS = 900; // 15 minutes
|
|
8
|
+
const DEFAULT_WAVE_WIDTH = 60;
|
|
9
|
+
const STATUS_TEXT_WIDTH = 45; // " Recording [00:00 / 15:00] Press Enter to stop"
|
|
10
|
+
|
|
11
|
+
// Horizontal bar characters for waveform (quiet to loud)
|
|
12
|
+
const WAVE_CHARS = ["·", "-", "=", "≡", "■", "█"];
|
|
13
|
+
|
|
14
|
+
function formatTime(seconds: number): string {
|
|
15
|
+
const mins = Math.floor(seconds / 60);
|
|
16
|
+
const secs = seconds % 60;
|
|
17
|
+
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dbToChar(db: number): string {
|
|
21
|
+
// Adjusted range: -45 (quiet) to -18 (normal speech peaks)
|
|
22
|
+
const clamped = Math.max(-45, Math.min(-18, db));
|
|
23
|
+
const normalized = (clamped + 45) / 27;
|
|
24
|
+
const index = Math.min(
|
|
25
|
+
WAVE_CHARS.length - 1,
|
|
26
|
+
Math.floor(normalized * WAVE_CHARS.length),
|
|
27
|
+
);
|
|
28
|
+
return WAVE_CHARS[index];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getWaveWidth(): number {
|
|
32
|
+
const termWidth = process.stdout.columns || 80;
|
|
33
|
+
// If terminal is wide enough for single line, use default
|
|
34
|
+
if (termWidth >= DEFAULT_WAVE_WIDTH + STATUS_TEXT_WIDTH) {
|
|
35
|
+
return DEFAULT_WAVE_WIDTH;
|
|
36
|
+
}
|
|
37
|
+
// Otherwise, use full terminal width for wave (will wrap text to next line)
|
|
38
|
+
return Math.max(10, termWidth - 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RecordingResult {
|
|
42
|
+
path: string;
|
|
43
|
+
durationSeconds: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function record(verbose = false): Promise<RecordingResult> {
|
|
47
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "whspr-"));
|
|
48
|
+
const wavPath = path.join(tmpDir, "recording.wav");
|
|
49
|
+
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
// Initialize waveform buffer
|
|
52
|
+
let waveWidth = getWaveWidth();
|
|
53
|
+
const waveBuffer: string[] = new Array(waveWidth).fill(" ");
|
|
54
|
+
let currentDb = -60;
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
|
|
57
|
+
// Spawn FFmpeg with ebur128 filter to get volume levels
|
|
58
|
+
const ffmpeg: ChildProcess = spawn(
|
|
59
|
+
"ffmpeg",
|
|
60
|
+
[
|
|
61
|
+
"-f",
|
|
62
|
+
"avfoundation",
|
|
63
|
+
"-i",
|
|
64
|
+
":0",
|
|
65
|
+
"-af",
|
|
66
|
+
"ebur128=peak=true",
|
|
67
|
+
"-t",
|
|
68
|
+
MAX_DURATION_SECONDS.toString(),
|
|
69
|
+
"-y",
|
|
70
|
+
wavPath,
|
|
71
|
+
],
|
|
72
|
+
{
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
let elapsedSeconds = 0;
|
|
78
|
+
let stopped = false;
|
|
79
|
+
|
|
80
|
+
function renderTUI() {
|
|
81
|
+
const elapsed = formatTime(elapsedSeconds);
|
|
82
|
+
const max = formatTime(MAX_DURATION_SECONDS);
|
|
83
|
+
const wave = waveBuffer.join("");
|
|
84
|
+
const termWidth = process.stdout.columns || 80;
|
|
85
|
+
const singleLineWidth = waveWidth + STATUS_TEXT_WIDTH;
|
|
86
|
+
|
|
87
|
+
if (termWidth >= singleLineWidth) {
|
|
88
|
+
// Single line layout
|
|
89
|
+
process.stdout.write(
|
|
90
|
+
`\x1b[2K\r${chalk.cyan(wave)} ${chalk.blue("Recording")} [${chalk.yellow(elapsed)} / ${max}] ${chalk.gray("Press Enter to stop")}`,
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
// Two line layout: wave on first line, status on second
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
`\x1b[2K\r${chalk.cyan(wave)}\n\x1b[2K${chalk.blue("Recording")} [${chalk.yellow(elapsed)} / ${max}] ${chalk.gray("Press Enter to stop")}\x1b[A\r`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Update timer every second
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
if (stopped) return;
|
|
103
|
+
elapsedSeconds++;
|
|
104
|
+
renderTUI();
|
|
105
|
+
|
|
106
|
+
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
}
|
|
109
|
+
}, 1000);
|
|
110
|
+
|
|
111
|
+
// Update waveform more frequently
|
|
112
|
+
const waveTimer = setInterval(() => {
|
|
113
|
+
if (stopped) return;
|
|
114
|
+
// Push new character based on current dB level
|
|
115
|
+
waveBuffer.shift();
|
|
116
|
+
waveBuffer.push(dbToChar(currentDb));
|
|
117
|
+
renderTUI();
|
|
118
|
+
}, 50);
|
|
119
|
+
|
|
120
|
+
// Initial display
|
|
121
|
+
renderTUI();
|
|
122
|
+
|
|
123
|
+
// Parse stderr for volume levels from ebur128
|
|
124
|
+
ffmpeg.stderr?.on("data", (data: Buffer) => {
|
|
125
|
+
const output = data.toString();
|
|
126
|
+
|
|
127
|
+
// Look for FTPK (frame true peak) from ebur128 output
|
|
128
|
+
// Format: "FTPK: -XX.X -XX.X dBFS"
|
|
129
|
+
const ftpkMatch = output.match(/FTPK:\s*(-?[\d.]+)\s+(-?[\d.]+)\s+dBFS/);
|
|
130
|
+
if (ftpkMatch) {
|
|
131
|
+
// Average the left and right channels
|
|
132
|
+
const left = parseFloat(ftpkMatch[1]);
|
|
133
|
+
const right = parseFloat(ftpkMatch[2]);
|
|
134
|
+
if (!isNaN(left) && !isNaN(right)) {
|
|
135
|
+
currentDb = (left + right) / 2;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Listen for Enter to stop, Ctrl+C to cancel
|
|
141
|
+
const onKeypress = (data: Buffer) => {
|
|
142
|
+
const key = data.toString();
|
|
143
|
+
const isEnter = key.includes("\n") || key.includes("\r");
|
|
144
|
+
const isCtrlC = key.includes("\x03");
|
|
145
|
+
|
|
146
|
+
if (isEnter || isCtrlC) {
|
|
147
|
+
stopped = true;
|
|
148
|
+
cancelled = isCtrlC;
|
|
149
|
+
clearInterval(timer);
|
|
150
|
+
clearInterval(waveTimer);
|
|
151
|
+
process.stdin.removeListener("data", onKeypress);
|
|
152
|
+
process.stdin.setRawMode(false);
|
|
153
|
+
process.stdin.pause();
|
|
154
|
+
|
|
155
|
+
// Send SIGINT to FFmpeg to stop gracefully
|
|
156
|
+
ffmpeg.kill("SIGINT");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (process.stdin.isTTY) {
|
|
161
|
+
process.stdin.setRawMode(true);
|
|
162
|
+
process.stdin.resume();
|
|
163
|
+
process.stdin.on("data", onKeypress);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ffmpeg.on("close", (code) => {
|
|
167
|
+
clearInterval(timer);
|
|
168
|
+
clearInterval(waveTimer);
|
|
169
|
+
const termWidth = process.stdout.columns || 80;
|
|
170
|
+
const singleLineWidth = waveWidth + STATUS_TEXT_WIDTH;
|
|
171
|
+
if (termWidth >= singleLineWidth) {
|
|
172
|
+
process.stdout.write("\x1b[2K\r"); // Clear the line
|
|
173
|
+
} else {
|
|
174
|
+
process.stdout.write("\x1b[2K\n\x1b[2K\x1b[A\r"); // Clear both lines
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (cancelled) {
|
|
178
|
+
// User pressed Ctrl+C - clean up and reject
|
|
179
|
+
if (fs.existsSync(wavPath)) {
|
|
180
|
+
fs.unlinkSync(wavPath);
|
|
181
|
+
}
|
|
182
|
+
reject(new Error("cancelled"));
|
|
183
|
+
} else if (stopped || code === 0 || code === 255) {
|
|
184
|
+
// FFmpeg returns 255 when interrupted with SIGINT
|
|
185
|
+
if (fs.existsSync(wavPath)) {
|
|
186
|
+
if (verbose) {
|
|
187
|
+
console.log(
|
|
188
|
+
chalk.green(`Recording complete (${formatTime(elapsedSeconds)})`),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
resolve({ path: wavPath, durationSeconds: elapsedSeconds });
|
|
192
|
+
} else {
|
|
193
|
+
reject(new Error("Recording failed: no output file created"));
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
ffmpeg.on("error", (err) => {
|
|
201
|
+
clearInterval(timer);
|
|
202
|
+
clearInterval(waveTimer);
|
|
203
|
+
stopped = true;
|
|
204
|
+
if (process.stdin.isTTY) {
|
|
205
|
+
process.stdin.setRawMode(false);
|
|
206
|
+
process.stdin.pause();
|
|
207
|
+
}
|
|
208
|
+
reject(new Error(`Failed to start FFmpeg: ${err.message}`));
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function convertToMp3(wavPath: string): Promise<string> {
|
|
214
|
+
const mp3Path = wavPath.replace(/\.wav$/, ".mp3");
|
|
215
|
+
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const ffmpeg = spawn(
|
|
218
|
+
"ffmpeg",
|
|
219
|
+
[
|
|
220
|
+
"-i",
|
|
221
|
+
wavPath,
|
|
222
|
+
"-codec:a",
|
|
223
|
+
"libmp3lame",
|
|
224
|
+
"-qscale:a",
|
|
225
|
+
"2",
|
|
226
|
+
"-y",
|
|
227
|
+
mp3Path,
|
|
228
|
+
],
|
|
229
|
+
{
|
|
230
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
ffmpeg.on("close", (code) => {
|
|
235
|
+
if (code === 0) {
|
|
236
|
+
// Delete the WAV file after successful conversion
|
|
237
|
+
fs.unlinkSync(wavPath);
|
|
238
|
+
resolve(mp3Path);
|
|
239
|
+
} else {
|
|
240
|
+
reject(new Error(`MP3 conversion failed with code ${code}`));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
ffmpeg.on("error", (err) => {
|
|
245
|
+
reject(new Error(`Failed to convert to MP3: ${err.message}`));
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Groq from "groq-sdk";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
|
|
4
|
+
const groq = new Groq(); // Uses GROQ_API_KEY env var
|
|
5
|
+
|
|
6
|
+
export async function transcribe(audioPath: string): Promise<string> {
|
|
7
|
+
const transcription = await groq.audio.transcriptions.create({
|
|
8
|
+
file: fs.createReadStream(audioPath),
|
|
9
|
+
model: "whisper-large-v3-turbo",
|
|
10
|
+
temperature: 0,
|
|
11
|
+
language: "en",
|
|
12
|
+
});
|
|
13
|
+
return transcription.text;
|
|
14
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function withRetry<T>(
|
|
2
|
+
fn: () => Promise<T>,
|
|
3
|
+
maxAttempts = 3,
|
|
4
|
+
label = "API call"
|
|
5
|
+
): Promise<T> {
|
|
6
|
+
let lastError: Error | undefined;
|
|
7
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
8
|
+
try {
|
|
9
|
+
return await fn();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
12
|
+
console.warn(`${label} attempt ${attempt}/${maxAttempts} failed:`, lastError.message);
|
|
13
|
+
if (attempt < maxAttempts) {
|
|
14
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw lastError;
|
|
19
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"declaration": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|