Support native playback on Windows (#360)

* Support native playback on Windows

* Use mypy-blessed platform switching logic

* Address feedback

* I think mypy is happy now

* Don't print srt when filename is None

* Fix ctypes imports

* Fix pylint and black complaints

Signed-off-by: rany <rany2@riseup.net>

* Have play_mp3_win32 use a guard clause to reduce nesting

Signed-off-by: rany <rany2@riseup.net>

---------

Signed-off-by: rany <rany2@riseup.net>
Co-authored-by: rany <rany2@riseup.net>
This commit is contained in:
Will
2025-02-27 11:10:43 -08:00
committed by GitHub
parent 152a69e77f
commit ecf50b916e
5 changed files with 108 additions and 25 deletions

View File

@@ -24,7 +24,7 @@ If you wish to play it back immediately with subtitles, you could use the `edge-
$ edge-playback --text "Hello, world!" $ edge-playback --text "Hello, world!"
Note that `edge-playback` requires the installation of the [`mpv` command line player](https://mpv.io/). Note that `edge-playback` requires the installation of the [`mpv` command line player](https://mpv.io/), except on Windows.
All `edge-tts` commands work with `edge-playback` with the exception of the `--write-media`, `--write-subtitles` and `--list-voices` options. All `edge-tts` commands work with `edge-playback` with the exception of the `--write-media`, `--write-subtitles` and `--list-voices` options.

View File

@@ -1,5 +1,6 @@
"""The edge_playback package wraps the functionality of mpv and edge-tts to generate """The edge_playback package wraps the functionality of mpv and edge-tts to generate
text-to-speech (TTS) using edge-tts and then plays back the generated audio using mpv.""" text-to-speech (TTS) using edge-tts and then plays back the generated audio using mpv.
"""
from .__main__ import _main from .__main__ import _main

View File

@@ -1,20 +1,37 @@
"""Main entrypoint for the edge-playback package.""" """Main entrypoint for the edge-playback package."""
import argparse
import os import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from shutil import which from shutil import which
from .util import pr_err
def pr_err(msg: str) -> None:
"""Print to stderr."""
print(msg, file=sys.stderr)
def _main() -> None: def _main() -> None:
depcheck_failed = False depcheck_failed = False
for dep in ("edge-tts", "mpv"):
parser = argparse.ArgumentParser(
prog="edge-playback",
description="Speak text using Microsoft Edge's online text-to-speech API",
epilog="See `edge-tts` for additional arguments",
)
parser.add_argument(
"--mpv",
action="store_true",
help="Use mpv to play audio. By default, false on Windows and true on all other platforms",
)
args, tts_args = parser.parse_known_args()
use_mpv = sys.platform != "win32" or args.mpv
deps = ["edge-tts"]
if use_mpv:
deps.append("mpv")
for dep in deps:
if not which(dep): if not which(dep):
pr_err(f"{dep} is not installed.") pr_err(f"{dep} is not installed.")
depcheck_failed = True depcheck_failed = True
@@ -33,31 +50,36 @@ def _main() -> None:
media.close() media.close()
mp3_fname = media.name mp3_fname = media.name
if not srt_fname: if not srt_fname and use_mpv:
subtitle = tempfile.NamedTemporaryFile(suffix=".srt", delete=False) subtitle = tempfile.NamedTemporaryFile(suffix=".srt", delete=False)
subtitle.close() subtitle.close()
srt_fname = subtitle.name srt_fname = subtitle.name
print(f"Media file: {mp3_fname}") print(f"Media file: {mp3_fname}")
print(f"Subtitle file: {srt_fname}\n") if srt_fname:
with subprocess.Popen( print(f"Subtitle file: {srt_fname}\n")
[
"edge-tts", edge_tts_cmd = ["edge-tts", f"--write-media={mp3_fname}"]
f"--write-media={mp3_fname}", if srt_fname:
f"--write-subtitles={srt_fname}", edge_tts_cmd.append(f"--write-subtitles={srt_fname}")
] edge_tts_cmd = edge_tts_cmd + tts_args
+ sys.argv[1:] with subprocess.Popen(edge_tts_cmd) as process:
) as process:
process.communicate() process.communicate()
with subprocess.Popen( if sys.platform == "win32" and not use_mpv:
[ # pylint: disable-next=import-outside-toplevel
"mpv", from .win32_playback import play_mp3_win32
f"--sub-file={srt_fname}",
mp3_fname, play_mp3_win32(mp3_fname)
] else:
) as process: with subprocess.Popen(
process.communicate() [
"mpv",
f"--sub-file={srt_fname}",
mp3_fname,
]
) as process:
process.communicate()
finally: finally:
if keep: if keep:
print(f"\nKeeping temporary files: {mp3_fname} and {srt_fname}") print(f"\nKeeping temporary files: {mp3_fname} and {srt_fname}")

View File

@@ -0,0 +1,8 @@
"""Utility functions for edge-playback"""
import sys
def pr_err(msg: str) -> None:
"""Print to stderr."""
print(msg, file=sys.stderr)

View File

@@ -0,0 +1,52 @@
"""Functions to play audio on Windows using native win32 APIs"""
import sys
from .util import pr_err
def play_mp3_win32(mp3_fname: str) -> None:
"""Play mp3 file with given path using win32 API"""
if sys.platform != "win32":
raise NotImplementedError("Function only available on Windows")
# pylint: disable-next=import-outside-toplevel
from ctypes import create_unicode_buffer, windll, wintypes # type: ignore
_get_short_path_name_w = windll.kernel32.GetShortPathNameW
_get_short_path_name_w.argtypes = [
wintypes.LPCWSTR,
wintypes.LPWSTR,
wintypes.DWORD,
]
_get_short_path_name_w.restype = wintypes.DWORD
def get_short_path_name(long_name: str) -> str:
"""
Gets the DOS-safe short path name of a given long path.
http://stackoverflow.com/a/23598461/200291
"""
output_buf_size = 0
while True:
output_buf = create_unicode_buffer(output_buf_size)
needed = _get_short_path_name_w(long_name, output_buf, output_buf_size)
if output_buf_size >= needed:
return output_buf.value
output_buf_size = needed
mci_send_string_w = windll.winmm.mciSendStringW
def mci_send(msg: str) -> None:
"""Send MCI command string"""
result = mci_send_string_w(msg, 0, 0, 0)
if result != 0:
pr_err(f"Error {result} in mciSendString {msg}. Exiting.")
sys.exit(1)
mp3_shortname = get_short_path_name(mp3_fname)
mci_send("Close All")
mci_send(f'Open "{mp3_shortname}" Type MPEGVideo Alias theMP3')
mci_send("Play theMP3 Wait")
mci_send("Close theMP3")