"""
Audio Helper — click-based command-line interface.
Twin of :mod:`audio_helper.cli_argparse`: same public surface (identical
subcommand names, identical flag semantics), but implemented with
:mod:`click` so users who already have a click-native shell setup
(bash / zsh completion via ``click.shell_completion``, colored `--help`,
nested command groups) can plug it in without friction. Installed as
the ``audio-helper-click`` entry point in ``pyproject.toml``.
Design notes
------------
- Subcommands mirror ``audio-helper`` (the argparse twin) so both CLIs
can be introspected identically by higher layers (FastAPI).
- Flags reuse the argparse names (``--input`` / ``--output`` / …) rather
than the more idiomatic click positional style — consistency across
the two CLIs beats micro-idiomaticity here.
- Errors from the library propagate unchanged; click handles the
formatting.
Usage Example
-------------
>>> # audio-helper-click convert --input in.mp3 --output out.wav --freq 44100
>>> # audio-helper-click duration --input in.mp3
>>> # audio-helper-click split --input in.mp3 --output-dir chunks/ --seconds 30
>>> # audio-helper-click 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
try:
import click
except ImportError as exc: # pragma: no cover
raise ImportError(
"The click CLI requires the [cli] extra. Install with: pip install 'audio-helper[cli]'"
) from exc
# Same underlying functions as the argparse twin — one source of truth.
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},
)
@click.version_option(package_name="audio-helper", prog_name="audio-helper-click")
def cli() -> None:
"""Audio Helper — click twin of the argparse CLI. Same subcommands."""
# 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."
)
def convert(
input_: str, output: str, freq: int, channels: int, encoding: str, overwrite: bool
) -> None:
"""Re-encode an audio file (freq / channels / codec)."""
# Thin dispatch to the library.
out = sound_converter(
input_audio=input_,
output_audio=output,
freq=freq,
channels=channels,
encoding=encoding,
overwrite=overwrite,
)
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 (repeat --inputs for each).",
)
@click.option("--output", type=click.Path(), default=None)
@click.option("--overwrite/--no-overwrite", default=False, show_default=True)
def concat(inputs: tuple[str, ...], output: str | None, overwrite: bool) -> None:
"""Concatenate several audio files head-to-tail."""
out = audio_concatenation(
audio_files=list(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 console_entry() -> None:
"""Console entry point (``audio-helper-click``).
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
console_entry()