Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,21 @@ For the rest of the whisper commands, the transcription is used as an editable p
You can override the default language by setting {lang} with the 2 letter
shortname of your language (e.g. "en" for English, "fr" for French etc).

To use [60db speech-to-text](https://docs.60db.ai/api-reference/stt/speech-to-text),
set `SIXTYDB_API_KEY` and configure:

```lua
require("gp").setup({
whisper = { provider = "60db", language = "auto" },
})
```

All `GpWhisper*` commands use this provider. OpenAI remains the default. A 60db
transcription key is independent of your chat provider credentials. You can also
set `whisper.secret` to a key or a command table, using the existing secret resolver.
60db accepts MP3 recordings up to 10 MB and one hour; requests time out after
60 seconds. Audio is uploaded to 60db; recording and SoX processing stay local.

#### `:GpWhisperRewrite` <!-- {doc=:GpWhisperRewrite} -->

Similar to `:GpRewrite`, but the prompt instruction dialog uses transcribed spoken instructions.
Expand Down
2 changes: 2 additions & 0 deletions lua/gp/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ local config = {
-- we can reduce the cost by 50% or more and get the results faster

whisper = {
-- Speech-to-text provider: "openai" (default) or "60db".
provider = "openai",
-- you can disable whisper completely by whisper = {disable = true}
disable = false,

Expand Down
107 changes: 55 additions & 52 deletions lua/gp/whisper.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ local W = {

---@param opts table # user config
W.setup = function(opts)
logger.debug("whisper setup started\n" .. vim.inspect(opts))
logger.debug("whisper setup started\n" .. vim.inspect(opts), true)

W.config = vim.deepcopy(default_config.whisper)

Expand All @@ -34,6 +34,12 @@ W.setup = function(opts)
W.config[k] = v
end

if W.config.provider == "60db" then
vault.add_secret("sixtydb_stt", opts.secret or os.getenv("SIXTYDB_API_KEY"))
W.config.endpoint = opts.endpoint or "https://api.60db.ai/stt"
end
W.config.secret = nil

W.config.store_dir = helpers.prepare_dir(W.config.store_dir, "whisper store")

for cmd, _ in pairs(W.cmd) do
Expand All @@ -52,9 +58,9 @@ local whisper = function(callback, language)
return
end

local bearer = vault.get_secret("openai_api_key")
local bearer = vault.get_secret(W.config.provider == "60db" and "sixtydb_stt" or "openai_api_key")
if not bearer then
logger.error("OpenAI API key not found")
logger.error("Transcription API key not found")
return
end

Expand Down Expand Up @@ -175,61 +181,54 @@ local whisper = function(callback, language)
-- cleanup on buffer exit
helpers.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { buf }, close, gid)

local curl_params = W.config.curl_params or {}
local curl = "curl" .. " " .. table.concat(curl_params, " ")

-- transcribe the recording
-- Process audio first; pass credentials and form fields as argv, never shell code.
local transcribe = function()
local cmd = "cd "
.. W.config.store_dir
.. " && "
.. "export LC_NUMERIC='C' && "
-- normalize volume to -3dB
.. "sox --norm=-3 rec.wav norm.wav && "
-- get RMS level dB * silence threshold
.. "t=$(sox 'norm.wav' -n channels 1 stats 2>&1 | grep 'RMS lev dB' "
.. " | sed -e 's/.* //' | awk '{print $1*"
.. W.config.silence
.. "}') && "
-- remove silence, speed up, pad and convert to mp3
local cmd = "cd " .. vim.fn.shellescape(W.config.store_dir)
.. " && export LC_NUMERIC='C' && sox --norm=-3 rec.wav norm.wav && "
.. "t=$(sox norm.wav -n channels 1 stats 2>&1 | grep 'RMS lev dB' "
.. "| sed -e 's/.* //' | awk '{print $1*" .. W.config.silence .. "}') && "
.. "sox -q norm.wav -C 196.5 final.mp3 silence -l 1 0.05 $t'dB' -1 1.0 $t'dB'"
.. " pad 0.1 0.1 tempo "
.. W.config.tempo
.. " && "
-- call openai
.. curl
.. " --max-time 20 "
.. W.config.endpoint
.. ' -s -H "Authorization: Bearer '
.. bearer
.. '" -H "Content-Type: multipart/form-data" '
.. '-F model="whisper-1" -F language="'
.. language
.. '" -F file="@final.mp3;type=audio/mpeg" '
.. '-F response_format="json"'

tasker.run(nil, "bash", { "-c", cmd }, function(code, signal, stdout, _)
if code ~= 0 then
logger.error(string.format("Whisper query exited: %d, %d", code, signal))
return
end
.. " pad 0.1 0.1 tempo " .. W.config.tempo

if not stdout or stdout == "" or #stdout < 11 then
logger.error("Whisper query, no stdout: " .. vim.inspect(stdout))
tasker.run(nil, "bash", { "-c", cmd }, function(code)
if code ~= 0 then
logger.error("Transcription audio processing failed")
return
end
local text = vim.json.decode(stdout).text
if not text then
logger.error("Whisper query, no text: " .. vim.inspect(stdout))
return
local file = W.config.store_dir .. "/final.mp3"
if W.config.provider == "60db" then
local stat = uv.fs_stat(file)
if not stat or stat.size == 0 or stat.size > 10 * 1024 * 1024 then
logger.error("60db requires a nonempty audio file up to 10 MB")
return
end
end

text = table.concat(vim.split(text, "\n"), " ")
text = text:gsub("%s+$", "")

if callback and stdout then
callback(text)
local args = vim.deepcopy(W.config.curl_params or {})
vim.list_extend(args, {
"--max-time", W.config.provider == "60db" and "60" or "20", "--silent", "--show-error", "--fail",
"--header", "Authorization: Bearer " .. bearer,
"--form", 'file=@"' .. file:gsub("\\", "\\\\"):gsub('"', '\\"') .. '";type=audio/mpeg',
"--form-string", "language=" .. language,
})
if W.config.provider ~= "60db" then
vim.list_extend(args, { "--form-string", "model=whisper-1", "--form-string", "response_format=json" })
end
table.insert(args, W.config.endpoint)
tasker.run(nil, "curl", args, function(status, _, stdout)
if status ~= 0 then
logger.error("Transcription request failed (curl exit " .. tostring(status) .. ")")
return
end
local ok, response = pcall(vim.json.decode, stdout or "")
if not ok or type(response) ~= "table" or response.success == false or type(response.text) ~= "string" then
logger.error("Transcription returned an invalid response")
return
end
local text = table.concat(vim.split(response.text, "\n"), " "):gsub("%s+$", "")
if callback then
callback(text)
end
end)
end)
end

Expand Down Expand Up @@ -300,7 +299,11 @@ end
---@param callback function # callback function(text)
---@param language string | nil # language code
W.Whisper = function(callback, language)
vault.run_with_secret("openai_api_key", function()
if W.config.provider ~= "openai" and W.config.provider ~= "60db" then
logger.error("Unknown transcription provider: " .. tostring(W.config.provider))
return
end
vault.run_with_secret(W.config.provider == "60db" and "sixtydb_stt" or "openai_api_key", function()
whisper(callback, language)
end)
end
Expand Down
86 changes: 86 additions & 0 deletions tests/whisper.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- Run from the repository root: nvim --headless -u NONE -l tests/whisper.lua
vim.opt.runtimepath:prepend(vim.fn.getcwd())
local errors, calls, mappings, recording, processing, request, secret_name
local size
local key = 'test-key;$(false)'
local uv = vim.uv
local old_timer, old_stat = uv.new_timer, uv.fs_stat
uv.new_timer = function() return { start = function() end, stop = function() end, close = function() end } end
uv.fs_stat = function() return { size = size } end
vim.schedule = function(fn) fn() end
vim.defer_fn = function(fn) fn() end
vim.fn.executable = function() return 1 end
vim.api.nvim_del_augroup_by_id = function() end
package.loaded["gp.logger"] = {
debug = function() end,
error = function(message) table.insert(errors, message) end,
}
package.loaded["gp.helper"] = {
prepare_dir = function(path) return path end,
create_user_command = function() end,
create_augroup = function() return 1 end,
autocmd = function() end,
set_keymap = function(_, _, name, fn) mappings[name] = fn end,
}
package.loaded["gp.render"] = { popup = function() return 1, 1, function() end end }
package.loaded["gp.vault"] = {
add_secret = function(name, value) assert(name == "sixtydb_stt" and value == key) end,
run_with_secret = function(name, fn) secret_name = name; fn() end,
get_secret = function(name) assert(name == secret_name); return key end,
}
package.loaded["gp.tasker"] = {
once = function(fn) return fn end,
stop = function() end,
run = function(_, command, args, callback)
table.insert(calls, { command = command, args = args })
if command == "sox" then recording = callback
elseif command == "bash" then processing = callback
else assert(command == "curl"); request = callback end
end,
}
local whisper = require("gp.whisper")
local function contains(args, value)
for _, arg in ipairs(args) do if arg == value then return true end end
return false
end
local function run(provider, body, status, bytes, process_status, accept)
errors, calls, mappings = {}, {}, {}
recording, processing, request = nil, nil, nil
size = bytes or 100
local output
whisper.setup({ provider = provider, secret = key, rec_cmd = "sox", cmd_prefix = "Gp", store_dir = "/tmp/audio with spaces" })
assert(whisper.config.secret == nil)
whisper.Whisper(function(text) output = text end, "auto")
if not recording then return output end
if accept ~= false then mappings["<cr>"]() else mappings["<esc>"]() end
recording(0, 0, "", "")
if not processing then return output end
assert(not calls[2].args[2]:find(key, 1, true))
processing(process_status or 0)
if request then request(status or 0, 0, body) end
return output
end
assert(run("60db", '{"text":"hello\\nworld "}') == "hello world")
assert(secret_name == "sixtydb_stt")
local args = calls[3].args
assert(contains(args, "https://api.60db.ai/stt"))
assert(contains(args, "Authorization: Bearer " .. key))
assert(contains(args, 'file=@"/tmp/audio with spaces/final.mp3";type=audio/mpeg'))
assert(contains(args, "language=auto") and contains(args, "--fail"))
assert(not contains(args, "model=whisper-1"))
assert(run(nil, '{"text":"default"}') == "default")
assert(secret_name == "openai_api_key")
assert(contains(calls[3].args, "model=whisper-1"))
assert(contains(calls[3].args, "https://api.openai.com/v1/audio/transcriptions"))
assert(run("60db", '{"text":""}') == "")
for _, body in ipairs({ "not json", "null", '{"text":42}', '{"success":false,"text":"bad"}' }) do
assert(run("60db", body) == nil and #errors == 1)
end
assert(run("60db", '{"text":"bad"}', 22) == nil and #errors == 1)
assert(run("60db", '{"text":"bad"}', 0, 11 * 1024 * 1024) == nil and not request)
assert(run("60db", '{"text":"bad"}', 0, 0) == nil and not request)
assert(run("60db", '{"text":"bad"}', 0, 100, 1) == nil and not request)
assert(run("60db", '{"text":"cancelled"}', 0, 100, 0, false) == nil and #calls == 1)
assert(run("unknown", '{}') == nil and #errors == 1 and #calls == 0)
uv.new_timer, uv.fs_stat = old_timer, old_stat
print("Whisper provider flow checks passed")