--[[ 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