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
3 changes: 3 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@ export(tts)
export(tts_health)
export(tts_providers)
export(tts_voices)
export(voice_ensure)
export(voice_file)
export(voice_library)
export(voice_upload)
export(voices)
92 changes: 92 additions & 0 deletions R/gpuctl_integration.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# gpu.ctl integration for tts.api
#
# Optionally acquires GPU resources before API calls when gpu.ctl is available.
# Enable with: options(tts.gpuctl = TRUE)

# Service registry for TTS backends
.tts_gpu_services <- list(
chatterbox = list(
name = "chatterbox",
port = 7810,
vram = 6,
container = "chatterbox",
health = "/health"
),
qwen3 = list(
name = "qwen3-tts",
port = 7811,
vram = 8,
container = "qwen3-tts-api",
health = "/health"
)
)

#' Check if gpu.ctl integration is enabled
#' @noRd
.gpuctl_enabled <- function () {
isTRUE(getOption("tts.gpuctl", FALSE)) &&
requireNamespace("gpu.ctl", quietly = TRUE)
}

#' Register tts.api service with gpu.ctl
#' @param backend Character. Backend name ("chatterbox" or "qwen3")
#' @noRd
.gpuctl_register_service <- function (backend = "chatterbox") {
if (!.gpuctl_enabled()) return(invisible(FALSE))

service <- .tts_gpu_services[[backend]]
if (is.null(service)) {
warning("Unknown backend for gpu.ctl: ", backend, call. = FALSE)
return(invisible(FALSE))
}

tryCatch({
# Only register if not already registered
existing <- gpu.ctl::gpu_services()
if (!service$name %in% existing$name) {
gpu.ctl::gpu_register(
name = service$name,
port = service$port,
vram = service$vram,
container = service$container,
health_endpoint = service$health
)
}
}, error = function (e) {
# Silently ignore registration errors
})
invisible(TRUE)
}

#' Acquire GPU for TTS backend if gpu.ctl is enabled
#'
#' @param backend Character. Backend name ("chatterbox" or "qwen3")
#' @return Invisible TRUE if acquired, FALSE if not using gpu.ctl
#' @noRd
.gpuctl_acquire <- function (backend = "chatterbox") {
if (!.gpuctl_enabled()) return(invisible(FALSE))

service <- .tts_gpu_services[[backend]]
if (is.null(service)) {
warning("Unknown backend for gpu.ctl: ", backend, call. = FALSE)
return(invisible(FALSE))
}

.gpuctl_register_service(backend)

tryCatch({
gpu.ctl::gpu_acquire(service$name)

# Auto-set tts.api_base from gpu.ctl's service URL
url <- gpu.ctl::gpu_service_url(service$name)
if (!is.null(url)) {
options(tts.api_base = url)
}

invisible(TRUE)
}, error = function (e) {
warning("gpu.ctl: ", e$message, call. = FALSE)
invisible(FALSE)
})
}

