macOS transcode via afconvert; document ducking no-op

- 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.
This commit is contained in:
Ryan Malloy 2026-07-03 22:25:13 -06:00
parent 2711e90dce
commit 3d5d8dad36
2 changed files with 54 additions and 17 deletions

View File

@ -94,13 +94,19 @@ class ConversionError(RuntimeError):
"""Raised when format conversion fails.""" """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: async def convert_audio(src_wav: Path, dest: Path, fmt: str) -> Path:
"""Convert a source WAV to the requested format at dest. """Convert a source WAV to the requested format at dest.
`fmt == "wav"` shells out to a plain copy (no ffmpeg invocation). `fmt == "wav"` is a plain copy. Otherwise ffmpeg is used when available; on
Other formats use ffmpeg with codec args from _FFMPEG_CODEC_ARGS. macOS without ffmpeg, afconvert covers m4a (mp3/ogg/flac still need ffmpeg).
Raises ConversionError on failure or unsupported format.
Raises ConversionError on ffmpeg non-zero exit or unsupported format.
""" """
if fmt not in SUPPORTED_FORMATS: if fmt not in SUPPORTED_FORMATS:
raise ConversionError( raise ConversionError(
@ -113,20 +119,44 @@ async def convert_audio(src_wav: Path, dest: Path, fmt: str) -> Path:
shutil.copyfile(src_wav, dest) shutil.copyfile(src_wav, dest)
return dest return dest
proc = await asyncio.create_subprocess_exec( if platform_audio.has_ffmpeg():
"ffmpeg", "-y", "-loglevel", "error", "-i", str(src_wav), proc = await asyncio.create_subprocess_exec(
*_FFMPEG_CODEC_ARGS[fmt], "ffmpeg", "-y", "-loglevel", "error", "-i", str(src_wav),
str(dest), *_FFMPEG_CODEC_ARGS[fmt],
stdout=asyncio.subprocess.DEVNULL, str(dest),
stderr=asyncio.subprocess.PIPE, 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]}"
) )
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): class PlaybackError(RuntimeError):

View File

@ -11,6 +11,13 @@ protocol connection, while pactl (using libpulse) works reliably.
If pactl is not installed or the PulseAudio socket is unavailable, all If pactl is not installed or the PulseAudio socket is unavailable, all
operations silently no-op TTS still works, just without ducking. 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 import asyncio