From 7947a4a4ebaa1ee4349a0ab88ea467e753f7649d Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 17 Jun 2026 19:47:34 -0500 Subject: [PATCH 1/3] API backend: request and parse word-level timestamps For response_format='verbose_json', .via_api now sends timestamp_granularities[]=word and parses the response's words array into result$words (word/start/end), matching the native whisper backend. Works against OpenAI and a self-hosted whisper::serve(). Verified end-to-end against a whisper serve (48 words returned). Bumps to 0.2.1.3. --- DESCRIPTION | 2 +- NEWS.md | 7 +++++++ R/internal_api.R | 26 +++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) 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..c83a678 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,10 @@ +# 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[]=word` + 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..b911f9e 100644 --- a/R/internal_api.R +++ b/R/internal_api.R @@ -26,6 +26,13 @@ form_data$response_format <- response_format + # Request word-level timestamps for verbose_json, so result$words is + # populated the same way the in-process whisper backend does (OpenAI and + # whisper::serve both honor timestamp_granularities[]=word). + if (identical(response_format, "verbose_json")) { + form_data[["timestamp_granularities[]"]] <- "word" + } + # Build headers (curl expects "Name: Value" format) headers <- "Accept: application/json" if (!is.null(api_key) && nchar(api_key) > 0) { @@ -111,12 +118,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 } From 6f02a1ead243e49d6fdb1022b171942aac5a08b4 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 17 Jun 2026 20:06:54 -0500 Subject: [PATCH 2/3] API backend: request both segment and word granularities OpenAI treats timestamp_granularities[] values as separate; requesting word alone can suppress segments and break the verbose_json segment contract. Send both segment and word (two array-style fields) so segments are preserved and words added. Verified against the whisper serve: 2 segments + 48 words. --- R/internal_api.R | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/R/internal_api.R b/R/internal_api.R index b911f9e..1e7bccb 100644 --- a/R/internal_api.R +++ b/R/internal_api.R @@ -27,10 +27,14 @@ form_data$response_format <- response_format # Request word-level timestamps for verbose_json, so result$words is - # populated the same way the in-process whisper backend does (OpenAI and - # whisper::serve both honor timestamp_granularities[]=word). + # 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")) { - form_data[["timestamp_granularities[]"]] <- "word" + gran <- list("segment", "word") + names(gran) <- c("timestamp_granularities[]", "timestamp_granularities[]") + form_data <- c(form_data, gran) } # Build headers (curl expects "Name: Value" format) From 28bcee2e27661d858a5698cb8faf00bf6e6ff91a Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 17 Jun 2026 20:16:58 -0500 Subject: [PATCH 3/3] NEWS: reflect that the API backend requests both segment and word granularities --- NEWS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index c83a678..8481c11 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,9 +1,10 @@ # 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[]=word` - and returns `result$words` (word/start/end), matching the native whisper - backend. Works against OpenAI and a self-hosted `whisper::serve()`. + `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