|
| 1 | +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +# /// script |
| 5 | +# requires-python = ">=3.12" |
| 6 | +# dependencies = [ |
| 7 | +# "aws-sdk-polly~=0.6.0", |
| 8 | +# ] |
| 9 | +# /// |
| 10 | +""" |
| 11 | +Speech synthesis to a file using AWS Polly bidirectional streaming. |
| 12 | +
|
| 13 | +This example demonstrates how to: |
| 14 | +- Send text to AWS Polly over a bidirectional streaming connection |
| 15 | +- Receive synthesized audio chunks as they become available |
| 16 | +- Write the synthesized audio to an MP3 file |
| 17 | +
|
| 18 | +Prerequisites: |
| 19 | +- AWS credentials configured (via environment variables) |
| 20 | +- [uv](https://docs.astral.sh/uv/getting-started/installation/) installed |
| 21 | +
|
| 22 | +Usage: |
| 23 | +- `uv run stream_speech_to_file.py` |
| 24 | +- `uv run stream_speech_to_file.py "Hello from Polly."` |
| 25 | +- `cat story.txt | uv run stream_speech_to_file.py -` |
| 26 | +- `uv run stream_speech_to_file.py --voice Ruth --output hello.mp3 "Hi."` |
| 27 | +""" |
| 28 | + |
| 29 | +import argparse |
| 30 | +import asyncio |
| 31 | +import sys |
| 32 | +import textwrap |
| 33 | +from pathlib import Path |
| 34 | + |
| 35 | +from smithy_aws_core.identity import EnvironmentCredentialsResolver |
| 36 | +from smithy_core.aio.interfaces.eventstream import EventPublisher, EventReceiver |
| 37 | + |
| 38 | +from aws_sdk_polly.client import PollyClient |
| 39 | +from aws_sdk_polly.config import Config |
| 40 | +from aws_sdk_polly.models import ( |
| 41 | + CloseStreamEvent, |
| 42 | + StartSpeechSynthesisStreamActionStream, |
| 43 | + StartSpeechSynthesisStreamActionStreamCloseStreamEvent, |
| 44 | + StartSpeechSynthesisStreamActionStreamTextEvent, |
| 45 | + StartSpeechSynthesisStreamEventStream, |
| 46 | + StartSpeechSynthesisStreamEventStreamAudioEvent, |
| 47 | + StartSpeechSynthesisStreamInput, |
| 48 | + TextEvent, |
| 49 | +) |
| 50 | + |
| 51 | +DEFAULT_REGION = "us-east-1" |
| 52 | +DEFAULT_VOICE = "Matthew" |
| 53 | +DEFAULT_OUTPUT_PATH = Path(__file__).parent / "output.mp3" |
| 54 | + |
| 55 | +SAMPLE_RATE = 24000 |
| 56 | +TEXT_CHUNK_SIZE = 160 |
| 57 | + |
| 58 | +# A few sentences delivered as separate events to demonstrate incremental |
| 59 | +# input — Polly will start producing audio as soon as the first event arrives. |
| 60 | +DEFAULT_TEXT_CHUNKS = [ |
| 61 | + "Hello! This audio was synthesized using the AWS SDK for Python.", |
| 62 | + "Polly's bidirectional streaming API returns audio as it is generated, ", |
| 63 | + "which makes it well suited for piping output from a streaming language model.", |
| 64 | +] |
| 65 | + |
| 66 | + |
| 67 | +def parse_args() -> argparse.Namespace: |
| 68 | + parser = argparse.ArgumentParser( |
| 69 | + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 70 | + ) |
| 71 | + parser.add_argument( |
| 72 | + "text", nargs="?", help='Text to synthesize. Use "-" to read text from stdin.' |
| 73 | + ) |
| 74 | + parser.add_argument("--voice", default=DEFAULT_VOICE, help="Polly voice ID.") |
| 75 | + parser.add_argument("--region", default=DEFAULT_REGION, help="AWS region.") |
| 76 | + parser.add_argument( |
| 77 | + "--output", |
| 78 | + type=Path, |
| 79 | + default=DEFAULT_OUTPUT_PATH, |
| 80 | + help="Path to write the MP3 output.", |
| 81 | + ) |
| 82 | + return parser.parse_args() |
| 83 | + |
| 84 | + |
| 85 | +def chunk_text(text: str) -> list[str]: |
| 86 | + chunks = textwrap.wrap(text, width=TEXT_CHUNK_SIZE, break_long_words=False) |
| 87 | + return [f"{chunk} " for chunk in chunks[:-1]] + chunks[-1:] |
| 88 | + |
| 89 | + |
| 90 | +def get_text_chunks(text_arg: str | None) -> list[str]: |
| 91 | + if text_arg is None: |
| 92 | + return DEFAULT_TEXT_CHUNKS |
| 93 | + |
| 94 | + text = sys.stdin.read() if text_arg == "-" else text_arg |
| 95 | + if not text.strip(): |
| 96 | + raise SystemExit("No text provided.") |
| 97 | + return chunk_text(text) |
| 98 | + |
| 99 | + |
| 100 | +async def send_text( |
| 101 | + input_stream: EventPublisher[StartSpeechSynthesisStreamActionStream], |
| 102 | + text_chunks: list[str], |
| 103 | +): |
| 104 | + for chunk in text_chunks: |
| 105 | + await input_stream.send( |
| 106 | + StartSpeechSynthesisStreamActionStreamTextEvent(value=TextEvent(text=chunk)) |
| 107 | + ) |
| 108 | + |
| 109 | + # Signal the end of input so Polly flushes the final audio. |
| 110 | + await input_stream.send( |
| 111 | + StartSpeechSynthesisStreamActionStreamCloseStreamEvent(CloseStreamEvent()) |
| 112 | + ) |
| 113 | + await input_stream.close() |
| 114 | + |
| 115 | + |
| 116 | +async def write_audio( |
| 117 | + output_stream: EventReceiver[StartSpeechSynthesisStreamEventStream], |
| 118 | + output_path: Path, |
| 119 | +): |
| 120 | + bytes_written = 0 |
| 121 | + with output_path.open("wb") as f: |
| 122 | + async for event in output_stream: |
| 123 | + if isinstance(event, StartSpeechSynthesisStreamEventStreamAudioEvent): |
| 124 | + if event.value.audio_chunk: |
| 125 | + f.write(event.value.audio_chunk) |
| 126 | + bytes_written += len(event.value.audio_chunk) |
| 127 | + |
| 128 | + print(f"Wrote {bytes_written} bytes of MP3 audio to {output_path}") |
| 129 | + |
| 130 | + |
| 131 | +async def main(): |
| 132 | + args = parse_args() |
| 133 | + text_chunks = get_text_chunks(args.text) |
| 134 | + |
| 135 | + client = PollyClient( |
| 136 | + config=Config( |
| 137 | + endpoint_uri=f"https://polly.{args.region}.amazonaws.com", |
| 138 | + region=args.region, |
| 139 | + aws_credentials_identity_resolver=EnvironmentCredentialsResolver(), |
| 140 | + ) |
| 141 | + ) |
| 142 | + |
| 143 | + stream = await client.start_speech_synthesis_stream( |
| 144 | + input=StartSpeechSynthesisStreamInput( |
| 145 | + engine="generative", |
| 146 | + output_format="mp3", |
| 147 | + sample_rate=str(SAMPLE_RATE), |
| 148 | + voice_id=args.voice, |
| 149 | + ) |
| 150 | + ) |
| 151 | + |
| 152 | + _, output_stream = await stream.await_output() |
| 153 | + if output_stream is None: |
| 154 | + raise RuntimeError("Polly stream did not return an output stream") |
| 155 | + |
| 156 | + print("Synthesizing audio...") |
| 157 | + await asyncio.gather( |
| 158 | + send_text(stream.input_stream, text_chunks), |
| 159 | + write_audio(output_stream, args.output), |
| 160 | + ) |
| 161 | + |
| 162 | + |
| 163 | +if __name__ == "__main__": |
| 164 | + asyncio.run(main()) |
0 commit comments