whspr 1.0.14 → 1.0.15

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 CHANGED
@@ -43,6 +43,11 @@ whspr
43
43
 
44
44
  # With verbose output
45
45
  whspr --verbose
46
+
47
+ # Pipe output to another command (instead of clipboard)
48
+ whspr --pipe "pbcopy" # Explicit clipboard
49
+ whspr --pipe "claude" # Pipe directly to Claude Code
50
+ whspr -p "cat >> notes.txt" # Append to a file
46
51
  ```
47
52
 
48
53
  Press **Enter** to stop recording.
@@ -53,9 +58,12 @@ Press **Enter** to stop recording.
53
58
  - 15-minute max recording time
54
59
  - Transcription via Groq Whisper API
55
60
  - AI-powered post-processing to fix transcription errors
61
+ - Progress bar during post-processing
62
+ - Cost tracking for Anthropic models
56
63
  - Custom vocabulary support via `WHSPR.md` (global and local)
57
64
  - Configurable settings via `~/.whspr/settings.json`
58
- - Automatic clipboard copy
65
+ - Automatic clipboard copy (or pipe to any command with `--pipe`)
66
+ - Optional auto-save for transcriptions and audio files
59
67
 
60
68
  ## Settings
61
69
 
@@ -70,40 +78,46 @@ Create `~/.whspr/settings.json` to customize whspr's behavior:
70
78
  "model": "groq:openai/gpt-oss-120b",
71
79
  "systemPrompt": "Your task is to clean up transcribed text...",
72
80
  "customPromptPrefix": "Here's my custom user prompt:",
73
- "transcriptionPrefix": "Here's my raw transcription output:"
81
+ "transcriptionPrefix": "Here's my raw transcription output:",
82
+ "alwaysSaveTranscriptions": false,
83
+ "alwaysSaveAudio": false,
84
+ "saveTranscriptionsToCwd": false
74
85
  }
75
86
  ```
76
87
 
77
- | Option | Type | Default | Description |
78
- |--------|------|---------|-------------|
79
- | `verbose` | boolean | `false` | Enable verbose output |
80
- | `suffix` | string | none | Text appended to all transcriptions |
81
- | `transcriptionModel` | string | `"whisper-large-v3-turbo"` | Whisper model (`"whisper-large-v3"` or `"whisper-large-v3-turbo"`) |
82
- | `language` | string | `"en"` | ISO 639-1 language code (e.g., `"en"`, `"zh"`, `"es"`) |
83
- | `model` | string | `"groq:openai/gpt-oss-120b"` | Post-processing model in `provider:model-name` format (see below) |
84
- | `systemPrompt` | string | (built-in) | System prompt for AI post-processing |
85
- | `customPromptPrefix` | string | `"Here's my custom user prompt:"` | Prefix before custom prompt content |
86
- | `transcriptionPrefix` | string | `"Here's my raw transcription output that I need you to edit:"` | Prefix before raw transcription |
88
+ | Option | Type | Default | Description |
89
+ | -------------------------- | ------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------ |
90
+ | `verbose` | boolean | `false` | Enable verbose output |
91
+ | `suffix` | string | none | Text appended to all transcriptions |
92
+ | `transcriptionModel` | string | `"whisper-large-v3-turbo"` | Whisper model (`"whisper-large-v3"` or `"whisper-large-v3-turbo"`) |
93
+ | `language` | string | `"en"` | ISO 639-1 language code (e.g., `"en"`, `"zh"`, `"es"`) |
94
+ | `model` | string | `"groq:openai/gpt-oss-120b"` | Post-processing model in `provider:model-name` format (see below) |
95
+ | `systemPrompt` | string | (built-in) | System prompt for AI post-processing |
96
+ | `customPromptPrefix` | string | `"Here's my custom user prompt:"` | Prefix before custom prompt content |
97
+ | `transcriptionPrefix` | string | `"Here's my raw transcription output that I need you to edit:"` | Prefix before raw transcription |
98
+ | `alwaysSaveTranscriptions` | boolean | `false` | Always save transcription text files to `~/.whspr/transcriptions/` |
99
+ | `alwaysSaveAudio` | boolean | `false` | Always save audio MP3 files to `~/.whspr/recordings/` |
100
+ | `saveTranscriptionsToCwd` | boolean | `false` | Save transcriptions to current directory instead of `~/.whspr/transcriptions/` |
87
101
 
