Skip to content

Xilu002 Generate Sfx

src.xil_pipeline.XILU002_generate_SFX

Standalone SFX stem generation utility.

Generates sound effect stems from an SFX configuration file, storing them in the same stems/<TAG>/ directory that XILP002 and XILP003 expect. This allows SFX stems to be generated independently of dialogue voice generation, with fine-grained control over API credit spend.

Shared SFX assets are cached in the SFX/ directory so that each unique effect is only generated once. Episode stems are copies of the shared assets with sequence-numbered filenames.

Usage:

# Preview what will be generated and estimated cost
python XILU002_generate_SFX.py --episode S01E01 --dry-run
# Generate only short effects (≤5s) to limit credit usage
python XILU002_generate_SFX.py --episode S01E01 --max-duration 5.0

# Generate all SFX stems
python XILU002_generate_SFX.py --episode S01E01
Module Attributes

STEMS_DIR: Base directory for stem subdirectories.

logger module-attribute

logger = get_logger(__name__)

client module-attribute

client = ElevenLabs(api_key=os.environ.get('ELEVENLABS_API_KEY'))

STEMS_DIR module-attribute

STEMS_DIR = str(get_workspace_root() / 'stems')

load_sfx_plan

load_sfx_plan(script_json_path: str, sfx_json_path: str, cast_json_path: str, max_duration: float | None = None, direction_types: set[str] | None = None, local_only: bool = False) -> tuple[list[dict], str]

Load SFX entries and derive the stems directory path.

Delegates entry loading to :func:sfx_common.load_sfx_entries and derives the stems directory from the cast configuration tag.

Parameters:

  • script_json_path (str) –

    Path to the parsed script JSON.

  • sfx_json_path (str) –

    Path to the SFX configuration JSON.

  • cast_json_path (str) –

    Path to the cast configuration JSON.

  • max_duration (float | None, default: None ) –

    If set, exclude effects with duration_seconds exceeding this value. Useful for limiting API credit spend.

  • direction_types (set[str] | None, default: None ) –

    If set, only include entries whose direction_type is in this set. None includes all.

  • local_only (bool, default: False ) –

    If True, skip effects not already present in SFX/; no API generation.

Returns:

  • list[dict]

    A tuple of (sfx_entries, stems_dir) where sfx_entries is

  • str

    a list of dicts and stems_dir is the full path to the episode

  • tuple[list[dict], str]

    stems directory.

Source code in src/xil_pipeline/XILU002_generate_SFX.py
def load_sfx_plan(
    script_json_path: str, sfx_json_path: str, cast_json_path: str,
    max_duration: float | None = None,
    direction_types: set[str] | None = None,
    local_only: bool = False,
) -> tuple[list[dict], str]:
    """Load SFX entries and derive the stems directory path.

    Delegates entry loading to :func:`sfx_common.load_sfx_entries` and
    derives the stems directory from the cast configuration tag.

    Args:
        script_json_path: Path to the parsed script JSON.
        sfx_json_path: Path to the SFX configuration JSON.
        cast_json_path: Path to the cast configuration JSON.
        max_duration: If set, exclude effects with ``duration_seconds``
            exceeding this value. Useful for limiting API credit spend.
        direction_types: If set, only include entries whose
            ``direction_type`` is in this set. ``None`` includes all.
        local_only: If ``True``, skip effects not already present in
            ``SFX/``; no API generation.

    Returns:
        A tuple of ``(sfx_entries, stems_dir)`` where ``sfx_entries`` is
        a list of dicts and ``stems_dir`` is the full path to the episode
        stems directory.
    """
    if not os.path.exists(cast_json_path):
        raise FileNotFoundError(
            f"Cast config not found: {cast_json_path}\n"
            "Run XILP001 first or check your --episode flag."
        )
    with open(cast_json_path, encoding="utf-8") as f:
        cast_data = json.load(f)
    cast_cfg = CastConfiguration(**cast_data)
    stems_dir = os.path.join(STEMS_DIR, show_slug(cast_cfg.show), cast_cfg.tag)

    sfx_entries = load_sfx_entries(
        script_json_path, sfx_json_path,
        max_duration=max_duration,
        direction_types=direction_types,
        local_only=local_only,
    )
    return sfx_entries, stems_dir

