diff --git a/DESCRIPTION b/DESCRIPTION index b58233f..64b6395 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: stt.api Title: 'OpenAI' Compatible Speech-to-Text API Client -Version: 0.2.1.2 +Version: 0.2.1.3 Authors@R: c( person("Troy", "Hernandez", email = "troy@cornball.ai", role = c("aut", "cre"), comment = c(ORCID = "0009-0005-4248-604X")), diff --git a/NEWS.md b/NEWS.md index 92ef088..8481c11 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# stt.api 0.2.1.3 + +* The API backend now requests and parses word-level timestamps: with + `response_format = "verbose_json"` it sends `timestamp_granularities[]` for + both `segment` and `word` (requesting word alone can suppress segments on + OpenAI) and returns `result$words` (word/start/end), matching the native + whisper backend. Works against OpenAI and a self-hosted `whisper::serve()`. + # stt.api 0.2.1.2 * `stt()` gains a `source` axis ("auto", "api", "package"), mirroring diff --git a/R/internal_api.R b/R/internal_api.R index fc1b63a..1e7bccb 100644 --- a/R/internal_api.R +++ b/R/internal_api.R @@ -26,6 +26,17 @@ form_data$response_format <- response_format + # Request word-level timestamps for verbose_json, so result$words is + # populated like the in-process whisper backend. OpenAI treats word and + # segment as separate granularities, and requesting word alone can suppress + # segments -- so ask for BOTH (two array-style fields). whisper::serve + # honors either. + if (identical(response_format, "verbose_json")) { + gran <- list("segment", "word") + names(gran) <- c("timestamp_granularities[]", "timestamp_granularities[]") + form_data <- c(form_data, gran) + } + # Build headers (curl expects "Name: Value" format) headers <- "Accept: application/json" if (!is.null(api_key) && nchar(api_key) > 0) { @@ -111,12 +122,29 @@ segments <- .normalize_segments(segments) } - list( + # Extract word-level timestamps if available (verbose_json + word + # granularity), mirroring the native whisper backend's result$words. + words <- NULL + if (!is.null(parsed$words) && length(parsed$words) > 0) { + words <- tryCatch( + do.call(rbind, lapply(parsed$words, function(w) { + data.frame(word = w$word, start = w$start, end = w$end, + stringsAsFactors = FALSE) + })), + error = function(e) NULL + ) + } + + out <- list( text = parsed$text %||% "", segments = segments, language = parsed$language %||% language, backend = "api", raw = parsed ) + if (!is.null(words)) { + out$words <- words + } + out }