diff --git a/DESCRIPTION b/DESCRIPTION index 318940f..3785530 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: compost -Version: 0.1.0.12 +Version: 0.1.0.13 Title: Video Compositing via 'FFmpeg' Description: Wraps common 'FFmpeg' filter_complex patterns (overlay, chromakey, concat, scale, vstack) into clean R diff --git a/NAMESPACE b/NAMESPACE index 46d1981..07d2fd5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -13,6 +13,7 @@ export(crossfade_concat) export(frame_export) export(frames_clip) export(hstack) +export(normalize_audio) export(overlay) export(pad) export(pip) diff --git a/R/audio.R b/R/audio.R index 572a02c..8cb55e2 100644 --- a/R/audio.R +++ b/R/audio.R @@ -47,6 +47,59 @@ broadcast_audio <- function(input, output, dehum = TRUE, target_lufs = -14, invisible(output) } +#' Normalize Audio Loudness +#' +#' A single loudnorm pass to an EBU R128 / ITU-R BS.1770 target -- the +#' light-touch cousin of \code{\link{broadcast_audio}}, which wraps loudnorm +#' in a full mastering chain (high-pass, de-hum notches, compression, fades). +#' Use this to level takes that are already clean, e.g. TTS output across a +#' batch of tracks. +#' +#' @param input Path to input audio (or A/V) file. +#' @param output Path for output file (default: overwrite input in place). +#' @param integrated Target integrated loudness in LUFS (default -16). +#' @param lra Loudness range target in LU (default 11). +#' @param tp True-peak ceiling in dBTP (default -1.5). +#' @param bitrate Output audio bitrate for lossy formats (default "192k"), +#' or NULL for the encoder default. +#' @param overwrite If TRUE (default), overwrite the output file. +#' @param dry_run If TRUE, return the FFmpeg command without executing. +#' +#' @return Invisibly returns the output path. If dry_run, returns the +#' command string. +#' +#' @examples +#' \dontrun{ +#' normalize_audio("audio.mp3") +#' normalize_audio("take.wav", "leveled.wav", integrated = -14) +#' } +#' +#' @export +normalize_audio <- function(input, output = input, integrated = -16, + lra = 11, tp = -1.5, bitrate = "192k", + overwrite = TRUE, dry_run = FALSE) { + input <- normalizePath(input, mustWork = TRUE) + in_place <- identical(input, normalizePath(output, mustWork = FALSE)) + out_path <- if (in_place && !dry_run) { + tempfile(fileext = paste0(".", tools::file_ext(input))) + } else { + normalizePath(output, mustWork = FALSE) + } + + args <- c(if (overwrite) "-y", "-i", input, "-vn", "-af", + sprintf("loudnorm=I=%g:LRA=%g:TP=%g", integrated, lra, tp), + if (!is.null(bitrate)) c("-b:a", bitrate), out_path) + + if (dry_run) { + return(.run_ffmpeg(args, dry_run = TRUE)) + } + .run_ffmpeg(args) + if (in_place) { + file.rename(out_path, output) + } + invisible(output) +} + #' Build the -af filter graph for the broadcast-clean chain #' #' Pure string builder so the chain can be tested without ffmpeg. diff --git a/inst/tinytest/test_audio.R b/inst/tinytest/test_audio.R index 002aac3..77206f3 100644 --- a/inst/tinytest/test_audio.R +++ b/inst/tinytest/test_audio.R @@ -33,4 +33,27 @@ cmd <- broadcast_audio(wav, "clean.wav", dry_run = TRUE) expect_true(grepl("^ffmpeg ", cmd)) expect_true(grepl("-af ", cmd)) +# normalize_audio: the loudnorm-only pass. +cmdn <- normalize_audio(wav, "level.wav", dry_run = TRUE) +expect_true(grepl("loudnorm=I=-16:LRA=11:TP=-1.5", cmdn)) +expect_true(grepl("-vn", cmdn)) +expect_true(grepl("-b:a 192k", cmdn)) +cmdn2 <- normalize_audio(wav, "level.wav", integrated = -14, bitrate = NULL, + dry_run = TRUE) +expect_true(grepl("loudnorm=I=-14:", cmdn2)) +expect_false(grepl("-b:a", cmdn2)) + unlink(wav) + +# In-place normalize on a real sine (at_home). +if (at_home() && nzchar(Sys.which("ffmpeg"))) { + w <- tempfile(fileext = ".wav") + compost:::.run_ffmpeg(c("-y", "-f", "lavfi", "-i", + "sine=frequency=440:duration=2", "-ar", "48000", + "-ac", "1", w)) + d0 <- probe(w, "duration") + normalize_audio(w) + expect_true(file.exists(w)) + expect_true(abs(probe(w, "duration") - d0) < 0.1) + unlink(w) +} diff --git a/man/normalize_audio.Rd b/man/normalize_audio.Rd new file mode 100644 index 0000000..d9bf60d --- /dev/null +++ b/man/normalize_audio.Rd @@ -0,0 +1,44 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{normalize_audio} +\alias{normalize_audio} +\title{Normalize Audio Loudness} +\usage{ +normalize_audio(input, output = input, integrated = -16, lra = 11, tp = -1.5, + bitrate = "192k", overwrite = TRUE, dry_run = FALSE) +} +\arguments{ +\item{input}{Path to input audio (or A/V) file.} + +\item{output}{Path for output file (default: overwrite input in place).} + +\item{integrated}{Target integrated loudness in LUFS (default -16).} + +\item{lra}{Loudness range target in LU (default 11).} + +\item{tp}{True-peak ceiling in dBTP (default -1.5).} + +\item{bitrate}{Output audio bitrate for lossy formats (default "192k"), +or NULL for the encoder default.} + +\item{overwrite}{If TRUE (default), overwrite the output file.} + +\item{dry_run}{If TRUE, return the FFmpeg command without executing.} +} +\value{ +Invisibly returns the output path. If dry_run, returns the + command string. +} +\description{ +A single loudnorm pass to an EBU R128 / ITU-R BS.1770 target -- the +light-touch cousin of \code{\link{broadcast_audio}}, which wraps loudnorm +in a full mastering chain (high-pass, de-hum notches, compression, fades). +Use this to level takes that are already clean, e.g. TTS output across a +batch of tracks. +} +\examples{ +\dontrun{ +normalize_audio("audio.mp3") +normalize_audio("take.wav", "leveled.wav", integrated = -14) +} + +}