88
102
  ### Supported Providers
89
103
 
90
104
  The `model` setting uses a `provider:model-name` format. Supported providers:
91
105
 
92
- | Provider | API Key Required |
93
- |----------|------------------|
94
- | `groq` | `GROQ_API_KEY` |
106
+ | Provider | API Key Required |
107
+ | ----------- | ------------------- |
108
+ | `groq` | `GROQ_API_KEY` |
95
109
  | `anthropic` | `ANTHROPIC_API_KEY` |
96
110
 
97
111
  ### Common Models
98
112
 
99
- | Provider | Model | Description |
100
- |----------|-------|-------------|
101
- | `anthropic` | `claude-sonnet-4-5` | Balanced speed and quality (recommended) |
102
- | `anthropic` | `claude-haiku-4-5` | Fastest responses, smaller model |
103
- | `anthropic` | `claude-opus-4-5` | Best quality, slower and more expensive |
104
- | `groq` | `openai/gpt-oss-120b` | Default model |
105
- | `groq` | `llama-3.3-70b-versatile` | Fast, versatile Llama model |
106
- | `groq` | `moonshotai/kimi-k2-instruct-0905` | Moonshot Kimi model |
113
+ | Provider | Model | Description |
114
+ | ----------- | ---------------------------------- | ---------------------------------------- |
115
+ | `anthropic` | `claude-sonnet-4-5` | Balanced speed and quality (recommended) |
116
+ | `anthropic` | `claude-haiku-4-5` | Fastest responses, smaller model |
117
+ | `anthropic` | `claude-opus-4-5` | Best quality, slower and more expensive |
118
+ | `groq` | `openai/gpt-oss-120b` | Default model |
119
+ | `groq` | `llama-3.3-70b-versatile` | Fast, versatile Llama model |
120
+ | `groq` | `moonshotai/kimi-k2-instruct-0905` | Moonshot Kimi model |
107
121
 
108
122
  > **Note:** Model names are set by the providers and may change at any time. Check [Groq Models](https://console.groq.com/docs/models) and [Anthropic Models](https://docs.anthropic.com/en/docs/about-claude/models) for the latest available models.
109
123
 
@@ -116,6 +130,32 @@ The `model` setting uses a `provider:model-name` format. Supported providers:
116
130
  }