get_parser

get_parser() -> argparse.ArgumentParser

Return the argument parser for xil-sfx.

Source code in src/xil_pipeline/XILU002_generate_SFX.py
def get_parser() -> argparse.ArgumentParser:
    """Return the argument parser for xil-sfx."""
    parser = argparse.ArgumentParser(
        prog="xil-sfx",
        description="Generate SFX stems from an SFX config (standalone utility)",
    )
    tag_group = parser.add_mutually_exclusive_group(required=True)
    tag_group.add_argument("--episode",
                           help="Episode tag (e.g. S01E01) — derives cast and SFX config paths")
    tag_group.add_argument("--tag",
                           help="Raw tag for non-episodic content (e.g. V01C03, D01)")
    parser.add_argument("--show", default=None,
                        help="Show name override (default: from project.json)")
    parser.add_argument("--script", default=None,
                        help="Path to parsed script JSON (default: derived from cast config)")
    parser.add_argument("--dry-run", action="store_true",
                        help="Preview existing vs. new stems and estimated credit cost")
    parser.add_argument("--max-duration", type=float, default=None,
                        help="Only process effects with duration_seconds <= this value")
    parser.add_argument("--gen-sfx", action="store_true",
                        help="Limit to SFX and BEAT entries only")
    parser.add_argument("--gen-music", action="store_true",
                        help="Limit to MUSIC entries only")
    parser.add_argument("--gen-ambience", action="store_true",
                        help="Limit to AMBIENCE entries only")
    parser.add_argument("--sfx-music", action="store_true",
                        help="(deprecated) shorthand for --gen-sfx --gen-music --gen-ambience")
    parser.add_argument("--local-only", action="store_true",
                        help="Only place stems for effects already present in SFX/; skip API generation")
    parser.add_argument("--sfx-backend", choices=["elevenlabs", "audioldm2", "stableaudio"],
                        default="elevenlabs", metavar="BACKEND",
                        help=(
                            "Backend for SFX/music/ambience generation. 'elevenlabs' (default) "
                            "calls the ElevenLabs Sound Effects API. 'audioldm2' uses a local "
                            "AudioLDM 2 Large diffusion model (venv-audioldm2) — free, "
                            "GPU-accelerated, writes SFX/<slug>.audioldm2.mp3. 'stableaudio' "
                            "uses a local Stable Audio Open 1.0 model (shares venv-audioldm2; "
                            "44.1 kHz stereo, up to 47s per clip; HF license-gated weights) and "
                            "writes SFX/<slug>.stableaudio.mp3."
                        ))
    parser.add_argument("--audioldm2-python", default=None, metavar="PATH",
                        help=(
                            "Path to the Python executable in the AudioLDM 2 venv "
                            "(default: auto-detect ./venv-audioldm2/bin/python3). "
                            "Used only with --sfx-backend audioldm2."
                        ))
    parser.add_argument("--audioldm2-guidance", type=float, default=3.5, metavar="FLOAT",
                        help=(
                            "AudioLDM 2 guidance scale — how closely generation follows the "
                            "prompt (default: 3.5). Used only with --sfx-backend audioldm2."
                        ))
    parser.add_argument("--audioldm2-steps", type=int, default=200, metavar="INT",
                        help=(
                            "AudioLDM 2 diffusion inference steps — higher is slower but "
                            "cleaner (default: 200). Used only with --sfx-backend audioldm2."
                        ))
    parser.add_argument("--audioldm2-negative-prompt", default="low quality, noise",
                        metavar="STR",
                        help=(
                            "AudioLDM 2 negative prompt (default: 'low quality, noise'). "
                            "Used only with --sfx-backend audioldm2."
                        ))
    parser.add_argument("--stableaudio-python", default=None, metavar="PATH",
                        help=(
                            "Path to the Python executable for the Stable Audio backend "
                            "(default: auto-detect the shared venv-audioldm2 Python — "
                            "StableAudioPipeline ships in the same diffusers install). "
                            "Used only with --sfx-backend stableaudio."
                        ))
    parser.add_argument("--stableaudio-guidance", type=float, default=7.0, metavar="FLOAT",
                        help=(
                            "Stable Audio guidance scale — how closely generation follows the "
                            "prompt (default: 7.0). Used only with --sfx-backend stableaudio."
                        ))
    parser.add_argument("--stableaudio-steps", type=int, default=100, metavar="INT",
                        help=(
                            "Stable Audio diffusion inference steps — higher is slower but "
                            "cleaner (default: 100). Used only with --sfx-backend stableaudio."
                        ))
    parser.add_argument("--stableaudio-negative-prompt", default="low quality, average quality",
                        metavar="STR",
                        help=(
                            "Stable Audio negative prompt (default: 'low quality, average "
                            "quality'). Used only with --sfx-backend stableaudio."
                        ))
    parser.add_argument("--stableaudio-seed", type=int, default=None, metavar="INT",
                        help=(
                            "Reproducibility seed for Stable Audio generation (default: "
                            "nondeterministic). Used only with --sfx-backend stableaudio."
                        ))
    return parser

