Skip to content

Commit 48b4da5

Browse files
authored
Add Polly bidi streaming examples (#61)
* Add Polly bidi streaming examples Add two self-contained uv scripts showing how to use start_speech_synthesis_stream: simple_file.py writes synthesized audio to MP3, and simple_speaker.py plays it back in real time via miniaudio. * Rename files to more descriptive names * Add license headers * pin client dependency and allow usage outside of repo
1 parent 34e4188 commit 48b4da5

3 files changed

Lines changed: 473 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Polly Bidirectional Streaming Examples
2+
3+
Each script is a self-contained [uv](https://docs.astral.sh/uv/getting-started/installation/) script that demonstrates one way to use Polly's bidirectional streaming API (`start_speech_synthesis_stream`).
4+
5+
| Script | What it shows | Extra dependencies |
6+
| --- | --- | --- |
7+
| [`stream_speech_to_file.py`](stream_speech_to_file.py) | Stream synthesized audio and save it to an MP3 file. | None |
8+
| [`stream_speech_to_speakers.py`](stream_speech_to_speakers.py) | Real-time MP3 playback through your speakers as audio arrives. The MP3 decoder handles buffering. | `miniaudio` |
9+
10+
## Prerequisites
11+
12+
- AWS credentials available via environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally `AWS_SESSION_TOKEN`).
13+
- Python 3.12+.
14+
- `uv` installed.
15+
- For `stream_speech_to_speakers.py`: a working audio output device.
16+
17+
## Running
18+
19+
All examples accept text as a positional argument, from stdin via `-`, or fall back to a built-in default:
20+
21+
```sh
22+
# Default text
23+
uv run stream_speech_to_file.py
24+
25+
# Inline text
26+
uv run stream_speech_to_file.py "Hello from Polly."
27+
28+
# From stdin
29+
cat story.txt | uv run stream_speech_to_file.py -
30+
```
31+
32+
Common flags:
33+
34+
- `--voice` — Polly voice ID (default `Matthew`)
35+
- `--region` — AWS region (default `us-east-1`)
36+
- `--output` (`stream_speech_to_file.py` only) — MP3 output path
37+
38+
The bidi API only supports the `generative` engine, so engine selection is not exposed.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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

Comments
 (0)