117
131
  ```
118
132
 
133
+ ### Example: Auto-save Transcriptions to Current Directory
134
+
135
+ ```json
136
+ {
137
+ "alwaysSaveTranscriptions": true,
138
+ "saveTranscriptionsToCwd": true
139
+ }
140
+ ```
141
+
142
+ ## Pipe Output
143
+
144
+ Use `--pipe` (or `-p`) to send the transcription to any command instead of the clipboard:
145
+
146
+ ```bash
147
+ # Pipe to Claude Code for further processing
148
+ whspr --pipe "claude"
149
+
150
+ # Append to a file
151
+ whspr --pipe "cat >> meeting-notes.txt"
152
+
153
+ # Send via curl
154
+ whspr --pipe "xargs -I {} curl -X POST -d 'text={}' https://api.example.com"
155
+ ```
156
+
157
+ If the pipe command fails, whspr falls back to copying to the clipboard.
158
+
119
159
  ## Custom Vocabulary
120
160
 
121
161
  Create a `WHSPR.md` (or `WHISPER.md`) file to provide custom vocabulary, names, or instructions for the AI post-processor.
@@ -152,9 +192,11 @@ When both exist, they are combined (global first, then local).
152
192
  3. Converts the recording to MP3
153
193
  4. Sends audio to Groq's Whisper API for transcription
154
194
  5. Loads custom prompts from `~/.whspr/WHSPR.md` and/or `./WHSPR.md`
155
- 6. Sends transcription + custom vocabulary to AI for post-processing
195
+ 6. Sends transcription + custom vocabulary to AI for post-processing (with progress bar)
156
196
  7. Applies suffix (if configured)
157
- 8. Prints result and copies to clipboard
197
+ 8. Displays result with word count, character count, and cost estimate
198
+ 9. Pipes to command (`--pipe`) or copies to clipboard
199
+ 10. Saves transcription/audio files (if configured)
158
200
 
159
201
  If transcription fails, the recording is saved to `~/.whspr/recordings/` for manual recovery.
160
202
 
package/dist/index.d.ts CHANGED
@@ -16,4 +16,7 @@ export interface WhsprSettings {
16
16
  systemPrompt?: string;
17
17
  customPromptPrefix?: string;
18
18
  transcriptionPrefix?: string;
19
+ alwaysSaveTranscriptions?: boolean;
20
+ alwaysSaveAudio?: boolean;
21
+ saveTranscriptionsToCwd?: boolean;
19
22
  }
package/dist/index.js CHANGED
@@ -4,10 +4,11 @@ import { transcribe } from "./transcribe.js";
4
4
  import { postprocess } from "./postprocess.js";
5
5
  import { copyToClipboard } from "./utils/clipboard.js";
6
6
  import { calculateCost, formatCost } from "./utils/pricing.js";
7
- import { renderStartupHeader, formatCompactStats, formatStatus, colors, BOX } from "./ui.js";
7
+ import { renderStartupHeader, formatCompactStats, formatStatus, colors, BOX, } from "./ui.js";
8
8
  import fs from "fs";
9
9
  import path from "path";
10
10
  import os from "os";
11
+ import { spawn } from "child_process";
11
12
  // Default prompts (can be overridden in settings.json)
12
13
  export const DEFAULTS = {
13
14
  transcriptionModel: "whisper-large-v3-turbo",
@@ -27,6 +28,13 @@ const DEFAULT_SETTINGS = {
27
28
  };
28
29
  const WHSPR_DIR = path.join(os.homedir(), ".whspr");
29
30
  const SETTINGS_PATH = path.join(WHSPR_DIR, "settings.json");
31
+ const TRANSCRIPTIONS_DIR = path.join(WHSPR_DIR, "transcriptions");
32
+ const RECORDINGS_DIR = path.join(WHSPR_DIR, "recordings");
33
+ function generateTimestampedFilename(extension) {
34
+ const now = new Date();
35
+ const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
36
+ return `transcription-${timestamp}${extension}`;
37
+ }
30
38
  function parseModelProvider(model) {
31
39
  const colonIndex = model.indexOf(":");
32
40
  if (colonIndex === -1) {
@@ -98,7 +106,40 @@ function loadCustomPrompt(verbose) {
98
106
  return { prompt: combinedPrompt, sources };
99
107
  }
100
108
  const settings = loadSettings();
101
- const verbose = settings.verbose || process.argv.includes("--verbose") || process.argv.includes("-v");
109
+ const verbose = settings.verbose ||
110
+ process.argv.includes("--verbose") ||
111
+ process.argv.includes("-v");
112
+ // Parse --pipe flag
113
+ function getPipeCommand() {
114
+ const pipeIndex = process.argv.findIndex((arg) => arg === "--pipe" || arg === "-p");
115
+ if (pipeIndex !== -1 && process.argv[pipeIndex + 1]) {
116
+ return process.argv[pipeIndex + 1];
117
+ }
118
+ return null;
119
+ }
120
+ const pipeCommand = getPipeCommand();
121
+ // Execute a command with text piped to stdin
122
+ function pipeToCommand(text, command) {
123
+ return new Promise((resolve, reject) => {
124
+ const child = spawn(command, [], {
125
+ shell: true,
126
+ stdio: ["pipe", "inherit", "inherit"],
127
+ });
128
+ child.on("error", (err) => {
129
+ reject(new Error(`Failed to execute pipe command: ${err.message}`));
130
+ });
131
+ child.on("close", (code) => {
132
+ if (code === 0) {
133
+ resolve();
134
+ }
135
+ else {
136
+ reject(new Error(`Pipe command exited with code ${code}`));
137
+ }
138
+ });
139
+ child.stdin.write(text);
140
+ child.stdin.end();
141
+ });
142
+ }
102
143
  function status(message) {
103
144
  process.stdout.write(`\x1b[2K\r${formatStatus(message)}`);
104
145
  }
@@ -122,14 +163,14 @@ async function main() {
122
163
  if (!process.env.GROQ_API_KEY) {
123
164
  console.error(colors.error("Error: GROQ_API_KEY environment variable is not set"));
124
165
  console.log(colors.metadata("Get your API key at https://console.groq.com/keys"));
125
- console.log(colors.metadata("Then run: export GROQ_API_KEY=\"your-api-key\""));
166
+ console.log(colors.metadata('Then run: export GROQ_API_KEY="your-api-key"'));
126
167
  process.exit(1);
127
168
  }
128
169
  // Check for provider-specific API key for post-processing
129
170
  if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
130
171
  console.error(colors.error("Error: ANTHROPIC_API_KEY environment variable is not set"));
131
172
  console.log(colors.metadata("Get your API key at https://console.anthropic.com/settings/keys"));
132
- console.log(colors.metadata("Then run: export ANTHROPIC_API_KEY=\"your-api-key\""));
173
+ console.log(colors.metadata('Then run: export ANTHROPIC_API_KEY="your-api-key"'));
133
174
  process.exit(1);
134
175
  }
135
176
  // Load custom prompt early to show in startup header
@@ -180,7 +221,10 @@ async function main() {
180
221
  // 6. Output and copy
181
222
  clearStatus();
182
223
  const processTime = ((Date.now() - processStart) / 1000).toFixed(1);
183
- const wordCount = fixedText.trim().split(/\s+/).filter(w => w.length > 0).length;
224
+ const wordCount = fixedText
225
+ .trim()
226
+ .split(/\s+/)
227
+ .filter((w) => w.length > 0).length;
184
228
  const charCount = fixedText.length;
185
229
  // Calculate cost if usage info is available
186
230
  let costString;
@@ -192,7 +236,9 @@ async function main() {
192
236
  const termWidth = Math.min(process.stdout.columns || 60, 80);
193
237
  const lineWidth = termWidth - 2;
194
238
  const label = " TRANSCRIPT ";
195
- console.log(colors.dim(BOX.topLeft + BOX.horizontal) + colors.header.bold(label) + colors.dim(BOX.horizontal.repeat(lineWidth - label.length - 1) + BOX.topRight));
239
+ console.log(colors.dim(BOX.topLeft + BOX.horizontal) +
240
+ colors.header.bold(label) +
241
+ colors.dim(BOX.horizontal.repeat(lineWidth - label.length - 1) + BOX.topRight));
196
242
  const lines = fixedText.split("\n");
197
243
  for (const line of lines) {
198
244
  // Wrap long lines
@@ -200,31 +246,79 @@ async function main() {
200
246
  while (remaining.length > 0) {
201
247
  const chunk = remaining.slice(0, lineWidth - 2);
202
248
  remaining = remaining.slice(lineWidth - 2);
203
- console.log(colors.dim(BOX.vertical + " ") + colors.white(chunk.padEnd(lineWidth - 2)) + colors.dim(" " + BOX.vertical));
249
+ console.log(colors.dim(BOX.vertical + " ") +
250
+ colors.white(chunk.padEnd(lineWidth - 2)) +
251
+ colors.dim(" " + BOX.vertical));
204
252
  }
205
253
  if (line.length === 0) {
206
- console.log(colors.dim(BOX.vertical + " " + " ".repeat(lineWidth - 2) + " " + BOX.vertical));
254
+ console.log(colors.dim(BOX.vertical +
255
+ " " +
256
+ " ".repeat(lineWidth - 2) +
257
+ " " +
258
+ BOX.vertical));
207
259
  }
208
260
  }
209
261
  const stats = ` ${wordCount} words \u2022 ${charCount} chars `;
210
262
  const bottomLine = BOX.horizontal.repeat(lineWidth - stats.length - 1) + " ";
211
- console.log(colors.dim(BOX.bottomLeft + bottomLine) + colors.metadata(stats) + colors.dim(BOX.bottomRight));
212
- await copyToClipboard(fixedText);
263
+ console.log(colors.dim(BOX.bottomLeft + bottomLine) +
264
+ colors.metadata(stats) +
265
+ colors.dim(BOX.bottomRight));
213
266
  console.log(formatCompactStats({
214
267
  audioDuration: formatDuration(recording.durationSeconds),
215
268
  processingTime: processTime + "s",
216
269
  cost: costString,
217
270
  }));
218
- console.log(colors.success("\u2713") + colors.metadata(" Copied to clipboard"));
219
- // 7. Clean up
271
+ // Either pipe to command or copy to clipboard
272
+ if (pipeCommand) {
273
+ try {
274
+ await pipeToCommand(fixedText, pipeCommand);
275
+ console.log(colors.success("\u2713") +
276
+ colors.metadata(` Piped to: ${pipeCommand}`));
277
+ }
278
+ catch (err) {
279
+ console.error(colors.error(`Pipe failed: ${err}`));
280
+ // Fall back to clipboard
281
+ await copyToClipboard(fixedText);
282
+ console.log(colors.success("\u2713") +
283
+ colors.metadata(" Copied to clipboard (pipe failed)"));
284
+ }
285
+ }
286
+ else {
287
+ await copyToClipboard(fixedText);
288
+ console.log(colors.success("\u2713") + colors.metadata(" Copied to clipboard"));
289
+ }
290
+ // 7. Save transcription if configured
291
+ if (settings.alwaysSaveTranscriptions) {
292
+ const filename = generateTimestampedFilename(".txt");
293
+ let savePath;
294
+ if (settings.saveTranscriptionsToCwd) {
295
+ savePath = path.join(process.cwd(), filename);
296
+ }
297
+ else {
298
+ fs.mkdirSync(TRANSCRIPTIONS_DIR, { recursive: true });
299
+ savePath = path.join(TRANSCRIPTIONS_DIR, filename);
300
+ }
301
+ fs.writeFileSync(savePath, fixedText, "utf-8");
302
+ console.log(colors.success("\u2713") +
303
+ colors.metadata(` Saved transcription to: ${savePath}`));
304
+ }
305
+ // 8. Save audio if configured
306
+ if (settings.alwaysSaveAudio) {
307
+ fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
308
+ const audioFilename = generateTimestampedFilename(".mp3");
309
+ const audioSavePath = path.join(RECORDINGS_DIR, audioFilename);
310
+ fs.copyFileSync(mp3Path, audioSavePath);
311
+ console.log(colors.success("\u2713") +
312
+ colors.metadata(` Saved audio to: ${audioSavePath}`));
313
+ }
314
+ // 9. Clean up
220
315
  fs.unlinkSync(mp3Path);
221
316
  }
222
317
  catch (error) {
223
318
  clearStatus();
224
- // Save recording on failure
225
- const backupDir = path.join(os.homedir(), ".whspr", "recordings");
226
- fs.mkdirSync(backupDir, { recursive: true });
227
- const backupPath = path.join(backupDir, `recording-${Date.now()}.mp3`);
319
+ // Save recording on failure (post-processing failed, save audio only)
320
+ fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
321
+ const backupPath = path.join(RECORDINGS_DIR, `recording-${Date.now()}.mp3`);
228
322
  fs.renameSync(mp3Path, backupPath);
229
323
  console.error(colors.error(`Error: ${error}`));
230
324
  console.log(colors.info(`Recording saved to: ${backupPath}`));
@@ -10,7 +10,8 @@ export async function postprocess(rawTranscription, customPrompt, options) {
10
10
  messages: [
11
11
  {
12
12
  role: "system",
13
- content: systemPrompt + "\n\nIMPORTANT: Output ONLY the corrected transcription text. Do not wrap it in JSON, markdown code blocks, or any other formatting. Just output the fixed text directly.",
13
+ content: systemPrompt +
14
+ "\n\nIMPORTANT: Output ONLY the corrected transcription text. Do not wrap it in JSON, markdown code blocks, or any other formatting. Just output the fixed text directly.",
14
15
  },
15
16
  {
16
17
  role: "user",
package/dist/ui.js CHANGED
@@ -25,7 +25,9 @@ export function renderStartupHeader(config) {
25
25
  const termWidth = Math.min(process.stdout.columns || 60, 66);
26
26
  const innerWidth = termWidth - 4; // Account for "│ " and " │"
27
27
  const headerLabel = " WHSPR ";
28
- const topLine = BOX.topLeft + BOX.horizontal + colors.header.bold(headerLabel) +
28
+ const topLine = BOX.topLeft +
29
+ BOX.horizontal +
30
+ colors.header.bold(headerLabel) +
29
31
  colors.dim(BOX.horizontal.repeat(termWidth - headerLabel.length - 3) + BOX.topRight);
30
32
  console.log(topLine);
31
33
  // Model line
@@ -33,7 +35,8 @@ export function renderStartupHeader(config) {
33
35
  const modelValue = config.model;
34
36
  const modelLine = `${modelLabel}${modelValue}`;
35
37
  console.log(colors.dim(BOX.vertical + " ") +
36
- colors.metadata(modelLabel) + colors.white(modelValue) +
38
+ colors.metadata(modelLabel) +
39
+ colors.white(modelValue) +
37
40
  " ".repeat(Math.max(0, innerWidth - modelLine.length)) +
38
41
  colors.dim(" " + BOX.vertical));
39
42
  // Vocab line (only show if sources exist)
@@ -42,7 +45,8 @@ export function renderStartupHeader(config) {
42
45
  const vocabValue = config.vocabSources.join(" + ");
43
46
  const vocabLine = `${vocabLabel}${vocabValue}`;
44
47
  console.log(colors.dim(BOX.vertical + " ") +
45
- colors.metadata(vocabLabel) + colors.info(vocabValue) +
48
+ colors.metadata(vocabLabel) +
49
+ colors.info(vocabValue) +
46
50
  " ".repeat(Math.max(0, innerWidth - vocabLine.length)) +
47
51
  colors.dim(" " + BOX.vertical));
48
52
  }
@@ -51,8 +55,10 @@ export function renderStartupHeader(config) {
51
55
  console.log(); // Empty line after header
52
56
  }
53
57
  export function formatCompactStats(stats) {
54
- let result = colors.metadata("Audio: ") + colors.white(stats.audioDuration) +
55
- colors.metadata(" \u2022 Processing: ") + colors.white(stats.processingTime);
58
+ let result = colors.metadata("Audio: ") +
59
+ colors.white(stats.audioDuration) +
60
+ colors.metadata(" \u2022 Processing: ") +
61
+ colors.white(stats.processingTime);
56
62
  if (stats.cost) {
57
63
  result += colors.metadata(" \u2022 Cost: ") + colors.white(stats.cost);
58
64
  }
@@ -1,10 +1,10 @@
1
1
  export const MODEL_PRICING = {
2
2
  // Groq models
3
- "openai/gpt-oss-120b": { input: 0.00, output: 0.00 }, // Free tier pricing
3
+ "openai/gpt-oss-120b": { input: 0.0, output: 0.0 }, // Free tier pricing
4
4
  // Anthropic models
5
- "claude-sonnet-4-5": { input: 3.00, output: 15.00 },
6
- "claude-haiku-4-5": { input: 0.80, output: 4.00 },
7
- "claude-opus-4-5": { input: 15.00, output: 75.00 },
5
+ "claude-sonnet-4-5": { input: 3.0, output: 15.0 },
6
+ "claude-haiku-4-5": { input: 0.8, output: 4.0 },
7
+ "claude-opus-4-5": { input: 15.0, output: 75.0 },
8
8
  };
9
9
  export function calculateCost(modelName, usage) {
10
10
  const pricing = MODEL_PRICING[modelName];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whspr",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "CLI tool for audio transcription with Groq Whisper API",
5
5
  "type": "module",
6
6
  "bin": {