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:
parent
2711e90dce
commit
3d5d8dad36
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user