# app/services/export.py — Stitch committed spine audio files via ffmpeg # # Uses ffmpeg concat demuxer (list-form args, no shell injection). # WAV output: copy codec. MP3 output: libmp3lame 320k. from __future__ import annotations import asyncio import logging import os import shutil import tempfile from pathlib import Path from typing import Literal logger = logging.getLogger(__name__) ExportFormat = Literal["wav", "mp3"] async def stitch( audio_paths: list[str], output_path: str, fmt: ExportFormat = "wav", ) -> str: """ Concatenate audio_paths in order and write to output_path. Returns output_path on success. Raises RuntimeError on ffmpeg failure. Requires ffmpeg on PATH. """ if not audio_paths: raise ValueError("No audio paths to stitch.") Path(output_path).parent.mkdir(parents=True, exist_ok=True) # Single file: just copy, no concatenation needed if len(audio_paths) == 1: shutil.copy2(audio_paths[0], output_path) return output_path # Write ffmpeg concat file list to a tempfile with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False ) as flist: for p in audio_paths: # ffmpeg concat list format requires one "file '/abs/path'" per line flist.write(f"file '{p}'\n") flist_path = flist.name try: codec_args: list[str] = ( ["-c:a", "copy"] if fmt == "wav" else ["-c:a", "libmp3lame", "-b:a", "320k"] ) # create_subprocess_exec uses a list of args — no shell interpolation proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", flist_path, *codec_args, output_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: raise RuntimeError( f"ffmpeg concat failed (exit {proc.returncode}): {stderr.decode()}" ) finally: os.unlink(flist_path) logger.info("Stitched %d files -> %s", len(audio_paths), output_path) return output_path def make_export_path(data_dir: str, chain_id: str, fmt: ExportFormat) -> str: """Standard export output path.""" return str(Path(data_dir) / "chains" / chain_id / f"export.{fmt}")