opencode-groq-dictate 0.0.0-stage → 1.1.2
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/LICENSE +21 -0
- package/README.md +156 -2
- package/package.json +30 -4
- package/src/voice.test.ts +70 -0
- package/src/voice.ts +78 -0
- package/tui.tsx +433 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonardo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-groq-dictate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](#requirements)
|
|
4
|
+
[](https://www.npmjs.com/package/opencode-groq-dictate)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/LeonVeganMan/opencode-groq-dictate/releases)
|
|
7
|
+
[](#development)
|
|
8
|
+
|
|
9
|
+
Dictation inside the **opencode v2** TUI: press `alt+v`, speak, press `alt+v` again.
|
|
10
|
+
The take goes to Groq (`whisper-large-v3-turbo` by default) and the transcript is
|
|
11
|
+
pasted into the prompt, ready to edit. `esc` throws a take away without uploading it.
|
|
12
|
+
|
|
13
|
+
The sidebar carries the state, the model, the language, the spend and the request
|
|
14
|
+
count, so you can see what the plugin is doing and what it costs while you dictate.
|
|
15
|
+
|
|
16
|
+
## Why a plugin and not a global shortcut
|
|
17
|
+
|
|
18
|
+
The usual setup is a shell script bound to a desktop shortcut: it records in one
|
|
19
|
+
process, then pastes with `xdotool ctrl+v` into whatever window has focus. That works
|
|
20
|
+
until the focus is somewhere else, and it leaves the transcript in no-man's land.
|
|
21
|
+
|
|
22
|
+
Here recording, upload and paste all happen inside the TUI. The text is put on the
|
|
23
|
+
clipboard and handed to `prompt.paste`, so it always lands in the composer, at the
|
|
24
|
+
cursor, as if it had been pasted by hand.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- **opencode v2** — the generation whose TUI plugins are loaded from
|
|
29
|
+
`~/.config/opencode/plugins/` and built on `@opencode/plugin/tui` (`ui.slot`,
|
|
30
|
+
`ui.dialog`, `keymap.layer`). The v1 plugin API is a different generation and this
|
|
31
|
+
plugin does not target it.
|
|
32
|
+
- `parec` (`pulseaudio-utils`) to record, `xclip` to place the transcript on the
|
|
33
|
+
clipboard. Both are already installed on a normal PulseAudio/PipeWire desktop.
|
|
34
|
+
- A Groq API key, free tier is enough. It is asked once, on the first `alt+v`, and kept
|
|
35
|
+
in the opencode plugin storage — never in this repository.
|
|
36
|
+
|
|
37
|
+
`bun` is only needed for the tests.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/LeonVeganMan/opencode-groq-dictate.git \
|
|
43
|
+
~/.config/opencode/plugins/opencode-groq-dictate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Local plugins are discovered from `~/.config/opencode/plugins/<name>/`: no entry in
|
|
47
|
+
`opencode.json` is needed. Restart the TUI afterwards — plugins are read at startup and
|
|
48
|
+
there is no hot reload.
|
|
49
|
+
|
|
50
|
+
The clone above stays the documented install — opencode loads plugins from that folder —
|
|
51
|
+
and the package is also on npm as
|
|
52
|
+
[`opencode-groq-dictate`](https://www.npmjs.com/package/opencode-groq-dictate) for
|
|
53
|
+
directories and registries that index plugins.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
| Key | Action |
|
|
58
|
+
|-----|--------|
|
|
59
|
+
| `alt+v` | start recording, press again to stop and transcribe |
|
|
60
|
+
| `esc` | cancel the take: no upload, no request, no cost |
|
|
61
|
+
| `/dictate` | same as `alt+v`, from the command palette |
|
|
62
|
+
|
|
63
|
+
A take stops on its own after 60 seconds. Everything else is in the palette under
|
|
64
|
+
**Voice**: `Dictation model`, `Dictation language`, `Dictation quota`.
|
|
65
|
+
|
|
66
|
+
Super+V cannot be used: VTE (Tilix, xfce4-terminal) only enables xterm
|
|
67
|
+
`modifyOtherKeys`, which never reports the Super modifier, so the binding could not
|
|
68
|
+
fire inside the terminal. `alt+v` is reported by both the legacy and the
|
|
69
|
+
`modifyOtherKeys` encodings.
|
|
70
|
+
|
|
71
|
+
## Screenshots
|
|
72
|
+
|
|
73
|
+

|
|
74
|
+
|
|
75
|
+
The sidebar panel, without a border, same shape and colours as
|
|
76
|
+
[opencode-go-meter](https://github.com/LeonVeganMan/opencode-go-meter). Nothing in it is
|
|
77
|
+
decoration: every row is clickable and does something.
|
|
78
|
+
|
|
79
|
+
| Row | Shows | Click |
|
|
80
|
+
|-----|-------|-------|
|
|
81
|
+
| `Groq Dictate` (title) | the panel name, muted | start / stop a take |
|
|
82
|
+
| `state` | `idle`, `● rec 12s`, `transcribing…` | start / stop a take |
|
|
83
|
+
| `model` | `large-v3-turbo` or `large-v3` | model dialog |
|
|
84
|
+
| `lang` | `it`, `en`, `auto`, … | language dialog |
|
|
85
|
+
| `today` | `$0.0032 · 13` takes | quota dialog |
|
|
86
|
+
| `total` | `$0.0093` since the log began | quota dialog |
|
|
87
|
+
| `last` | `7.3s · $0.0001` of the last take | — |
|
|
88
|
+
| `rpm` | `1/20` requests in the last minute | quota dialog |
|
|
89
|
+
|
|
90
|
+
The state row is green when idle, red while recording, amber while the text is in
|
|
91
|
+
flight; `rpm` turns red at the limit, where a take would be refused.
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
While a take is running the prompt footer carries a red `● REC Ns`; it turns amber
|
|
96
|
+
while the text is in flight.
|
|
97
|
+
|
|
98
|
+

|
|
99
|
+
|
|
100
|
+
The quota dialog, also reachable by clicking `today`, `total` or `rpm`: model, language,
|
|
101
|
+
requests left today (from the Groq headers), local RPM, spend today with the number of
|
|
102
|
+
takes, spend in total.
|
|
103
|
+
|
|
104
|
+
## Cost and quota
|
|
105
|
+
|
|
106
|
+
- Cost is estimated on the audio length at **$0.04/hour** and appended to
|
|
107
|
+
`~/.cache/groq-dictate-costs.log`, the same file and the same four columns
|
|
108
|
+
(`date time seconds cost`) the older shell script wrote, so a `/dictate-cost` command
|
|
109
|
+
keeps working unchanged.
|
|
110
|
+
- The **daily** request figure comes from the `x-ratelimit-*` headers of the real
|
|
111
|
+
transcription. No request is spent just to read the quota.
|
|
112
|
+
- The **per-minute** figure is tracked locally: timestamps of the last 60 seconds, cap
|
|
113
|
+
20, the free tier limit. At the cap the recording does not start.
|
|
114
|
+
- Language defaults to Italian. Set another ISO code, or `auto` for detection, from the
|
|
115
|
+
language dialog. Model can be `whisper-large-v3-turbo` (default, faster) or
|
|
116
|
+
`whisper-large-v3` (same price, a little more accurate).
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
1. `parec --rate=16000 --channels=1 --file-format=wav` records to `/tmp` at the rate
|
|
121
|
+
Whisper wants, so no `ffmpeg` step is needed.
|
|
122
|
+
2. On stop the WAV goes to `https://api.groq.com/openai/v1/audio/transcriptions` with
|
|
123
|
+
`fetch` and `FormData` (no dependencies), and the file is deleted as soon as the
|
|
124
|
+
answer arrives.
|
|
125
|
+
3. The text goes to the clipboard with `xclip`, then `prompt.paste` puts it in the
|
|
126
|
+
composer.
|
|
127
|
+
4. The cost line is appended and the sidebar totals update.
|
|
128
|
+
|
|
129
|
+
The pure logic (`src/voice.ts`: audio length, cost, log format, rpm window, totals) is
|
|
130
|
+
separate from the TUI so it can be unit tested without a terminal.
|
|
131
|
+
|
|
132
|
+
## Limitations
|
|
133
|
+
|
|
134
|
+
- TUI only: dictating into a browser or an editor is out of scope. A desktop shortcut
|
|
135
|
+
running a small script is still the right tool there.
|
|
136
|
+
- The transcript is pasted, not submitted: you always get a chance to fix it.
|
|
137
|
+
- No automatic stop at the end of a sentence yet. The microphone gain on a typical
|
|
138
|
+
laptop is too high for a threshold to separate voice from room; see [TODO.md](TODO.md)
|
|
139
|
+
for the measurements and the sketch.
|
|
140
|
+
- A silent or monitor source makes Whisper hallucinate ("Grazie a tutti!" is the
|
|
141
|
+
classic). The plugin checks `pactl get-default-source` and refuses to record when the
|
|
142
|
+
default is a `.monitor`; if the check passes and the text still looks invented, the
|
|
143
|
+
capture level is the first thing to look at.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bun test # unit tests on the pure logic
|
|
149
|
+
bun build tui.tsx --target=node --external '*' # type/JSX sanity check
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The plugin is one TUI entry point (`tui.tsx`) plus one pure module (`src/voice.ts`).
|
|
153
|
+
Editing it requires restarting the TUI: there is no hot reload.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-groq-dictate",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Groq dictation in the OpenCode v2 TUI: alt+v records, transcribes with whisper-large-v3-turbo, and pastes the text into the prompt.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./tui": "./tui.tsx"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"tui.tsx",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"tui",
|
|
21
|
+
"dictation",
|
|
22
|
+
"groq",
|
|
23
|
+
"whisper"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Leonardo Cosmai",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/LeonVeganMan/opencode-groq-dictate.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/LeonVeganMan/opencode-groq-dictate#readme"
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MODEL,
|
|
4
|
+
audioSeconds,
|
|
5
|
+
cost,
|
|
6
|
+
logLine,
|
|
7
|
+
parseConfig,
|
|
8
|
+
recentRpm,
|
|
9
|
+
startTicker,
|
|
10
|
+
summarize,
|
|
11
|
+
} from "./voice"
|
|
12
|
+
|
|
13
|
+
test("audio length comes from the bytes past the header", () => {
|
|
14
|
+
expect(audioSeconds(44)).toBe(0)
|
|
15
|
+
expect(audioSeconds(44 + 16_000 * 2 * 3)).toBe(3)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("cost is the hourly rate pro rata", () => {
|
|
19
|
+
expect(cost(3600)).toBeCloseTo(0.04, 6)
|
|
20
|
+
expect(cost(0)).toBe(0)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("the rpm window drops stamps older than a minute", () => {
|
|
24
|
+
const now = 100_000
|
|
25
|
+
expect(recentRpm([now - 61_000, now - 1_000, now], now)).toEqual([now - 1_000, now])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("a config with the wrong types falls back to the defaults", () => {
|
|
29
|
+
expect(parseConfig(null)).toEqual({ apiKey: "", language: "it", model: DEFAULT_MODEL })
|
|
30
|
+
expect(parseConfig({ apiKey: 42, language: "", model: "nope" })).toEqual({
|
|
31
|
+
apiKey: "",
|
|
32
|
+
language: "it",
|
|
33
|
+
model: DEFAULT_MODEL,
|
|
34
|
+
})
|
|
35
|
+
expect(parseConfig({ apiKey: "gsk_x", language: "auto", model: "whisper-large-v3" })).toEqual({
|
|
36
|
+
apiKey: "gsk_x",
|
|
37
|
+
language: "auto",
|
|
38
|
+
model: "whisper-large-v3",
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("the cost line keeps the shell script's four columns", () => {
|
|
43
|
+
expect(logLine(new Date(2026, 9, 19, 8, 5, 3), 12.34, 0.000137)).toBe("2026-10-19 08:05:03 12.3 0.000137")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("the totals only count today's lines, and ignore junk", () => {
|
|
47
|
+
const now = new Date(2026, 9, 19, 12, 0, 0)
|
|
48
|
+
const log = [
|
|
49
|
+
"2026-10-18 16:30:57 453.7 0.005041",
|
|
50
|
+
"2026-10-19 08:05:03 12.3 0.000137",
|
|
51
|
+
"2026-10-19 09:00:00 30.0 0.000333",
|
|
52
|
+
"garbage line",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n")
|
|
55
|
+
const totals = summarize(log, now)
|
|
56
|
+
expect(totals.today).toBeCloseTo(0.00047, 9)
|
|
57
|
+
expect(totals.total).toBeCloseTo(0.005511, 9)
|
|
58
|
+
expect(totals.todayCount).toBe(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("the ticker runs until its stop function is called", async () => {
|
|
62
|
+
let ticks = 0
|
|
63
|
+
const stop = startTicker(() => ticks++, 5)
|
|
64
|
+
await new Promise((done) => setTimeout(done, 22))
|
|
65
|
+
stop()
|
|
66
|
+
const seen = ticks
|
|
67
|
+
await new Promise((done) => setTimeout(done, 20))
|
|
68
|
+
expect(seen).toBeGreaterThan(0)
|
|
69
|
+
expect(ticks).toBe(seen)
|
|
70
|
+
})
|
package/src/voice.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The parts of dictation that do not touch a terminal: config shape, WAV maths,
|
|
2
|
+
// the local RPM window, the cost-log line and the totals read back from it.
|
|
3
|
+
// Kept here so `bun test` can check them without the TUI.
|
|
4
|
+
|
|
5
|
+
export const MODELS = ["whisper-large-v3-turbo", "whisper-large-v3"] as const
|
|
6
|
+
export type Model = (typeof MODELS)[number]
|
|
7
|
+
export const DEFAULT_MODEL: Model = "whisper-large-v3-turbo"
|
|
8
|
+
// Both models bill the same per audio hour; turbo is only faster.
|
|
9
|
+
export const USD_PER_HOUR = 0.04
|
|
10
|
+
export const RPM_LIMIT = 20
|
|
11
|
+
// A forgotten recording would upload silence for minutes; cap it.
|
|
12
|
+
export const MAX_SECONDS = 60
|
|
13
|
+
export const SAMPLE_RATE = 16_000
|
|
14
|
+
export const BYTES_PER_SAMPLE = 2
|
|
15
|
+
// parec writes a canonical 44-byte header; anything past it is audio.
|
|
16
|
+
export const WAV_HEADER = 44
|
|
17
|
+
|
|
18
|
+
export type Config = { apiKey: string; language: string; model: Model }
|
|
19
|
+
|
|
20
|
+
export const DEFAULTS: Config = { apiKey: "", language: "it", model: DEFAULT_MODEL }
|
|
21
|
+
|
|
22
|
+
export function parseConfig(raw: unknown): Config {
|
|
23
|
+
const value = (raw ?? {}) as Record<string, unknown>
|
|
24
|
+
const model = MODELS.includes(value.model as Model) ? (value.model as Model) : DEFAULTS.model
|
|
25
|
+
return {
|
|
26
|
+
apiKey: typeof value.apiKey === "string" ? value.apiKey : DEFAULTS.apiKey,
|
|
27
|
+
language: typeof value.language === "string" && value.language ? value.language : DEFAULTS.language,
|
|
28
|
+
model,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function audioSeconds(bytes: number): number {
|
|
33
|
+
return Math.max(0, bytes - WAV_HEADER) / (SAMPLE_RATE * BYTES_PER_SAMPLE)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function cost(seconds: number): number {
|
|
37
|
+
return (seconds / 3600) * USD_PER_HOUR
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recentRpm(stamps: number[], now: number): number[] {
|
|
41
|
+
return stamps.filter((at) => now - at < 60_000)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Same columns the shell script wrote: `date time seconds cost`, which is what
|
|
45
|
+
// the /dictate-cost command's awk still reads.
|
|
46
|
+
export function logLine(at: Date, seconds: number, usd: number): string {
|
|
47
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
48
|
+
const date = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
|
|
49
|
+
const time = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
|
|
50
|
+
return `${date} ${time} ${seconds.toFixed(1)} ${usd.toFixed(6)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Totals = { today: number; total: number; todayCount: number }
|
|
54
|
+
|
|
55
|
+
// Reads back the same four columns, ignoring anything malformed so a hand-edited
|
|
56
|
+
// log line cannot break the sidebar.
|
|
57
|
+
export function summarize(text: string, now: Date): Totals {
|
|
58
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
59
|
+
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
|
60
|
+
const totals: Totals = { today: 0, total: 0, todayCount: 0 }
|
|
61
|
+
for (const line of text.split("\n")) {
|
|
62
|
+
const [date, , , rawCost] = line.trim().split(/\s+/)
|
|
63
|
+
const usd = Number(rawCost)
|
|
64
|
+
if (!date || !Number.isFinite(usd)) continue
|
|
65
|
+
totals.total += usd
|
|
66
|
+
if (date === today) {
|
|
67
|
+
totals.today += usd
|
|
68
|
+
totals.todayCount += 1
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return totals
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One ticker for the recording counter: the TUI only supplies the signal.
|
|
75
|
+
export function startTicker(onTick: () => void, ms = 1000): () => void {
|
|
76
|
+
const id = setInterval(onTick, ms)
|
|
77
|
+
return () => clearInterval(id)
|
|
78
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { execFile, spawn } from "node:child_process"
|
|
3
|
+
import { readFile, appendFile, rm, stat } from "node:fs/promises"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { Show, createMemo, createSignal } from "solid-js"
|
|
7
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
8
|
+
import {
|
|
9
|
+
DEFAULTS,
|
|
10
|
+
MAX_SECONDS,
|
|
11
|
+
MODELS,
|
|
12
|
+
RPM_LIMIT,
|
|
13
|
+
audioSeconds,
|
|
14
|
+
cost,
|
|
15
|
+
logLine,
|
|
16
|
+
parseConfig,
|
|
17
|
+
recentRpm,
|
|
18
|
+
startTicker,
|
|
19
|
+
summarize,
|
|
20
|
+
} from "./src/voice"
|
|
21
|
+
|
|
22
|
+
const WAV = "/tmp/opencode-groq-dictate.wav"
|
|
23
|
+
const COST_LOG = join(homedir(), ".cache/groq-dictate-costs.log")
|
|
24
|
+
const GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
|
|
25
|
+
// Same column width as the go-meter panel, so both plugins line up.
|
|
26
|
+
const LABEL_WIDTH = 8
|
|
27
|
+
|
|
28
|
+
// super+v is the key the shell script used; here the recording, the upload and the
|
|
29
|
+
// paste all stay inside the TUI, so nothing depends on which window has focus.
|
|
30
|
+
export default Plugin.define({
|
|
31
|
+
id: "opencode-groq-dictate",
|
|
32
|
+
setup(context) {
|
|
33
|
+
// The docs show the first element used as a value, other plugins read it as an
|
|
34
|
+
// accessor; accepting both keeps the plugin working whichever it is.
|
|
35
|
+
const read = <T,>(value: T | (() => T)): T =>
|
|
36
|
+
typeof value === "function" ? (value as () => T)() : value
|
|
37
|
+
|
|
38
|
+
const [config, updateConfig] = context.storage.store("opencode-groq-dictate", { initial: DEFAULTS })
|
|
39
|
+
const [rpm, updateRpm] = context.storage.memory("opencode-groq-dictate.rpm", { initial: [] as number[] })
|
|
40
|
+
const [quota, updateQuota] = context.storage.memory("opencode-groq-dictate.quota", {
|
|
41
|
+
initial: { remaining: 0, limit: 0 },
|
|
42
|
+
})
|
|
43
|
+
let recorder: ReturnType<typeof spawn> | null = null
|
|
44
|
+
// A signal, not a plain variable: the footer indicator has to re-render when
|
|
45
|
+
// it flips, and a call still reads the current value inside a command.
|
|
46
|
+
const [recording, setRecording] = createSignal(false)
|
|
47
|
+
const [elapsed, setElapsed] = createSignal(0)
|
|
48
|
+
// "busy" covers the gap between stop and paste, so the dot never blinks off.
|
|
49
|
+
const [busy, setBusy] = createSignal(false)
|
|
50
|
+
const [totals, setTotals] = createSignal({ today: 0, total: 0, todayCount: 0 })
|
|
51
|
+
const [last, setLast] = createSignal<{ seconds: number; usd: number } | null>(null)
|
|
52
|
+
let ticker: (() => void) | null = null
|
|
53
|
+
let autoStop: ReturnType<typeof setTimeout> | null = null
|
|
54
|
+
|
|
55
|
+
const settings = () => parseConfig(read(config))
|
|
56
|
+
|
|
57
|
+
// Seeded from the log the shell script already wrote, so the sidebar shows the
|
|
58
|
+
// real spend from the first run instead of starting at zero.
|
|
59
|
+
void readFile(COST_LOG, "utf8")
|
|
60
|
+
.then((text) => setTotals(summarize(text, new Date())))
|
|
61
|
+
.catch(() => {})
|
|
62
|
+
|
|
63
|
+
async function askKey() {
|
|
64
|
+
const apiKey = await context.ui.dialog.prompt({
|
|
65
|
+
title: "Groq API key",
|
|
66
|
+
placeholder: "gsk_...",
|
|
67
|
+
})
|
|
68
|
+
if (!apiKey) return ""
|
|
69
|
+
await updateConfig((draft) => {
|
|
70
|
+
draft.apiKey = apiKey.trim()
|
|
71
|
+
})
|
|
72
|
+
return apiKey.trim()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function paste(text: string) {
|
|
76
|
+
// prompt.paste reads the system clipboard, so the text goes there first and
|
|
77
|
+
// the composer receives it exactly as if it had been pasted by hand.
|
|
78
|
+
const clip = spawn("xclip", ["-selection", "clipboard"], { stdio: ["pipe", "ignore", "ignore"] })
|
|
79
|
+
clip.stdin.end(text)
|
|
80
|
+
await new Promise((done) => clip.on("close", done))
|
|
81
|
+
context.keymap.dispatch("prompt.paste")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One round trip to Groq. Returns the text, or why there is none, so the
|
|
85
|
+
// caller does not have to read the response twice.
|
|
86
|
+
async function request(key: string, model: string, language: string) {
|
|
87
|
+
const bytes = await readFile(WAV)
|
|
88
|
+
const form = new FormData()
|
|
89
|
+
form.append("file", new Blob([bytes], { type: "audio/wav" }), "voice.wav")
|
|
90
|
+
form.append("model", model)
|
|
91
|
+
if (language !== "auto") form.append("language", language)
|
|
92
|
+
|
|
93
|
+
const response = await fetch(GROQ_URL, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
96
|
+
body: form,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// The daily figure only ever arrives as a header, so it is read off the real
|
|
100
|
+
// request instead of spending a transcription on a probe.
|
|
101
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining-requests"))
|
|
102
|
+
const limit = Number(response.headers.get("x-ratelimit-limit-requests"))
|
|
103
|
+
if (limit > 0)
|
|
104
|
+
await updateQuota((draft) => {
|
|
105
|
+
draft.remaining = remaining
|
|
106
|
+
draft.limit = limit
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (response.status === 401) {
|
|
110
|
+
await updateConfig((draft) => {
|
|
111
|
+
draft.apiKey = ""
|
|
112
|
+
})
|
|
113
|
+
return { error: "Groq API key rejected" }
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) return { error: `Groq ${response.status}: ${await response.text()}` }
|
|
116
|
+
|
|
117
|
+
const text = ((await response.json()) as { text?: string }).text?.trim()
|
|
118
|
+
return text ? { text } : { warning: "Nothing transcribed" }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Cost bookkeeping: the log line is best effort, the totals are in memory.
|
|
122
|
+
async function bookkeep(seconds: number) {
|
|
123
|
+
const usd = cost(seconds)
|
|
124
|
+
try {
|
|
125
|
+
await appendFile(COST_LOG, logLine(new Date(), seconds, usd) + "\n")
|
|
126
|
+
} catch (error) {
|
|
127
|
+
context.ui.toast.show({ message: `Cost log: ${String(error)}`, variant: "warning" })
|
|
128
|
+
}
|
|
129
|
+
setTotals((current) => ({
|
|
130
|
+
today: current.today + usd,
|
|
131
|
+
total: current.total + usd,
|
|
132
|
+
todayCount: current.todayCount + 1,
|
|
133
|
+
}))
|
|
134
|
+
setLast({ seconds, usd })
|
|
135
|
+
return usd
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function transcribe(seconds: number) {
|
|
139
|
+
const { apiKey, language, model } = settings()
|
|
140
|
+
const key = apiKey || (await askKey())
|
|
141
|
+
if (!key) return
|
|
142
|
+
|
|
143
|
+
const outcome = await request(key, model, language)
|
|
144
|
+
if (outcome.error) {
|
|
145
|
+
context.ui.toast.show({ message: outcome.error, variant: "error" })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (!outcome.text) {
|
|
149
|
+
context.ui.toast.show({ message: outcome.warning ?? "Nothing transcribed", variant: "warning" })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await paste(outcome.text)
|
|
154
|
+
// The recording is only needed until Groq answers; leaving it in /tmp keeps
|
|
155
|
+
// a copy of everything dictated on disk.
|
|
156
|
+
await rm(WAV, { force: true }).catch(() => {})
|
|
157
|
+
|
|
158
|
+
const usd = await bookkeep(seconds)
|
|
159
|
+
context.ui.toast.show({ message: `Dictated ${seconds.toFixed(1)}s · $${usd.toFixed(4)}`, variant: "success" })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function stop() {
|
|
163
|
+
if (!recorder) return
|
|
164
|
+
const child = recorder
|
|
165
|
+
recorder = null
|
|
166
|
+
setRecording(false)
|
|
167
|
+
setBusy(true)
|
|
168
|
+
ticker?.()
|
|
169
|
+
if (autoStop) clearTimeout(autoStop)
|
|
170
|
+
ticker = null
|
|
171
|
+
autoStop = null
|
|
172
|
+
child.kill("SIGINT")
|
|
173
|
+
await new Promise((done) => child.on("close", done))
|
|
174
|
+
const seconds = audioSeconds((await stat(WAV)).size)
|
|
175
|
+
if (seconds < 0.2) {
|
|
176
|
+
context.ui.toast.show({ message: "Recording too short", variant: "warning" })
|
|
177
|
+
setBusy(false)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
context.ui.toast.show({ message: "Transcribing…", variant: "info" })
|
|
181
|
+
try {
|
|
182
|
+
await transcribe(seconds)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
context.ui.toast.show({ message: String(error), variant: "error" })
|
|
185
|
+
} finally {
|
|
186
|
+
setBusy(false)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A monitor source records the speakers, not the room: that silence comes back as
|
|
191
|
+
// an invented sentence ("Grazie a tutti!"), so the take is refused before it starts.
|
|
192
|
+
const hasMicrophone = () =>
|
|
193
|
+
new Promise<boolean>((done) => {
|
|
194
|
+
execFile("pactl", ["get-default-source"], (error, stdout) =>
|
|
195
|
+
done(!!error || !stdout.trim().endsWith(".monitor")),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
async function start() {
|
|
200
|
+
const { apiKey } = settings()
|
|
201
|
+
if (!apiKey) {
|
|
202
|
+
context.ui.toast.show({ message: "Set the Groq API key first", variant: "warning" })
|
|
203
|
+
if (!(await askKey())) return
|
|
204
|
+
}
|
|
205
|
+
if (!(await hasMicrophone())) {
|
|
206
|
+
context.ui.toast.show({
|
|
207
|
+
message: "No microphone: the default source is a monitor",
|
|
208
|
+
variant: "error",
|
|
209
|
+
})
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const now = Date.now()
|
|
213
|
+
const recent = recentRpm(read(rpm), now)
|
|
214
|
+
if (recent.length >= RPM_LIMIT) {
|
|
215
|
+
context.ui.toast.show({ message: `Groq free tier: ${RPM_LIMIT}/min reached, wait a moment`, variant: "error" })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
await updateRpm((draft) => {
|
|
219
|
+
draft.splice(0, draft.length, ...recent, now)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
recorder = spawn("parec", ["--rate=16000", "--channels=1", "--file-format=wav", WAV], {
|
|
223
|
+
stdio: "ignore",
|
|
224
|
+
})
|
|
225
|
+
recorder.on("error", (error) => {
|
|
226
|
+
recorder = null
|
|
227
|
+
setRecording(false)
|
|
228
|
+
setBusy(false)
|
|
229
|
+
ticker?.()
|
|
230
|
+
if (autoStop) clearTimeout(autoStop)
|
|
231
|
+
context.ui.toast.show({ message: `parec: ${error.message}`, variant: "error" })
|
|
232
|
+
})
|
|
233
|
+
setRecording(true)
|
|
234
|
+
setElapsed(0)
|
|
235
|
+
ticker = startTicker(() => setElapsed((value) => value + 1))
|
|
236
|
+
// Nobody wants a ten-minute upload because the stop key was forgotten.
|
|
237
|
+
autoStop = setTimeout(() => {
|
|
238
|
+
context.ui.toast.show({ message: `Stopped at ${MAX_SECONDS}s`, variant: "warning" })
|
|
239
|
+
void stop()
|
|
240
|
+
}, MAX_SECONDS * 1000)
|
|
241
|
+
context.ui.toast.show({ message: "Recording… alt+v stop · esc cancel", variant: "info", duration: 2000 })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
// Esc aborts the take: nothing is uploaded, so no quota and no cost.
|
|
246
|
+
async function cancel() {
|
|
247
|
+
if (!recorder) return
|
|
248
|
+
const child = recorder
|
|
249
|
+
recorder = null
|
|
250
|
+
setRecording(false)
|
|
251
|
+
setBusy(false)
|
|
252
|
+
ticker?.()
|
|
253
|
+
ticker = null
|
|
254
|
+
if (autoStop) clearTimeout(autoStop)
|
|
255
|
+
autoStop = null
|
|
256
|
+
child.kill("SIGINT")
|
|
257
|
+
await new Promise((done) => child.on("close", done))
|
|
258
|
+
await rm(WAV, { force: true }).catch(() => {})
|
|
259
|
+
context.ui.toast.show({ message: "Recording cancelled", variant: "info" })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// One entry point for the keybinding and for the click on the panel.
|
|
263
|
+
|
|
264
|
+
const toggle = () => (recording() ? stop() : start())
|
|
265
|
+
|
|
266
|
+
async function pickModel() {
|
|
267
|
+
const model = await context.ui.dialog.select({
|
|
268
|
+
title: "Transcription model",
|
|
269
|
+
options: MODELS.map((id) => ({ value: id, title: id })),
|
|
270
|
+
})
|
|
271
|
+
if (!model) return
|
|
272
|
+
await updateConfig((draft) => {
|
|
273
|
+
draft.model = model
|
|
274
|
+
})
|
|
275
|
+
context.ui.toast.show({ message: `Model: ${model}`, variant: "success" })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function showQuota() {
|
|
279
|
+
const { remaining, limit } = read(quota)
|
|
280
|
+
const used = recentRpm(read(rpm), Date.now()).length
|
|
281
|
+
const { today, total, todayCount } = read(totals)
|
|
282
|
+
await context.ui.dialog.alert({
|
|
283
|
+
title: "Groq dictation",
|
|
284
|
+
message: [
|
|
285
|
+
`Model: ${settings().model}`,
|
|
286
|
+
`Language: ${settings().language}`,
|
|
287
|
+
`Requests today: ${limit > 0 ? `${remaining} / ${limit} left` : "unknown (dictate once)"}`,
|
|
288
|
+
`Local RPM: ${used} / ${RPM_LIMIT}`,
|
|
289
|
+
`Spent today: $${today.toFixed(4)} (${todayCount} runs)`,
|
|
290
|
+
`Spent in total: $${total.toFixed(4)}`,
|
|
291
|
+
].join("\n"),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function pickLanguage() {
|
|
296
|
+
const language = await context.ui.dialog.prompt({
|
|
297
|
+
title: "Language code (blank or 'auto' for detection)",
|
|
298
|
+
placeholder: settings().language,
|
|
299
|
+
})
|
|
300
|
+
if (language === undefined) return
|
|
301
|
+
await updateConfig((draft) => {
|
|
302
|
+
draft.language = language.trim() || "auto"
|
|
303
|
+
})
|
|
304
|
+
context.ui.toast.show({ message: `Language: ${settings().language}`, variant: "success" })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
context.keymap.layer(() => ({
|
|
308
|
+
mode: "global",
|
|
309
|
+
priority: 10,
|
|
310
|
+
commands: [
|
|
311
|
+
{
|
|
312
|
+
id: "opencode-groq-dictate.toggle",
|
|
313
|
+
title: "Dictate (Groq)",
|
|
314
|
+
group: "Voice",
|
|
315
|
+
// Not super+v: VTE only enables xterm modifyOtherKeys, which never
|
|
316
|
+
// reports the Super modifier, so that binding could not fire here.
|
|
317
|
+
bind: "alt+v",
|
|
318
|
+
palette: true,
|
|
319
|
+
slash: { name: "dictate" },
|
|
320
|
+
run: toggle,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "opencode-groq-dictate.cancel",
|
|
324
|
+
title: "Cancel dictation",
|
|
325
|
+
group: "Voice",
|
|
326
|
+
bind: "escape",
|
|
327
|
+
// Returning false hands Escape back to the TUI when nothing is recording.
|
|
328
|
+
run: () => {
|
|
329
|
+
if (!recorder) return false
|
|
330
|
+
void cancel()
|
|
331
|
+
return true
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "opencode-groq-dictate.quota",
|
|
336
|
+
title: "Dictation quota",
|
|
337
|
+
group: "Voice",
|
|
338
|
+
palette: true,
|
|
339
|
+
run: showQuota,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "opencode-groq-dictate.language",
|
|
343
|
+
title: "Dictation language",
|
|
344
|
+
group: "Voice",
|
|
345
|
+
palette: true,
|
|
346
|
+
run: pickLanguage,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "opencode-groq-dictate.model",
|
|
350
|
+
title: "Dictation model",
|
|
351
|
+
group: "Voice",
|
|
352
|
+
palette: true,
|
|
353
|
+
run: pickModel,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
bindings: ["opencode-groq-dictate.toggle", "opencode-groq-dictate.cancel"],
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
const red = () => context.theme?.hue?.red?.["400"] ?? "#ff5555"
|
|
360
|
+
const amber = () => context.theme?.hue?.orange ?? "#FF9654"
|
|
361
|
+
const green = () => context.theme?.hue?.green?.["400"] ?? "#7fbf7f"
|
|
362
|
+
const muted = () => context.theme?.text?.muted ?? context.theme?.text?.subdued ?? "#808080"
|
|
363
|
+
// The brand accent, the same one the go-meter panel uses for its figures.
|
|
364
|
+
const accent = () =>
|
|
365
|
+
context.theme?.hue?.accent?.["500"] ?? context.theme?.hue?.interactive?.["300"] ?? "#FF9654"
|
|
366
|
+
|
|
367
|
+
// The footer row already carries the model and the other status items, so the
|
|
368
|
+
// dot sits next to them and disappears when there is nothing to report.
|
|
369
|
+
context.ui.slot({
|
|
370
|
+
append: "prompt.footer.status",
|
|
371
|
+
render: () => (
|
|
372
|
+
<Show when={recording() || busy()}>
|
|
373
|
+
<text fg={recording() ? red() : amber()}>
|
|
374
|
+
{recording() ? `● REC ${elapsed()}s` : "● …"}
|
|
375
|
+
</text>
|
|
376
|
+
</Show>
|
|
377
|
+
),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Label column, the same width trick the go-meter rows use so the figures line
|
|
381
|
+
// up under each other instead of drifting.
|
|
382
|
+
const row = (label: string, value: string, fg: () => string | undefined, onClick?: () => void) => (
|
|
383
|
+
<box flexDirection="row" gap={0} onMouseUp={onClick}>
|
|
384
|
+
<text>{label.padEnd(LABEL_WIDTH)}</text>
|
|
385
|
+
<text fg={fg()}>{value}</text>
|
|
386
|
+
</box>
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// Memoised so the six rows do not recompute the log totals on every repaint.
|
|
390
|
+
const spent = createMemo(() => read(totals))
|
|
391
|
+
const lastRun = createMemo(() => last())
|
|
392
|
+
|
|
393
|
+
// Same slot the go-meter plugin uses: the sidebar is where a figure can sit
|
|
394
|
+
// without crowding the prompt row.
|
|
395
|
+
context.ui.slot({
|
|
396
|
+
append: "sidebar.content",
|
|
397
|
+
render: () => {
|
|
398
|
+
const figures = spent()
|
|
399
|
+
const used = recentRpm(read(rpm), Date.now()).length
|
|
400
|
+
// Three colours, one meaning each: red while recording, amber while the
|
|
401
|
+
// text is in flight, green when the plugin is idle and ready.
|
|
402
|
+
const state = () => (recording() ? red() : busy() ? amber() : green())
|
|
403
|
+
return (
|
|
404
|
+
<box flexDirection="column">
|
|
405
|
+
<text fg={muted()} onMouseUp={toggle}>
|
|
406
|
+
Groq Dictate
|
|
407
|
+
</text>
|
|
408
|
+
{row(
|
|
409
|
+
"state",
|
|
410
|
+
recording() ? `● rec ${elapsed()}s` : busy() ? "transcribing…" : "idle",
|
|
411
|
+
state,
|
|
412
|
+
toggle,
|
|
413
|
+
)}
|
|
414
|
+
{row("model", settings().model.replace("whisper-", ""), () => undefined, pickModel)}
|
|
415
|
+
{row("lang", settings().language, () => undefined, pickLanguage)}
|
|
416
|
+
{row("today", `$${figures.today.toFixed(4)} · ${figures.todayCount}`, accent)}
|
|
417
|
+
{row("total", `$${figures.total.toFixed(4)}`, accent)}
|
|
418
|
+
<Show when={lastRun()}>
|
|
419
|
+
{(run) => row("last", `${run().seconds.toFixed(1)}s · $${run().usd.toFixed(4)}`, muted)}
|
|
420
|
+
</Show>
|
|
421
|
+
{row("rpm", `${used}/${RPM_LIMIT}`, used >= RPM_LIMIT ? red : muted, showQuota)}
|
|
422
|
+
</box>
|
|
423
|
+
)
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
return () => {
|
|
428
|
+
ticker?.()
|
|
429
|
+
if (autoStop) clearTimeout(autoStop)
|
|
430
|
+
if (recorder) recorder.kill("SIGINT")
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
})
|