main

main() -> None

CLI entry point for standalone SFX stem generation.

Source code in src/xil_pipeline/XILU002_generate_SFX.py
def main() -> None:
    """CLI entry point for standalone SFX stem generation."""
    configure_logging()
    with run_banner():
        args = get_parser().parse_args()

        if not args.dry_run and args.sfx_backend == "elevenlabs" and not os.environ.get("ELEVENLABS_API_KEY"):
            sys.exit("Error: ELEVENLABS_API_KEY environment variable is not set.")

        # Derive config paths from --episode / --tag
        tag = args.episode or args.tag
        slug = resolve_slug(args.show)
        p = derive_paths(slug, tag)
        cast_path = p["cast"]
        sfx_path = p["sfx"]

        # Derive default --script from cast config
        if args.script is None:
            if not os.path.exists(cast_path):
                sys.exit(f"Error: Cast config not found: {cast_path}\nRun XILP001 first or check your --episode flag.")
            with open(cast_path, encoding="utf-8") as f:
                cast_data = json.load(f)
            CastConfiguration(**cast_data)  # validate cast config
            args.script = p["parsed"]

        direction_types: set[str] | None = None
        if args.gen_sfx or args.gen_music or args.gen_ambience or args.sfx_music:
            direction_types = set()
            if args.gen_sfx or args.sfx_music:
                direction_types |= {"SFX", "BEAT"}
            if args.gen_music or args.sfx_music:
                direction_types.add("MUSIC")
            if args.gen_ambience or args.sfx_music:
                direction_types.add("AMBIENCE")

        entries, stems_dir = load_sfx_plan(
            args.script, sfx_path, cast_path,
            max_duration=args.max_duration,
            direction_types=direction_types,
            local_only=args.local_only,
        )

        if not os.path.exists(sfx_path):
            sys.exit(f"Error: SFX config not found: {sfx_path}\nRun XILP001 first or check your --episode flag.")
        with open(sfx_path, encoding="utf-8") as f:
            sfx_config_data = json.load(f)

        if args.dry_run:
            dry_run_sfx(entries, sfx_config_data, stems_dir, backend_name=args.sfx_backend)
        else:
            if args.sfx_backend == "stableaudio":
                sfx_backend = make_sfx_backend(
                    "stableaudio",
                    client=client,
                    stableaudio_python=args.stableaudio_python,
                    guidance=args.stableaudio_guidance,
                    steps=args.stableaudio_steps,
                    negative_prompt=args.stableaudio_negative_prompt,
                    seed=args.stableaudio_seed,
                )
            else:
                sfx_backend = make_sfx_backend(
                    args.sfx_backend,
                    client=client,
                    audioldm2_python=args.audioldm2_python,
                    guidance=args.audioldm2_guidance,
                    steps=args.audioldm2_steps,
                    negative_prompt=args.audioldm2_negative_prompt,
                )
            try:
                generate_sfx(
                    entries, sfx_config_data, stems_dir, client=client,
                    backend=sfx_backend,
                )
            finally:
                sfx_backend.close()