Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: tts.api
Title: OpenAI-Compatible Text-to-Speech API Client
Version: 0.2.0
Version: 0.2.0.1
Authors@R:
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai")
Expand Down
59 changes: 59 additions & 0 deletions R/sidecar.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# sidecar.R
# Call-record sidecars: every asset this package writes gets a JSON record of
# the call that made it, at <output>.json. The API client is the only place
# the resolved call exists (defaults filled, sizes computed), and the sidecar
# rides with the media file -- it survives any downstream bundle or timeline
# rebuild. Downstream timeline tooling can lift these records into edit
# metadata. Convention shared across cornball.ai generation packages
# (cornball_sidecar schema v1: package, version, fn, request, elapsed,
# created).

# Arm a sidecar for the calling function: registers an on.exit hook that, on
# return, writes <output>.json when the output file exists with an mtime at or
# after the call started (i.e. the call actually produced its asset; error
# paths and cache hits write nothing). One line at the top of a public
# generation function: .sidecar_arm(environment()) -- use the output_arg
# name of the function's output-path argument ("output", "file", ...).
.sidecar_arm <- function(env, output_arg = "output") {
fn_call <- sys.call(-1)
fn <- if (is.null(fn_call)) "unknown" else deparse(fn_call[[1]])
arg_names <- setdiff(names(formals(sys.function(-1))), "...")
started <- Sys.time()
# Splice the function OBJECT into the on.exit call: the hook then needs
# no name lookup, so it works regardless of the caller's search path.
expr <- bquote((.(.sidecar_finish))(.(fn), .(output_arg), .(env),
.(started), .(arg_names)))
do.call(on.exit, list(expr, add = TRUE), envir = env)
}

# The on.exit half: snapshot the function's RESOLVED arguments (short atomics
# only -- no raw payloads) and write the record next to the produced asset.
.sidecar_finish <- function(fn, output_arg, env, started, arg_names) {
out <- tryCatch(get(output_arg, envir = env), error = function(e) NULL)
ok <- is.character(out) && length(out) == 1 && !is.na(out) &&
file.exists(out) && file.mtime(out) >= started - 1
if (!ok) {
return(invisible(NULL))
}
a <- mget(arg_names, envir = env, ifnotfound = list(NULL))
keep <- vapply(a, function(x) {
!is.null(x) && is.atomic(x) && !is.raw(x) && length(x) <= 16
}, logical(1))
.write_sidecar(out, fn, a[keep], started)
}

