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."""
# 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):

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
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