From 3d5d8dad361a1b4c07b50487bbf7aea116d6f533 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 3 Jul 2026 22:25:13 -0600 Subject: [PATCH] macOS transcode via afconvert; document ducking no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - convert_audio: use ffmpeg when present (Linux path unchanged); on macOS without ffmpeg, fall back to the built-in afconvert for m4a, with a clear "install ffmpeg for mp3/ogg/flac" error for the formats it can't encode. - media_duck: document why macOS is left as a deliberate no-op — the only built-in volume control is system-wide and would dim our own afplay voice; proper per-app ducking needs CoreAudio, not osascript. --- src/mcspeak/audio.py | 64 ++++++++++++++++++++++++++++----------- src/mcspeak/media_duck.py | 7 +++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/mcspeak/audio.py b/src/mcspeak/audio.py index e8c6c7e..4574649 100644 --- a/src/mcspeak/audio.py +++ b/src/mcspeak/audio.py @@ -94,13 +94,19 @@ class ConversionError(RuntimeError): """Raised when format conversion fails.""" +# macOS built-in afconvert can only encode a subset (AAC/m4a) — no mp3/ogg/flac +# encoders ship with it. Those still need ffmpeg (brew install ffmpeg). +_AFCONVERT_ARGS: dict[str, list[str]] = { + "m4a": ["-f", "m4af", "-d", "aac"], # AAC in an MPEG-4 container +} + + async def convert_audio(src_wav: Path, dest: Path, fmt: str) -> Path: """Convert a source WAV to the requested format at dest. - `fmt == "wav"` shells out to a plain copy (no ffmpeg invocation). - Other formats use ffmpeg with codec args from _FFMPEG_CODEC_ARGS. - - Raises ConversionError on ffmpeg non-zero exit or unsupported format. + `fmt == "wav"` is a plain copy. Otherwise ffmpeg is used when available; on + macOS without ffmpeg, afconvert covers m4a (mp3/ogg/flac still need ffmpeg). + Raises ConversionError on failure or unsupported format. """ if fmt not in SUPPORTED_FORMATS: raise ConversionError( @@ -113,20 +119,44 @@ async def convert_audio(src_wav: Path, dest: Path, fmt: str) -> Path: shutil.copyfile(src_wav, dest) return dest - proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-y", "-loglevel", "error", "-i", str(src_wav), - *_FFMPEG_CODEC_ARGS[fmt], - str(dest), - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, - ) - _, stderr = await proc.communicate() - if proc.returncode != 0: - raise ConversionError( - f"ffmpeg failed ({proc.returncode}) converting to {fmt}: " - f"{stderr.decode(errors='replace').strip()[:500]}" + if platform_audio.has_ffmpeg(): + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-y", "-loglevel", "error", "-i", str(src_wav), + *_FFMPEG_CODEC_ARGS[fmt], + str(dest), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, ) - return dest + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise ConversionError( + f"ffmpeg failed ({proc.returncode}) converting to {fmt}: " + f"{stderr.decode(errors='replace').strip()[:500]}" + ) + return dest + + # No ffmpeg — fall back to the macOS built-in afconvert for what it can do. + if platform_audio.IS_MACOS: + args = _AFCONVERT_ARGS.get(fmt) + if args is None: + raise ConversionError( + f"macOS afconvert can't encode {fmt!r} (only m4a/wav without ffmpeg). " + f"Install ffmpeg (brew install ffmpeg) for mp3/ogg/flac." + ) + proc = await asyncio.create_subprocess_exec( + "afconvert", *args, str(src_wav), str(dest), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise ConversionError( + f"afconvert failed ({proc.returncode}) converting to {fmt}: " + f"{stderr.decode(errors='replace').strip()[:500]}" + ) + return dest + + raise ConversionError(f"ffmpeg not found — needed to convert to {fmt!r}. Install ffmpeg.") class PlaybackError(RuntimeError): diff --git a/src/mcspeak/media_duck.py b/src/mcspeak/media_duck.py index 0124751..9ecd4d1 100644 --- a/src/mcspeak/media_duck.py +++ b/src/mcspeak/media_duck.py @@ -11,6 +11,13 @@ protocol connection, while pactl (using libpulse) works reliably. If pactl is not installed or the PulseAudio socket is unavailable, all operations silently no-op — TTS still works, just without ducking. + +macOS: intentionally left as that no-op. The only built-in volume control +(`osascript ... set volume output volume`) is SYSTEM-WIDE, and our TTS plays +through afplay as system audio — so ducking would dim our own voice, the +opposite of the goal. Per-app ducking on macOS needs CoreAudio (not a +built-in), so proper ducking is a future native-helper task, not an osascript +one. Don't "fix" this by adding osascript volume control. """ import asyncio