# Write the record. A sidecar failure must never break generation.
.write_sidecar <- function(output, fn, request, started = NULL) {
pkg <- utils::packageName()
rec <- list(cornball_sidecar = 1L, package = pkg,
version = as.character(utils::packageVersion(pkg)),
fn = fn, request = request,
elapsed = if (!is.null(started)) {
round(as.numeric(difftime(Sys.time(), started, units = "secs")), 2)
},
created = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"))
rec <- rec[!vapply(rec, is.null, logical(1))]
try(jsonlite::write_json(rec, paste0(output, ".json"),
auto_unbox = TRUE, pretty = TRUE), silent = TRUE)
invisible(paste0(output, ".json"))
}
1 change: 1 addition & 0 deletions R/speech_clone.R
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ speech_clone <- function (input, voice_file, file = NULL,
language = NULL, exaggeration = NULL,
temperature = NULL, cfg_weight = NULL, speed = NULL,
seed = NULL) {
.sidecar_arm(environment(), "file")
backend <- match.arg(backend)

# Auto-detect backend: qwen3 has /v1/audio/speech/design endpoint
Expand Down
1 change: 1 addition & 0 deletions R/speech_design.R
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
#' }
speech_design <- function (input, voice_description, file = NULL,
language = "English") {
.sidecar_arm(environment(), "file")
# Acquire GPU for qwen3 (this function is qwen3-only)
.gpuctl_acquire("qwen3")

Expand Down
1 change: 1 addition & 0 deletions R/tts.R
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ tts <- function (input, voice, file = NULL,
similarity_boost = NULL, seed = NULL,
response_format = NULL, instructions = NULL,
language = NULL, device = "cuda") {
.sidecar_arm(environment(), "file")
backend <- match.arg(backend)

# Validate required parameters early (before backend dispatch)
Expand Down
24 changes: 24 additions & 0 deletions inst/tinytest/test_providers.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Test TTS provider configuration

# tts_providers is a named list
expect_true(is.list(tts_providers))
expect_true(length(tts_providers) > 0)

# Expected providers exist
expect_true("OpenAI" %in% names(tts_providers))
expect_true("ElevenLabs" %in% names(tts_providers))

# Each provider has expected structure
for (name in names(tts_providers)) {
p <- tts_providers[[name]]
expect_true(is.list(p), info = paste(name, "should be a list"))
expect_true("voices" %in% names(p) || is.null(p$voices),
info = paste(name, "should have voices field"))
expect_true("base_url" %in% names(p),
info = paste(name, "should have base_url"))
}

# OpenAI has static voice list
expect_true(is.character(tts_providers[["OpenAI"]]$voices))
expect_true("nova" %in% tts_providers[["OpenAI"]]$voices)
expect_true("alloy" %in% tts_providers[["OpenAI"]]$voices)
57 changes: 57 additions & 0 deletions inst/tinytest/test_sidecar.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Call-record sidecars: armed functions write <output>.json on success only.

# A toy "generation" function standing in for a backend call.
toy_gen <- function(prompt, output = tempfile(fileext = ".mp4"),
seed = NULL, fail = FALSE) {
tts.api:::.sidecar_arm(environment())
if (fail) {
stop("backend exploded")
}
writeLines("video bytes", output)
invisible(output)
}

out <- toy_gen("a corny prompt", seed = 42L)
sc <- paste0(out, ".json")
expect_true(file.exists(sc))
rec <- jsonlite::fromJSON(sc)
expect_equal(rec$cornball_sidecar, 1L)
expect_equal(rec$package, "tts.api")
expect_equal(rec$fn, "toy_gen")
expect_equal(rec$request$prompt, "a corny prompt")
expect_equal(rec$request$seed, 42L)
expect_equal(rec$request$fail, FALSE)
expect_true(is.numeric(rec$elapsed))
unlink(c(out, sc))

# Error path: no asset, no sidecar.
out2 <- tempfile(fileext = ".mp4")
expect_error(toy_gen("boom", output = out2, fail = TRUE))
expect_false(file.exists(paste0(out2, ".json")))

# Pre-existing stale file (cache hit / skipped work): no sidecar.
out3 <- tempfile(fileext = ".mp4")
writeLines("old", out3)
Sys.setFileTime(out3, Sys.time() - 3600)
toy_cache <- function(output) {
tts.api:::.sidecar_arm(environment())
invisible(output) # touches nothing
}
toy_cache(out3)
expect_false(file.exists(paste0(out3, ".json")))
unlink(out3)

# file-named output arg, resolved inside the function (rembg shape).
toy_file <- function(image, file = NULL) {
tts.api:::.sidecar_arm(environment(), "file")
if (is.null(file)) {
file <- tempfile(fileext = ".png")
}
writeLines("png bytes", file)
invisible(file)
}
res <- toy_file("in.png")
expect_true(file.exists(paste0(res, ".json")))
rec2 <- jsonlite::fromJSON(paste0(res, ".json"))
expect_equal(rec2$request$image, "in.png")
unlink(c(res, paste0(res, ".json")))
37 changes: 37 additions & 0 deletions inst/tinytest/test_speech_clone.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Test speech_clone() input validation

# input must be non-empty character
expect_error(
speech_clone(NULL, voice_file = "x.wav"),
pattern = "non-empty character"
)
expect_error(
speech_clone("", voice_file = "x.wav"),
pattern = "non-empty character"
)
expect_error(
speech_clone(123, voice_file = "x.wav"),
pattern = "non-empty character"
)

# voice_file must be a character string
expect_error(
speech_clone("hello", voice_file = NULL),
pattern = "character string"
)
expect_error(
speech_clone("hello", voice_file = 123),
pattern = "character string"
)

# voice_file must exist
expect_error(
speech_clone("hello", voice_file = "nonexistent_xyz.wav"),
pattern = "not found"
)

# backend must be valid
expect_error(
speech_clone("hello", voice_file = "x.wav", backend = "invalid"),
pattern = "arg"
)
21 changes: 21 additions & 0 deletions inst/tinytest/test_speech_design.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Test speech_design() input validation

# input must be non-empty character
expect_error(
speech_design(NULL, voice_description = "warm voice"),
pattern = "non-empty character"
)
expect_error(
speech_design("", voice_description = "warm voice"),
pattern = "non-empty character"
)

# voice_description must be non-empty character
expect_error(
speech_design("hello", voice_description = NULL),
pattern = "non-empty character"
)
expect_error(
speech_design("hello", voice_description = ""),
pattern = "non-empty character"
)