diff --git a/NAMESPACE b/NAMESPACE index ce96e1b..eb68177 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) diff --git a/R/gpuctl_integration.R b/R/gpuctl_integration.R new file mode 100644 index 0000000..f10875a --- /dev/null +++ b/R/gpuctl_integration.R @@ -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) + }) +} + diff --git a/R/internal_chatterbox.R b/R/internal_chatterbox.R index 9649d4b..d942902 100644 --- a/R/internal_chatterbox.R +++ b/R/internal_chatterbox.R @@ -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, diff --git a/R/tts.R b/R/tts.R index de72ade..32e4f11 100644 --- a/R/tts.R +++ b/R/tts.R @@ -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( diff --git a/R/tts_providers.R b/R/tts_providers.R index 4dc2be4..7704c5b 100644 --- a/R/tts_providers.R +++ b/R/tts_providers.R @@ -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)) } } diff --git a/R/voice_library.R b/R/voice_library.R new file mode 100644 index 0000000..726c41a --- /dev/null +++ b/R/voice_library.R @@ -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) +} diff --git a/man/dot-tts_elevenlabs.Rd b/man/dot-tts_elevenlabs.Rd index af07f39..410f746 100644 --- a/man/dot-tts_elevenlabs.Rd +++ b/man/dot-tts_elevenlabs.Rd @@ -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 diff --git a/man/dot-via_chatterbox.Rd b/man/dot-via_chatterbox.Rd index 155b1e6..b0ad42f 100644 --- a/man/dot-via_chatterbox.Rd +++ b/man/dot-via_chatterbox.Rd @@ -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.} diff --git a/man/elevenlabs_voice_upload.Rd b/man/elevenlabs_voice_upload.Rd index 5102d34..417210b 100644 --- a/man/elevenlabs_voice_upload.Rd +++ b/man/elevenlabs_voice_upload.Rd @@ -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).} diff --git a/man/speech_clone.Rd b/man/speech_clone.Rd index a38a148..78f6a68 100644 --- a/man/speech_clone.Rd +++ b/man/speech_clone.Rd @@ -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.} diff --git a/man/tts.Rd b/man/tts.Rd index 2f6e36b..7915a45 100644 --- a/man/tts.Rd +++ b/man/tts.Rd @@ -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.} diff --git a/man/voice_ensure.Rd b/man/voice_ensure.Rd new file mode 100644 index 0000000..8ccef60 --- /dev/null +++ b/man/voice_ensure.Rd @@ -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") +} +} diff --git a/man/voice_file.Rd b/man/voice_file.Rd new file mode 100644 index 0000000..8342bfa --- /dev/null +++ b/man/voice_file.Rd @@ -0,0 +1,26 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{voice_file} +\alias{voice_file} +\title{Resolve Voice Name to File Path} +\usage{ +voice_file(voice_name, voices_dir = NULL) +} +\arguments{ +\item{voice_name}{Character. Name of the voice to find.} + +\item{voices_dir}{Path to voice library directory (see \code{\link{voice_library}}).} +} +\value{ +Character string: full path to the voice file. + Stops with an error if the voice is not found. +} +\description{ +Looks up a voice by name in the voice library directory. +Case-insensitive matching is attempted if exact match fails. +} +\examples{ +\dontrun{ +voice_file("BigCasey") +voice_file("delicatecasey") +} +} diff --git a/man/voice_library.Rd b/man/voice_library.Rd new file mode 100644 index 0000000..8e8a808 --- /dev/null +++ b/man/voice_library.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{voice_library} +\alias{voice_library} +\title{List Voice Files in Library} +\usage{ +voice_library(voices_dir = NULL) +} +\arguments{ +\item{voices_dir}{Path to voice library directory. +Defaults to \code{TTS_VOICES_DIR} env var, then \code{~/.cornball/voices}.} +} +\value{ +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. +} +\description{ +Scans a directory for voice sample files and returns their paths. +} +\examples{ +\dontrun{ +voice_library() +voice_library("~/my-voices") +} +}