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!"
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.

View File

@@ -1,5 +1,6 @@
"""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

View File

@@ -1,20 +1,37 @@
"""Main entrypoint for the edge-playback package."""
import argparse
import os
import subprocess
import sys
import tempfile
from shutil import which
def pr_err(msg: str) -> None:
"""Print to stderr."""
print(msg, file=sys.stderr)
from .util import pr_err
def _main() -> None:
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):
pr_err(f"{dep} is not installed.")
depcheck_failed = True
@@ -33,31 +50,36 @@ def _main() -> None:
media.close()
mp3_fname = media.name
if not srt_fname:
if not srt_fname and use_mpv:
subtitle = tempfile.NamedTemporaryFile(suffix=".srt", delete=False)
subtitle.close()
srt_fname = subtitle.name
print(f"Media file: {mp3_fname}")
print(f"Subtitle file: {srt_fname}\n")
with subprocess.Popen(
[
"edge-tts",
f"--write-media={mp3_fname}",
f"--write-subtitles={srt_fname}",
]
+ sys.argv[1:]
) as process:
if srt_fname:
print(f"Subtitle file: {srt_fname}\n")
edge_tts_cmd = ["edge-tts", f"--write-media={mp3_fname}"]
if srt_fname:
edge_tts_cmd.append(f"--write-subtitles={srt_fname}")
edge_tts_cmd = edge_tts_cmd + tts_args
with subprocess.Popen(edge_tts_cmd) as process:
process.communicate()
with subprocess.Popen(
[
"mpv",
f"--sub-file={srt_fname}",
mp3_fname,
]
) as process:
process.communicate()
if sys.platform == "win32" and not use_mpv:
# pylint: disable-next=import-outside-toplevel
from .win32_playback import play_mp3_win32
play_mp3_win32(mp3_fname)
else:
with subprocess.Popen(
[
"mpv",
f"--sub-file={srt_fname}",
mp3_fname,
]
) as process:
process.communicate()
finally:
if keep:
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")