Spaces:
Sleeping
Sleeping
--[[ | |
ReaSpeechAPI.lua - ReaSpeech API client | |
]]-- | |
ReaSpeechAPI = { | |
CURL_TIMEOUT_SECONDS = 5, | |
base_url = nil, | |
} | |
function ReaSpeechAPI:init(host, protocol) | |
protocol = protocol or 'http:' | |
self.base_url = protocol .. '//' .. host | |
end | |
function ReaSpeechAPI:get_api_url(remote_path) | |
local remote_path_no_leading_slash = remote_path:gsub("^/+", "") | |
return ("%s/%s"):format(self.base_url, url.quote(remote_path_no_leading_slash)) | |
end | |
function ReaSpeechAPI:get_curl_cmd() | |
local curl = "curl" | |
if not reaper.GetOS():find("Win") then | |
curl = "/usr/bin/curl" | |
end | |
return curl | |
end | |
function ReaSpeechAPI:fetch_json(url_path, http_method, error_handler, success_handler, timeout_handler, retry_count) | |
http_method = http_method or 'GET' | |
error_handler = error_handler or function(_msg) end | |
success_handler = success_handler or function(_response) end | |
timeout_handler = timeout_handler or function() end | |
retry_count = retry_count or 0 | |
local max_retries = 5 | |
local retry_delay = 1 * (2 ^ retry_count) -- Exponential backoff | |
local curl = self:get_curl_cmd() | |
local api_url = self:get_api_url(url_path) | |
local http_method_argument = "" | |
if http_method ~= 'GET' then | |
http_method_argument = " -X " .. http_method | |
end | |
local command = table.concat({ | |
curl, | |
' "', api_url, '"', | |
' -H "accept: application/json"', | |
http_method_argument, | |
' -m ', self.CURL_TIMEOUT_SECONDS, | |
' -s', | |
' -i', | |
' --http1.1', | |
' --retry 5', | |
}) | |
app:debug('Fetch JSON: ' .. command) | |
local exec_result = (ExecProcess.new { command }):wait() | |
if exec_result == nil then | |
local msg = "Unable to run curl" | |
app:log(msg) | |
error_handler(msg) | |
return | |
end | |
local status, output = exec_result:match("(%d+)\n(.*)") | |
status = tonumber(status) | |
if status == 28 then | |
app:debug("Curl timeout reached") | |
timeout_handler() | |
return | |
elseif status ~= 0 then | |
local msg = "Curl failed with status " .. status | |
app:debug(msg) | |
error_handler(msg) | |
return | |
end | |
local response_status, response_body = self.http_status_and_body(output) | |
if response_status >= 500 and retry_count < max_retries then | |
app:debug("Got 500 error, retrying in " .. retry_delay .. " seconds. Retry " .. (retry_count + 1) .. " of " .. max_retries) | |
reaper.defer(function() | |
self:fetch_json(url_path, http_method, error_handler, success_handler, timeout_handler, retry_count + 1) | |
end) | |
return | |
elseif response_status >= 400 then | |
local msg = "Request failed with status " .. response_status | |
app:log(msg) | |
error_handler(msg) | |
return | |
end | |
local response_json = nil | |
if app:trap(function() | |
response_json = json.decode(response_body) | |
end) then | |
success_handler(response_json) | |
else | |
app:log("JSON parse error") | |
app:log(output) | |
error_handler("JSON parse error") | |
end | |
end | |
-- Requests data that may be large or time-consuming. | |
-- This method is non-blocking, and does not give any indication that it has | |
-- completed. The path to the output file is returned. | |
function ReaSpeechAPI:fetch_large(url_path, http_method) | |
http_method = http_method or 'GET' | |
local curl = self:get_curl_cmd() | |
local api_url = self:get_api_url(url_path) | |
local http_method_argument = "" | |
if http_method ~= 'GET' then | |
http_method_argument = " -X " .. http_method | |
end | |
local output_file = Tempfile:name() | |
local sentinel_file = Tempfile:name() | |
local command = table.concat({ | |
curl, | |
' "', api_url, '"', | |
' -H "accept: application/json"', | |
http_method_argument, | |
' -i ', | |
' -o "', output_file, '"', | |
' --http1.1', | |
' --retry 5', | |
}) | |
app:debug('Fetch large: ' .. command) | |
local executor = ExecProcess.new { command, self.touch_cmd(sentinel_file) } | |
if executor:background() then | |
return output_file, sentinel_file | |
else | |
app:log("Unable to run curl") | |
return nil | |
end | |
end | |
ReaSpeechAPI.touch_cmd = function(filename) | |
if reaper.GetOS():find("Win") then | |
return 'echo. > "' .. filename .. '"' | |
else | |
return 'touch "' .. filename .. '"' | |
end | |
end | |
-- Uploads a file to start a request for processing. | |
-- This method is non-blocking, and does not give any indication that it has | |
-- completed. The path to the output file is returned. | |
function ReaSpeechAPI:post_request(url_path, data, file_path) | |
local curl = self:get_curl_cmd() | |
local api_url = self:get_api_url(url_path) | |
local query = {} | |
for k, v in pairs(data) do | |
table.insert(query, k .. '=' .. url.quote(v)) | |
end | |
local output_file = Tempfile:name() | |
local sentinel_file = Tempfile:name() | |
local command = table.concat({ | |
curl, | |
' "', api_url, '?', table.concat(query, '&'), '"', | |
' -H "accept: application/json"', | |
' -H "Content-Type: multipart/form-data"', | |
' -F ', self:_maybe_quote('audio_file=@"' .. file_path .. '"'), | |
' -i ', | |
' --http1.1 ', | |
' --retry 5', | |
' -o "', output_file, '"', | |
}) | |
app:log(file_path) | |
app:debug('Post request: ' .. command) | |
local executor = ExecProcess.new { command, self.touch_cmd(sentinel_file) } | |
if executor:background() then | |
return output_file, sentinel_file | |
else | |
app:log("Unable to run curl") | |
return nil | |
end | |
end | |
function ReaSpeechAPI:_maybe_quote(arg) | |
if reaper.GetOS():find("Win") then | |
return arg | |
else | |
return "'" .. arg .. "'" | |
end | |
end | |
function ReaSpeechAPI.http_status_and_body(response) | |
local headers, content = ReaSpeechAPI._split_curl_response(response) | |
local last_status_line = headers[#headers] and headers[#headers][1] or '' | |
local status = last_status_line:match("^HTTP/%d%.%d%s+(%d+)") | |
if not status then | |
local headers_log = "Headers array (status parsing failed):\n" | |
for i, header_chunk in ipairs(headers) do | |
headers_log = headers_log .. string.format("Chunk %d:\n", i) | |
for j, header_line in ipairs(header_chunk) do | |
headers_log = headers_log .. string.format(" %s\n", header_line) | |
end | |
end | |
app:debug(headers_log) | |
return -1, 'Status not found in headers' | |
end | |
local body = {} | |
for _, chunk in pairs(content) do | |
table.insert(body, table.concat(chunk, "\n")) | |
end | |
return tonumber(status), table.concat(body, "\n") | |
end | |
function ReaSpeechAPI._split_curl_response(input) | |
local line_iterator = ReaSpeechAPI._line_iterator(input) | |
local chunk_iterator = ReaSpeechAPI._chunk_iterator(line_iterator) | |
local header_chunks = {} | |
local content_chunks = {} | |
local in_header = true | |
for chunk in chunk_iterator do | |
if in_header and chunk[1] and chunk[1]:match("^HTTP/%d%.%d") then | |
table.insert(header_chunks, chunk) | |
else | |
in_header = false | |
table.insert(content_chunks, chunk) | |
end | |
end | |
return header_chunks, content_chunks | |
end | |
function ReaSpeechAPI._line_iterator(input) | |
if type(input) == 'string' then | |
local i = 1 | |
local lines = {} | |
for line in input:gmatch("([^\n]*)\n?") do | |
table.insert(lines, line) | |
end | |
return function () | |
local line = lines[i] | |
i = i + 1 | |
return line | |
end | |
else | |
return input:lines() | |
end | |
end | |
function ReaSpeechAPI._chunk_iterator(line_iterator) | |
return function () | |
local chunk = nil | |
while true do | |
local line = line_iterator() | |
if line == nil or line:match("^%s*$") then break end | |
chunk = chunk or {} | |
table.insert(chunk, line) | |
end | |
return chunk | |
end | |
end | |