2 changes: 1 addition & 1 deletion R/internal_chatterbox.R
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ clear_native_chatterbox_cache <- function () {

# Generate speech (traced = TRUE for ~5x faster inference)
result <- tryCatch(
chatterbox::tts(
chatterbox::generate(
model = model,
text = input,
voice = voice,
Expand Down
5 changes: 5 additions & 0 deletions R/tts.R
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ tts <- function (input, voice, file = NULL,
}
}

# Auto-acquire GPU for container backends via gpu.ctl
if (backend %in% c("chatterbox", "qwen3")) {
.gpuctl_acquire(backend)
}

# Dispatch to native chatterbox package
if (backend == "native") {
return(.via_chatterbox(
Expand Down
12 changes: 4 additions & 8 deletions R/tts_providers.R
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,11 @@ tts_voices <- function (provider, base_url = NULL, timeout = 2) {
}
}

# For Chatterbox, try reading from filesystem as fallback
# For Chatterbox, try reading from voice library as fallback
if (provider == "Chatterbox (Local)") {
voices_dir <- Sys.getenv("CHATTERBOX_VOICES_DIR", "~/chatterbox-tts-api/voices")
voices_dir <- path.expand(voices_dir)
if (dir.exists(voices_dir)) {
files <- list.files(voices_dir, pattern = "\\.(mp3|wav)$", ignore.case = TRUE)
if (length(files) > 0) {
return(tools::file_path_sans_ext(files))
}
lib <- voice_library()
if (length(lib) > 0) {
return(names(lib))
}
}

Expand Down
115 changes: 115 additions & 0 deletions R/voice_library.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#' List Voice Files in Library
#'
#' Scans a directory for voice sample files and returns their paths.
#'
#' @param voices_dir Path to voice library directory.
#' Defaults to \code{TTS_VOICES_DIR} env var, then \code{~/.cornball/voices}.
#' @return Named character vector: names are voice names (filename without
#' extension), values are full file paths. Returns empty named character
#' vector if directory doesn't exist or contains no voice files.
#'
#' @export
#' @examples
#' \dontrun{
#' voice_library()
#' voice_library("~/my-voices")
#' }
voice_library <- function(voices_dir = NULL) {
if (is.null(voices_dir)) {
voices_dir <- Sys.getenv("TTS_VOICES_DIR", "~/.cornball/voices")
}
voices_dir <- path.expand(voices_dir)

if (!dir.exists(voices_dir)) {
return(structure(character(0), names = character(0)))
}

files <- list.files(voices_dir, pattern = "\\.(wav|mp3|m4a|flac)$",
full.names = TRUE, ignore.case = TRUE)
if (length(files) == 0) {
return(structure(character(0), names = character(0)))
}

names(files) <- tools::file_path_sans_ext(basename(files))
files
}

#' Resolve Voice Name to File Path
#'
#' Looks up a voice by name in the voice library directory.
#' Case-insensitive matching is attempted if exact match fails.
#'
#' @param voice_name Character. Name of the voice to find.
#' @param voices_dir Path to voice library directory (see \code{\link{voice_library}}).
#' @return Character string: full path to the voice file.
#' Stops with an error if the voice is not found.
#'
#' @export
#' @examples
#' \dontrun{
#' voice_file("BigCasey")
#' voice_file("delicatecasey")
#' }
voice_file <- function(voice_name, voices_dir = NULL) {
lib <- voice_library(voices_dir)

# Exact match
if (voice_name %in% names(lib)) {
return(unname(lib[voice_name]))
}

# Case-insensitive match
idx <- match(tolower(voice_name), tolower(names(lib)))
if (!is.na(idx)) {
return(unname(lib[idx]))
}

stop("Voice '", voice_name, "' not found in library. Available: ",
paste(names(lib), collapse = ", "), call. = FALSE)
}

#' Ensure Voice is Available on TTS Server
#'
#' Checks if a voice exists on the current TTS server. If not, finds the
#' voice file in the library and uploads it.
#'
#' @param voice_name Character. Name of the voice.
#' @param voices_dir Path to voice library directory (see \code{\link{voice_library}}).
#' @return Invisible \code{TRUE} if the voice is available (already present or
#' successfully uploaded).
#'
#' @export
#' @examples
#' \dontrun{
#' set_tts_base("http://localhost:4123")
#' voice_ensure("BigCasey")
#' tts("Hello!", voice = "BigCasey", file = "out.mp3")
#' }
voice_ensure <- function(voice_name, voices_dir = NULL) {
# Check if voice already on server
server_voices <- tryCatch(voices(), error = function(e) NULL)

if (!is.null(server_voices)) {
# Normalize to character vector of names
v_names <- if (is.data.frame(server_voices) && "name" %in% names(server_voices)) {
server_voices$name
} else if (is.data.frame(server_voices) && "id" %in% names(server_voices)) {
server_voices$id
} else if (is.character(server_voices)) {
server_voices
} else {
character(0)
}

if (tolower(voice_name) %in% tolower(v_names)) {
message("Voice '", voice_name, "' already on server")
return(invisible(TRUE))
}
}

# Find and upload
vf <- voice_file(voice_name, voices_dir)
message("Uploading voice '", voice_name, "' from ", vf)
voice_upload(voice_file = vf, voice_name = voice_name, language = "en")
invisible(TRUE)
}
10 changes: 2 additions & 8 deletions man/dot-tts_elevenlabs.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,8 @@
\alias{.tts_elevenlabs}
\title{ElevenLabs TTS backend}
\usage{
.tts_elevenlabs(
input,
voice_id,
file = NULL,
model = NULL,
stability = NULL,
similarity_boost = NULL
)
.tts_elevenlabs(input, voice_id, file = NULL, model = NULL, stability = NULL,
similarity_boost = NULL)
}
\description{
ElevenLabs TTS backend
Expand Down
11 changes: 2 additions & 9 deletions man/dot-via_chatterbox.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,8 @@
\alias{.via_chatterbox}
\title{Internal: Generate speech via native chatterbox package}
\usage{
.via_chatterbox(
input,
voice,
file = NULL,
exaggeration = NULL,
cfg_weight = NULL,
temperature = NULL,
device = "cuda"
)
.via_chatterbox(input, voice, file = NULL, exaggeration = NULL,
cfg_weight = NULL, temperature = NULL, device = "cuda")
}
\arguments{
\item{input}{Character. Text to convert to speech.}
Expand Down
9 changes: 2 additions & 7 deletions man/elevenlabs_voice_upload.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@
\alias{elevenlabs_voice_upload}
\title{Upload Voice to ElevenLabs (Instant Voice Clone)}
\usage{
elevenlabs_voice_upload(
files,
name,
description = NULL,
remove_background_noise = FALSE,
labels = NULL
)
elevenlabs_voice_upload(files, name, description = NULL,
remove_background_noise = FALSE, labels = NULL)
}
\arguments{
\item{files}{Character vector. Paths to audio files (1-25 files, each up to 10MB).}
Expand Down
18 changes: 4 additions & 14 deletions man/speech_clone.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,10 @@
\alias{speech_clone}
\title{Generate Speech with Voice Cloning}
\usage{
speech_clone(
input,
voice_file,
file = NULL,
backend = c("auto", "chatterbox", "qwen3"),
ref_text = NULL,
x_vector_only = FALSE,
language = NULL,
exaggeration = NULL,
temperature = NULL,
cfg_weight = NULL,
speed = NULL,
seed = NULL
)
speech_clone(input, voice_file, file = NULL,
backend = c("auto", "chatterbox", "qwen3"), ref_text = NULL,
x_vector_only = FALSE, language = NULL, exaggeration = NULL,
temperature = NULL, cfg_weight = NULL, speed = NULL, seed = NULL)
}
\arguments{
\item{input}{Character. The text to convert to speech.}
Expand Down
24 changes: 6 additions & 18 deletions man/tts.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,12 @@
\alias{tts}
\title{Generate Speech from Text}
\usage{
tts(
input,
voice,
file = NULL,
backend = c("auto", "native", "chatterbox", "qwen3", "openai", "elevenlabs"),
model = NULL,
temperature = NULL,
speed = NULL,
exaggeration = NULL,
cfg_weight = NULL,
stability = NULL,
similarity_boost = NULL,
seed = NULL,
response_format = NULL,
instructions = NULL,
language = NULL,
device = "cuda"
)
tts(input, voice, file = NULL,
backend = c("auto", "native", "chatterbox", "qwen3", "openai", "elevenlabs"),
model = NULL, temperature = NULL, speed = NULL, exaggeration = NULL,
cfg_weight = NULL, stability = NULL, similarity_boost = NULL, seed = NULL,
response_format = NULL, instructions = NULL, language = NULL,
device = "cuda")
}
\arguments{
\item{input}{Character. The text to convert to speech.}
Expand Down
27 changes: 27 additions & 0 deletions man/voice_ensure.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{voice_ensure}
\alias{voice_ensure}
\title{Ensure Voice is Available on TTS Server}
\usage{
voice_ensure(voice_name, voices_dir = NULL)
}
\arguments{
\item{voice_name}{Character. Name of the voice.}

\item{voices_dir}{Path to voice library directory (see \code{\link{voice_library}}).}
}
\value{
Invisible \code{TRUE} if the voice is available (already present or
successfully uploaded).
}
\description{
Checks if a voice exists on the current TTS server. If not, finds the
voice file in the library and uploads it.
}
\examples{
\dontrun{
set_tts_base("http://localhost:4123")
voice_ensure("BigCasey")
tts("Hello!", voice = "BigCasey", file = "out.mp3")
}
}
Loading