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=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
Source code in src/xil_pipeline/XILU002_generate_SFX.py
def get_parser() -> argparse.ArgumentParser:
    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")
    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 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)
        else:
            generate_sfx(
                entries, sfx_config_data, stems_dir, client=client,
            )