"""
Audio Helper — the ``audio-helper`` command line.
The single command-line surface of the package: every library operation
is reachable as a subcommand, implemented with :mod:`click` so shell
completion (``click.shell_completion``), colored ``--help`` and nested
groups come for free.
Subcommands
-----------
- ``convert`` — re-encode an audio file (sample rate / channels / codec)
- ``duration`` — print the duration in seconds of an audio file
- ``chunk`` — extract a ``[start, end]`` slice from an audio file
- ``silence`` — write a silent audio file of a given duration
- ``concat`` — concatenate several audio files head-to-tail
- ``roomtone`` — mix pink/white/brown ambient noise onto a speech track
- ``split`` — split an audio file into fixed-duration chunks
- ``separate`` — run Demucs source separation (needs the ``[demucs]`` extra)
- ``resemblance`` — MFCC-based similarity score between two audio files
Design notes
------------
- Flags are named (``--input`` / ``--output`` / …) rather than positional:
audio commands take several paths and a bare positional order is easy to
get backwards.
- Every subcommand is a thin dispatch onto :mod:`audio_helper.main`; the
result goes to stdout (a plain path for single-file outputs, one path per
line for multi-file ones, JSON for structured results) so shell pipelines
can chain on it.
- A library exception is caught by :func:`main` and printed as one clean
``Error: ...`` line + exit 1; click only handles its own exceptions.
Usage Example
-------------
>>> # audio-helper convert --input in.mp3 --output out.wav --freq 44100
>>> # audio-helper duration --input in.mp3
>>> # audio-helper chunk --input in.mp3 --start 3.0 --end 8.5 --output cut.mp3
>>> # audio-helper silence --duration 5 --output silence.wav
>>> # audio-helper concat --inputs a.mp3 b.mp3 c.mp3 --output all.mp3
>>> # audio-helper roomtone --input speech.wav --output speech-rt.wav --db -42
>>> # audio-helper split --input in.mp3 --output-dir chunks/ --seconds 30
>>> # audio-helper separate --input mix.mp3 --output-dir stems/
>>> # audio-helper resemblance --a a.mp3 --b b.mp3
Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""
from __future__ import annotations
import json
import sys
import click
# The library functions — one source of truth, shared with the HTTP surface.
from . import (
audio_concatenation,
extract_audio_chunk,
generate_silent_audio,
get_audio_duration,
mix_room_tone,
separate_sources,
sound_converter,
sound_resemblance,
split_audio_regularly,
)
# ---------------------------------------------------------------------------
# Top-level group
#
# ``invoke_without_command=False`` forces the user to name a subcommand;
# ``context_settings`` widens the help output so long option lists stay
# readable on modern terminals.
# ---------------------------------------------------------------------------
@click.group(
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
)
# ``-V`` beside ``--version``: ``-v`` is taken by --verbose elsewhere in the
# suite, so the version flag is the capital one. The version string comes from
# the installed distribution metadata — the package exposes no ``__version__``.
@click.version_option(
None, "-V", "--version", package_name="audio-helper", prog_name="audio-helper"
)
def cli() -> None:
"""Audio Helper — load, convert, cut, join and separate audio from the shell."""
# Nothing to do at the group level — every subcommand carries its
# own arguments and side effects.
# ---------------------------------------------------------------------------
# convert
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--input", "input_", required=True, type=click.Path(exists=True), help="Input audio path."
)
@click.option("--output", required=True, type=click.Path(), help="Output audio path.")
@click.option(
"--freq", type=int, default=44100, show_default=True, help="Target sample rate in Hz."
)
@click.option(
"--channels", type=int, default=1, show_default=True, help="Channel count (1 = mono)."
)
@click.option("--encoding", default="pcm_s16le", show_default=True, help="ffmpeg codec name.")
@click.option(
"--overwrite/--no-overwrite", default=True, show_default=True, help="Overwrite existing output."
)
@click.option(
"--bitrate",
default=None,
help="Target bitrate for a lossy output, e.g. 64k or 64000. Unset carries the "
"input's own bitrate over; an uncompressed input has none, and ffmpeg then "
"picks 32k for a 24 kHz mono stream. Ignored for a WAV output.",
)
def convert(
input_: str,
output: str,
freq: int,
channels: int,
encoding: str,
overwrite: bool,
bitrate: str | None,
) -> None:
"""Re-encode an audio file (freq / channels / codec / bitrate)."""
# Thin dispatch to the library.
out = sound_converter(
input_audio=input_,
output_audio=output,
freq=freq,
channels=channels,
encoding=encoding,
overwrite=overwrite,
bitrate=bitrate,
)
click.echo(out)
# ---------------------------------------------------------------------------
# duration
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--input", "input_", required=True, type=click.Path(exists=True), help="Audio path.")
def duration(input_: str) -> None:
"""Print the duration of an audio file, in seconds."""
click.echo(f"{get_audio_duration(input_):.6f}")
# ---------------------------------------------------------------------------
# chunk
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--input", "input_", required=True, type=click.Path(exists=True))
@click.option("--start", type=float, required=True, help="Start time in seconds.")
@click.option("--end", type=float, required=True, help="End time in seconds.")
@click.option("--output", type=click.Path(), default=None, help="Output path (auto if omitted).")
@click.option("--overwrite/--no-overwrite", default=True, show_default=True)
def chunk(input_: str, start: float, end: float, output: str | None, overwrite: bool) -> None:
"""Extract a [start, end] slice from an audio file."""
out = extract_audio_chunk(
audio_file=input_,
start_time=start,
end_time=end,
output_audio_filename=output,
overwrite=overwrite,
)
click.echo(out)
# ---------------------------------------------------------------------------
# silence
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--duration", "duration_", type=float, required=True, help="Duration in seconds.")
@click.option("--output", type=click.Path(), default=None, help="Output path (auto if omitted).")
@click.option("--sample-rate", type=int, default=44100, show_default=True)
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def silence(duration_: float, output: str | None, sample_rate: int, overwrite: bool) -> None:
"""Generate a silent audio file of a given duration."""
out = generate_silent_audio(
duration=duration_,
output_audio_filename=output,
sample_rate=sample_rate,
overwrite=overwrite,
)
click.echo(out)
# ---------------------------------------------------------------------------
# concat
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--inputs",
required=True,
multiple=True,
type=click.Path(exists=True),
help="Audio files in order: list them all after one --inputs, or repeat the flag.",
)
# A click ``multiple=True`` option takes exactly one value per occurrence, so
# `--inputs a.mp3 b.mp3 c.mp3` would bind only a.mp3 and choke on the rest.
# That spelling is the documented one, so the trailing files are collected as
# variadic arguments and appended — both forms work, and mixing them is
# interpreted left to right within each form.
@click.argument("more_inputs", nargs=-1, type=click.Path(exists=True))
@click.option("--output", type=click.Path(), default=None)
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def concat(
inputs: tuple[str, ...], more_inputs: tuple[str, ...], output: str | None, overwrite: bool
) -> None:
"""Concatenate several audio files head-to-tail."""
out = audio_concatenation(
audio_files=[*inputs, *more_inputs],
output_audio_filename=output,
overwrite=overwrite,
)
click.echo(out)
# ---------------------------------------------------------------------------
# roomtone
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--input", "input_", required=True, type=click.Path(exists=True))
@click.option("--output", type=click.Path(), default=None)
@click.option("--db", type=float, default=-42.0, show_default=True, help="Noise level in dB.")
@click.option(
"--color",
type=click.Choice(["white", "pink", "brown", "red", "blue", "violet", "velvet"]),
default="pink",
show_default=True,
)
@click.option("--sample-rate", type=int, default=44100, show_default=True)
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def roomtone(
input_: str, output: str | None, db: float, color: str, sample_rate: int, overwrite: bool
) -> None:
"""Mix low-level colored ambient noise on top of a speech track."""
out = mix_room_tone(
input_audio=input_,
output_audio=output,
noise_db=db,
color=color,
sample_rate=sample_rate,
overwrite=overwrite,
)
click.echo(out)
# ---------------------------------------------------------------------------
# split
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--input", "input_", required=True, type=click.Path(exists=True))
@click.option(
"--output-dir", required=True, type=click.Path(), help="Folder that receives the chunks."
)
@click.option("--seconds", type=float, required=True, help="Chunk duration in seconds.")
@click.option("--output-format", default="mp3", show_default=True, help="Chunk extension.")
@click.option("--suffix", default="split", show_default=True, help="Filename suffix.")
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def split(
input_: str, output_dir: str, seconds: float, output_format: str, suffix: str, overwrite: bool
) -> None:
"""Split an audio file into fixed-duration chunks."""
outputs = split_audio_regularly(
sound_path=input_,
chunk_folder=output_dir,
split_time=seconds,
output_format=output_format,
overwrite=overwrite,
suffix=suffix,
)
for path in outputs:
click.echo(path)
# ---------------------------------------------------------------------------
# separate (Demucs)
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--input", "input_", required=True, type=click.Path(exists=True))
@click.option("--output-dir", type=click.Path(), default=None)
@click.option("--device", default=None, help="'cuda' / 'cpu' / None (auto).")
@click.option(
"--workers",
type=int,
default=-2,
show_default=True,
help="Worker threads (sklearn convention).",
)
@click.option("--output-format", default="mp3", show_default=True)
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def separate(
input_: str,
output_dir: str | None,
device: str | None,
workers: int,
output_format: str,
overwrite: bool,
) -> None:
"""Run Demucs source separation (needs the [demucs] extra)."""
try:
result = separate_sources(
input_audio_file=input_,
output_folder=output_dir,
device=device,
overwrite=overwrite,
nb_workers=workers,
output_format=output_format,
)
except ImportError as exc:
# Surface a clean error rather than a raw traceback in the terminal.
click.echo(f"error: {exc}", err=True)
sys.exit(2)
click.echo(json.dumps(result, indent=2))
# ---------------------------------------------------------------------------
# resemblance
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--a", required=True, type=click.Path(exists=True), help="First audio path.")
@click.option("--b", required=True, type=click.Path(exists=True), help="Second audio path.")
def resemblance(a: str, b: str) -> None:
"""MFCC-based similarity score between two audio files."""
click.echo(f"{sound_resemblance(a, b):.6f}")
[docs]
def main() -> None:
"""Console entry point (``audio-helper``).
Click's own error handling only special-cases ``ClickException``/
``Abort`` (and a broken pipe); a plain library exception (e.g. an
``AssertionError`` from an invalid audio file, or an ``ffmpeg.Error``)
would otherwise propagate as a raw Python traceback instead of a clean
CLI error. This wraps the whole invocation and translates that last
case into a one-line stderr message + exit 1 — click's own control flow
(usage errors, ``--help``, an explicit ``sys.exit`` in a subcommand,
e.g. ``separate``'s missing-torch guard above) already raises
``SystemExit``, a ``BaseException`` this does not catch, so it passes
through untouched.
"""
try:
cli()
except Exception as err: # noqa: BLE001 — last resort: see docstring
click.echo(f"Error: {err}", err=True)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()