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,5 +1,5 @@
Package: compost
Version: 0.1.0.12
Version: 0.1.0.13
Title: Video Compositing via 'FFmpeg'
Description: Wraps common 'FFmpeg' <https://ffmpeg.org/> filter_complex
patterns (overlay, chromakey, concat, scale, vstack) into clean R
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export(crossfade_concat)
export(frame_export)
export(frames_clip)
export(hstack)
export(normalize_audio)
export(overlay)
export(pad)
export(pip)
Expand Down
53 changes: 53 additions & 0 deletions R/audio.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions inst/tinytest/test_audio.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
44 changes: 44 additions & 0 deletions man/normalize_audio.Rd
Original file line number Diff line number Diff line change
@@ -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)
}

}
Loading