From 6355b32f0c9b9852c4851f8b481d4959853b581f Mon Sep 17 00:00:00 2001 From: lzieniew Date: Sun, 21 Apr 2024 12:47:38 +0200 Subject: [PATCH] Add sync versions of stream and save methods (#215) * Add sync versions of stream and save methods In order to provide synchronous interface to the library * Fix save_sync() failing to use metadata_fname and fix typing issues Signed-off-by: rany --------- Signed-off-by: rany Co-authored-by: rany --- examples/basic_sync_audio_streaming.py | 27 ++++++++++++ examples/basic_sync_generation.py | 21 ++++++++++ .../sync_audio_generation_in_async_context.py | 36 ++++++++++++++++ .../sync_audio_stream_in_async_context.py | 42 +++++++++++++++++++ src/edge_tts/communicate.py | 40 ++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 examples/basic_sync_audio_streaming.py create mode 100644 examples/basic_sync_generation.py create mode 100644 examples/sync_audio_generation_in_async_context.py create mode 100644 examples/sync_audio_stream_in_async_context.py diff --git a/examples/basic_sync_audio_streaming.py b/examples/basic_sync_audio_streaming.py new file mode 100644 index 0000000..c906b85 --- /dev/null +++ b/examples/basic_sync_audio_streaming.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + +""" +Basic audio streaming example for sync interface + +""" + +import edge_tts + +TEXT = "Hello World!" +VOICE = "en-GB-SoniaNeural" +OUTPUT_FILE = "test.mp3" + + +def main() -> None: + """Main function to process audio and metadata synchronously.""" + communicate = edge_tts.Communicate(TEXT, VOICE) + with open(OUTPUT_FILE, "wb") as file: + for chunk in communicate.stream_sync(): + if chunk["type"] == "audio": + file.write(chunk["data"]) + elif chunk["type"] == "WordBoundary": + print(f"WordBoundary: {chunk}") + + +if __name__ == "__main__": + main() diff --git a/examples/basic_sync_generation.py b/examples/basic_sync_generation.py new file mode 100644 index 0000000..e6785bd --- /dev/null +++ b/examples/basic_sync_generation.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +""" +Basic example of edge_tts usage in synchronous function +""" + +import edge_tts + +TEXT = "Hello World!" +VOICE = "en-GB-SoniaNeural" +OUTPUT_FILE = "test.mp3" + + +def main() -> None: + """Main function""" + communicate = edge_tts.Communicate(TEXT, VOICE) + communicate.save_sync(OUTPUT_FILE) + + +if __name__ == "__main__": + main() diff --git a/examples/sync_audio_generation_in_async_context.py b/examples/sync_audio_generation_in_async_context.py new file mode 100644 index 0000000..d159514 --- /dev/null +++ b/examples/sync_audio_generation_in_async_context.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +""" +This example shows that sync version of save function also works when run from +a sync function called itself from an async function. +The simple implementation of save_sync() with only asyncio.run would fail in this scenario, +that's why ThreadPoolExecutor is used in implementation. + +""" + +import asyncio + +import edge_tts + +TEXT = "Hello World!" +VOICE = "en-GB-SoniaNeural" +OUTPUT_FILE = "test.mp3" + + +def sync_main() -> None: + """Main function""" + communicate = edge_tts.Communicate(TEXT, VOICE) + communicate.save_sync(OUTPUT_FILE) + + +async def amain() -> None: + """Main function""" + sync_main() + + +if __name__ == "__main__": + loop = asyncio.get_event_loop_policy().get_event_loop() + try: + loop.run_until_complete(amain()) + finally: + loop.close() diff --git a/examples/sync_audio_stream_in_async_context.py b/examples/sync_audio_stream_in_async_context.py new file mode 100644 index 0000000..68f0d44 --- /dev/null +++ b/examples/sync_audio_stream_in_async_context.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +""" +This example shows the sync version of stream function which also +works when run from a sync function called itself from an async function. +""" + +import asyncio + +import edge_tts + +TEXT = "Hello World!" +VOICE = "en-GB-SoniaNeural" +OUTPUT_FILE = "test.mp3" + + +def main() -> None: + """Main function to process audio and metadata synchronously.""" + communicate = edge_tts.Communicate(TEXT, VOICE) + with open(OUTPUT_FILE, "wb") as file: + for chunk in communicate.stream_sync(): + if chunk["type"] == "audio": + file.write(chunk["data"]) + elif chunk["type"] == "WordBoundary": + print(f"WordBoundary: {chunk}") + + +async def amain() -> None: + """ " + Async main function to call sync main function + + This demonstrates that this works even when called from an async function. + """ + main() + + +if __name__ == "__main__": + loop = asyncio.get_event_loop_policy().get_event_loop() + try: + loop.run_until_complete(amain()) + finally: + loop.close() diff --git a/src/edge_tts/communicate.py b/src/edge_tts/communicate.py index 325b0e0..0f5975f 100644 --- a/src/edge_tts/communicate.py +++ b/src/edge_tts/communicate.py @@ -2,6 +2,8 @@ Communicate package. """ +import asyncio +import concurrent.futures import json import re import ssl @@ -9,6 +11,7 @@ import uuid from contextlib import nullcontext from io import TextIOWrapper +from queue import Queue from typing import ( Any, AsyncGenerator, @@ -498,3 +501,40 @@ async def save( ): json.dump(message, metadata) metadata.write("\n") + + def stream_sync(self) -> Generator[Dict[str, Any], None, None]: + """Synchronous interface for async stream method""" + + def fetch_async_items(queue: Queue) -> None: # type: ignore + async def get_items() -> None: + async for item in self.stream(): + queue.put(item) + queue.put(None) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(get_items()) + loop.close() + + queue: Queue = Queue() # type: ignore + + with concurrent.futures.ThreadPoolExecutor() as executor: + executor.submit(fetch_async_items, queue) + + while True: + item = queue.get() + if item is None: + break + yield item + + def save_sync( + self, + audio_fname: Union[str, bytes], + metadata_fname: Optional[Union[str, bytes]] = None, + ) -> None: + """Synchronous interface for async save method.""" + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, self.save(audio_fname, metadata_fname) + ) + future.result()