Skip to content

Commit 35f480b

Browse files
committed
Modified and several files added
1 parent bffb1dc commit 35f480b

7 files changed

Lines changed: 298 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Check out code
17+
uses: actions/checkout@v2
18+
19+
- name: Set up Python 3.12
20+
uses: actions/setup-python@v2
21+
with:
22+
python-version: 3.12
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -r requirements.txt
28+
29+
- name: Run flake8
30+
run: |
31+
pip install flake8
32+
flake8 .
33+
34+
- name: Run tests
35+
run: |
36+
pip install pytest
37+
pytest

.gitignore

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
# Virtual environment
1+
# Virtual environments
22
venv/
3-
.env/
3+
.venv/
4+
ENV/
45

5-
# Python cache
6+
# Python
67
__pycache__/
78
*.pyc
9+
*.pyo
10+
*.pyd
11+
*.egg-info/
812

9-
# Editor settings
13+
# Environment variables
14+
.env
15+
16+
# Editors / OS
1017
.vscode/
1118
.idea/
12-
13-
# OS files
1419
.DS_Store

LICENSE

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
MIT License
2+
3+
Copyright (c) 2026 bundlab
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
[...]
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
authors OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# voice_stream_app
2+
3+
A small demo that prints lines of text and speaks them locally using pyttsx3.
4+
5+
Features added in this commit:
6+
- Robust, thread-safe TTS worker that initializes pyttsx3 inside its thread
7+
- CLI with options for rate, volume, continuous mode, and saving to a file
8+
- Graceful shutdown handling (signals & KeyboardInterrupt)
9+
- Basic unit test for non-TTS behavior and GitHub Actions CI
10+
11+
Usage
12+
13+
Run the demo (prints and speaks lines):
14+
15+
```bash
16+
python app.py

app.py

Lines changed: 187 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,201 @@
1-
import pyttsx3
21
import threading
2+
import queue
33
import time
4+
import logging
5+
import signal
6+
import sys
7+
import argparse
8+
from pathlib import Path
49

5-
# Initialize the speech engine
6-
engine = pyttsx3.init()
7-
8-
# Text lines to print and speak
9-
lines = [
10+
# NOTE: import pyttsx3 only inside TTS functions to avoid import-time errors in CI/tests
11+
# Configuration defaults
12+
DEFAULT_LINES = [
1013
"Hello, this is a live speaking text printer.",
1114
"This app prints and speaks text continuously.",
1215
"You can modify the text list to include your own content.",
1316
"Python makes it easy to combine speech and printing.",
1417
"Thanks for using this demo!"
1518
]
1619

17-
def speak_text():
18-
for line in lines:
19-
engine.say(line)
20-
engine.runAndWait()
21-
time.sleep(0.5)
20+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
21+
22+
def tts_worker(msg_queue: queue.Queue, stop_event: threading.Event, rate: int = 150, volume: float = 1.0):
23+
"""TTS worker that initializes the pyttsx3 engine inside the thread and speaks queued messages.
24+
25+
This avoids sharing the engine across threads and keeps runAndWait blocking only inside this thread.
26+
"""
27+
try:
28+
import pyttsx3
29+
except Exception as e:
30+
logging.exception("pyttsx3 is not available: %s", e)
31+
return
32+
33+
try:
34+
engine = pyttsx3.init()
35+
engine.setProperty("rate", rate)
36+
engine.setProperty("volume", volume)
37+
except Exception as e:
38+
logging.exception("Failed to initialize TTS engine: %s", e)
39+
return
40+
41+
logging.info("TTS worker started")
42+
try:
43+
while not stop_event.is_set():
44+
try:
45+
line = msg_queue.get(timeout=0.5)
46+
except queue.Empty:
47+
continue
48+
49+
if line is None:
50+
# sentinel
51+
break
52+
53+
try:
54+
engine.say(line)
55+
engine.runAndWait()
56+
except Exception:
57+
logging.exception("Error while speaking line: %r", line)
58+
finally:
59+
msg_queue.task_done()
60+
finally:
61+
try:
62+
engine.stop()
63+
except Exception:
64+
pass
65+
logging.info("TTS worker exiting")
66+
67+
def print_worker(lines, msg_queue: queue.Queue, stop_event: threading.Event, print_interval: float = 0.5, enqueue_for_tts: bool = True, run_once: bool = False):
68+
"""Printer worker that prints lines and optionally enqueues them for TTS.
69+
70+
Args:
71+
lines: iterable of strings to print.
72+
msg_queue: queue to put lines on for TTS.
73+
stop_event: threading.Event to stop the worker.
74+
print_interval: delay between lines.
75+
enqueue_for_tts: whether to put printed lines on the msg_queue.
76+
run_once: if True, run through the lines once and then exit.
77+
"""
78+
logging.info("Printer worker started")
79+
try:
80+
while not stop_event.is_set():
81+
for line in lines:
82+
if stop_event.is_set():
83+
break
84+
print(line)
85+
if enqueue_for_tts and msg_queue is not None:
86+
try:
87+
msg_queue.put(line, timeout=0.5)
88+
except queue.Full:
89+
logging.warning("TTS queue full; skipping line")
90+
if print_interval:
91+
time.sleep(print_interval)
92+
if run_once:
93+
break
94+
except Exception:
95+
logging.exception("Printer worker error")
96+
finally:
97+
logging.info("Printer worker exiting")
98+
99+
def synthesize_to_file(lines, output_path: str, rate: int = 150, volume: float = 1.0):
100+
"""Synthesize the provided lines to a file using pyttsx3.save_to_file and runAndWait.
101+
"""
102+
try:
103+
import pyttsx3
104+
except Exception as e:
105+
logging.exception("pyttsx3 is not available: %s", e)
106+
raise
107+
108+
engine = pyttsx3.init()
109+
engine.setProperty("rate", rate)
110+
engine.setProperty("volume", volume)
111+
112+
text = "\n".join(lines)
113+
output_path = str(output_path)
114+
logging.info("Saving synthesized audio to %s", output_path)
115+
engine.save_to_file(text, output_path)
116+
engine.runAndWait()
117+
logging.info("Finished saving %s", output_path)
118+
119+
def run(lines=None, *, continuous=False, rate=150, volume=1.0, print_interval=0.5):
120+
"""Run the printer + TTS workers until interrupted.
121+
122+
Returns after graceful shutdown.
123+
"""
124+
if lines is None:
125+
lines = DEFAULT_LINES
126+
127+
stop_event = threading.Event()
128+
msg_queue = queue.Queue(maxsize=64)
129+
130+
def handle_signal(signum, frame):
131+
logging.info("Signal %s received, shutting down", signum)
132+
stop_event.set()
133+
# Wake TTS worker if waiting
134+
try:
135+
msg_queue.put_nowait(None)
136+
except Exception:
137+
pass
138+
139+
signal.signal(signal.SIGINT, handle_signal)
140+
signal.signal(signal.SIGTERM, handle_signal)
141+
142+
tts_thread = threading.Thread(target=tts_worker, args=(msg_queue, stop_event, rate, volume), daemon=True)
143+
printer_thread = threading.Thread(
144+
target=print_worker, args=(lines, msg_queue, stop_event, print_interval, True, not continuous), daemon=True
145+
)
146+
147+
tts_thread.start()
148+
printer_thread.start()
149+
150+
try:
151+
while (tts_thread.is_alive() or printer_thread.is_alive()) and not stop_event.is_set():
152+
time.sleep(0.2)
153+
except KeyboardInterrupt:
154+
logging.info("KeyboardInterrupt, initiating shutdown")
155+
stop_event.set()
156+
try:
157+
msg_queue.put_nowait(None)
158+
except Exception:
159+
pass
160+
161+
# Wait a short while for threads to finish
162+
tts_thread.join(timeout=2.0)
163+
printer_thread.join(timeout=2.0)
164+
logging.info("Shutdown complete")
165+
166+
def _read_lines_from_file(path: str):
167+
p = Path(path)
168+
if not p.exists():
169+
raise FileNotFoundError(path)
170+
return [l.rstrip("\n\r") for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
171+
172+
def parse_args(argv=None):
173+
parser = argparse.ArgumentParser(description="Voice stream demo: print text and speak it locally using pyttsx3.")
174+
parser.add_argument("--lines-file", help="Path to a text file with one line per utterance (overrides built-in lines)")
175+
parser.add_argument("--continuous", action="store_true", help="Loop continuously over the provided lines")
176+
parser.add_argument("--rate", type=int, default=150, help="Speech rate for TTS (words per minute)")
177+
parser.add_argument("--volume", type=float, default=1.0, help="TTS volume (0.0..1.0)")
178+
parser.add_argument("--print-interval", type=float, default=0.5, help="Seconds between printed lines")
179+
parser.add_argument("--save", metavar="OUTPUT", help="Synthesize lines to a file (e.g., output.mp3 or output.wav) and exit")
180+
parser.add_argument("--run-once", dest="run_once", action="store_true", help="Print and speak the lines once and exit (overrides --continuous)")
181+
return parser.parse_args(argv)
182+
183+
def main(argv=None):
184+
args = parse_args(argv)
185+
186+
if args.lines_file:
187+
lines = _read_lines_from_file(args.lines_file)
188+
else:
189+
lines = DEFAULT_LINES
22190

23-
def print_text():
24-
for line in lines:
25-
print(line)
26-
time.sleep(0.5)
191+
if args.save:
192+
synthesize_to_file(lines, args.save, rate=args.rate, volume=args.volume)
193+
return
27194

28-
# Create threads
29-
speak_thread = threading.Thread(target=speak_text)
30-
print_thread = threading.Thread(target=print_text)
195+
# run_once flag means not continuous
196+
continuous = bool(args.continuous) and not args.run_once
197+
run(lines, continuous=continuous, rate=args.rate, volume=args.volume, print_interval=args.print_interval)
31198

32-
# Start threads
33-
speak_thread.start()
34-
print_thread.start()
35199

36-
# Wait for both threads to complete
37-
speak_thread.join()
38-
print_thread.join()
200+
if __name__ == "__main__":
201+
main()

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
pyttsx3==2.90
1+
pyttsx3>=2.90
2+
pytest>=7.0
3+
flake8>=6.0

tests/test_queue.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
from app import print_worker
3+
4+
5+
def test_print_worker_enqueue_lines():
6+
from queue import Queue
7+
from threading import Event
8+
9+
msg_queue = Queue()
10+
stop_event = Event()
11+
lines = ["line 1", "line 2", "line 3"]
12+
13+
# Run the print_worker as a separate thread or function.
14+
# Here you'd typically use threading to test concurrently.
15+
16+
# Simulate call to the print_worker function.
17+
print_worker(lines, msg_queue, stop_event, print_interval=0, enqueue_for_tts=True, run_once=False)
18+
19+
# Check that all lines were enqueued.
20+
for line in lines:
21+
assert msg_queue.get(timeout=1) == line
22+
23+
# Ensure queue is empty after processing lines.
24+
assert msg_queue.empty()

0 commit comments

Comments
 (0)