messageflight 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- message_flight/__init__.py +0 -0
- message_flight/autostart.py +41 -0
- message_flight/config.py +457 -0
- message_flight/demo_notifications.py +12 -0
- message_flight/flight_widget.py +470 -0
- message_flight/i18n.py +370 -0
- message_flight/notification_queue.py +72 -0
- message_flight/notification_worker.py +98 -0
- message_flight/plane_banner.py +359 -0
- message_flight/plane_presets/__init__.py +26 -0
- message_flight/plane_presets/airplane.py +103 -0
- message_flight/plane_presets/base.py +35 -0
- message_flight/plane_presets/bird.py +87 -0
- message_flight/plane_presets/rocket.py +94 -0
- message_flight/plane_presets/ufo.py +73 -0
- message_flight/preset_editor.py +345 -0
- message_flight/settings_dialog.py +266 -0
- message_flight/tray_app.py +280 -0
- message_flight/tts.py +298 -0
- message_flight/tts_manager.py +98 -0
- messageflight-0.2.4.dist-info/METADATA +94 -0
- messageflight-0.2.4.dist-info/RECORD +24 -0
- messageflight-0.2.4.dist-info/WHEEL +4 -0
- messageflight-0.2.4.dist-info/licenses/LICENSE +21 -0
message_flight/tts.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Text-to-speech notification reader.
|
|
2
|
+
|
|
3
|
+
Provides ``TTSReader`` abstract base class and two concrete implementations:
|
|
4
|
+
- ``SAPIReader`` – Windows SAPI via pywin32 (falls back to no-op if unavailable)
|
|
5
|
+
- ``MiniMaxReader`` – MiniMax online TTS engine via Qt async network + pygame audio
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import binascii
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from PyQt6.QtCore import QByteArray, QObject, QTimer, QUrl, pyqtSignal
|
|
18
|
+
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TTSReader:
|
|
24
|
+
"""Abstract TTS reader that formats a message through a template and
|
|
25
|
+
delegates speaking to :meth:`_speak_impl`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, enabled: bool = True, title_template: str = "{message}"):
|
|
29
|
+
self._enabled = enabled
|
|
30
|
+
self._title_template = title_template
|
|
31
|
+
|
|
32
|
+
def speak(self, message: str) -> None:
|
|
33
|
+
"""If enabled, format *message* with :attr:`_title_template` and speak it."""
|
|
34
|
+
if not self._enabled:
|
|
35
|
+
logger.debug("TTSReader.speak: skipped (disabled)")
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
text = self._title_template.replace("{message}", message)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.error("TTS format error: %s", e)
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
logger.debug("TTSReader.speak: speaking text=%r", text)
|
|
44
|
+
self._speak_impl(text)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error("TTS speak error: %s", e)
|
|
47
|
+
|
|
48
|
+
def _speak_impl(self, text: str) -> None:
|
|
49
|
+
"""Concrete subclasses must override this method."""
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SAPIReader(TTSReader):
|
|
54
|
+
"""Windows SAPI TTS via ``win32com.client``.
|
|
55
|
+
|
|
56
|
+
If ``pywin32`` is not installed or the platform is not Windows,
|
|
57
|
+
initialization silently fails and :meth:`speak` becomes a no-op.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, **kwargs):
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
if self._enabled:
|
|
63
|
+
self._init_sapi()
|
|
64
|
+
|
|
65
|
+
def _init_sapi(self) -> None:
|
|
66
|
+
if sys.platform != "win32":
|
|
67
|
+
logger.warning("SAPIReader: not on Windows, disabling")
|
|
68
|
+
self._enabled = False
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
import win32com.client
|
|
72
|
+
|
|
73
|
+
self._speaker = win32com.client.Dispatch("SAPI.SpVoice")
|
|
74
|
+
logger.info("SAPIReader: SAPI initialized successfully")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning("SAPIReader: failed to initialize SAPI: %s", e)
|
|
77
|
+
self._enabled = False
|
|
78
|
+
|
|
79
|
+
# SVSFlagsAsync = 1 — speak asynchronously so we don't block the UI
|
|
80
|
+
_SVS_FLAGS_ASYNC = 1
|
|
81
|
+
|
|
82
|
+
def _speak_impl(self, text: str) -> None:
|
|
83
|
+
logger.debug("SAPIReader._speak_impl: speaking asynchronously")
|
|
84
|
+
self._speaker.Speak(text, self._SVS_FLAGS_ASYNC)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MiniMaxReader(TTSReader, QObject):
|
|
88
|
+
"""MiniMax TTS via QNetworkAccessManager + pygame.mixer.
|
|
89
|
+
|
|
90
|
+
Asynchronously calls the MiniMax API and plays the returned MP3.
|
|
91
|
+
On any error, emits ``error_occurred`` so TTSManager can fall back
|
|
92
|
+
to SAPIReader.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
playback_finished = pyqtSignal()
|
|
96
|
+
# error_occurred(error_message: str, original_text: str)
|
|
97
|
+
# original_text is passed so TTSManager can fall back with the correct message
|
|
98
|
+
error_occurred = pyqtSignal(str, str)
|
|
99
|
+
|
|
100
|
+
_ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2"
|
|
101
|
+
_DEFAULT_VOICE = "male-qn-qingse"
|
|
102
|
+
_TIMEOUT_MS = 10000
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
api_key: str = "",
|
|
107
|
+
voice_id: str = _DEFAULT_VOICE,
|
|
108
|
+
speed: float = 1.0,
|
|
109
|
+
vol: float = 1.0,
|
|
110
|
+
enabled: bool = True,
|
|
111
|
+
title_template: str = "{message}",
|
|
112
|
+
):
|
|
113
|
+
TTSReader.__init__(self, enabled=enabled, title_template=title_template)
|
|
114
|
+
QObject.__init__(self)
|
|
115
|
+
self._api_key = api_key
|
|
116
|
+
self._voice_id = voice_id
|
|
117
|
+
self._speed = speed
|
|
118
|
+
self._vol = vol
|
|
119
|
+
self._network = QNetworkAccessManager()
|
|
120
|
+
self._active_audio_files: set[str] = set() # Track temp files for cleanup
|
|
121
|
+
self._last_text = "" # Last text sent to MiniMax, for error fallback
|
|
122
|
+
self._reply_text_map: dict[int, str] = {} # id(reply) -> original text
|
|
123
|
+
|
|
124
|
+
self._network.finished.connect(self._on_reply_finished)
|
|
125
|
+
from PyQt6.QtWidgets import QApplication
|
|
126
|
+
app = QApplication.instance()
|
|
127
|
+
if app is not None:
|
|
128
|
+
app.aboutToQuit.connect(self.cleanup)
|
|
129
|
+
logger.info("MiniMaxReader: initialized with voice_id=%s", voice_id)
|
|
130
|
+
|
|
131
|
+
def _speak_impl(self, text: str) -> None:
|
|
132
|
+
self._last_text = text
|
|
133
|
+
if not self._api_key:
|
|
134
|
+
logger.error("MiniMaxReader._speak_impl: api_key is empty")
|
|
135
|
+
self.error_occurred.emit("MiniMax API key is empty", text)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
logger.info("MiniMaxReader._speak_impl: sending TTS request for text=%r", text[:50])
|
|
139
|
+
|
|
140
|
+
request = QNetworkRequest(QUrl(self._ENDPOINT))
|
|
141
|
+
request.setTransferTimeout(self._TIMEOUT_MS)
|
|
142
|
+
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
|
|
143
|
+
request.setRawHeader(b"Authorization", f"Bearer {self._api_key}".encode())
|
|
144
|
+
|
|
145
|
+
payload = {
|
|
146
|
+
"model": "speech-2.8-hd",
|
|
147
|
+
"text": text,
|
|
148
|
+
"stream": False,
|
|
149
|
+
"voice_setting": {
|
|
150
|
+
"voice_id": self._voice_id,
|
|
151
|
+
"speed": self._speed,
|
|
152
|
+
"vol": self._vol,
|
|
153
|
+
"pitch": 0,
|
|
154
|
+
},
|
|
155
|
+
"audio_setting": {
|
|
156
|
+
"sample_rate": 32000,
|
|
157
|
+
"bitrate": 128000,
|
|
158
|
+
"format": "mp3",
|
|
159
|
+
"channel": 1,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
163
|
+
logger.debug("MiniMaxReader._speak_impl: request body=%s", body.decode("utf-8")[:200])
|
|
164
|
+
reply = self._network.post(request, body)
|
|
165
|
+
self._reply_text_map[id(reply)] = text
|
|
166
|
+
|
|
167
|
+
def _on_reply_finished(self, reply: QNetworkReply) -> None:
|
|
168
|
+
status_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
|
|
169
|
+
logger.info("MiniMaxReader._on_reply_finished: HTTP status=%s", status_code)
|
|
170
|
+
|
|
171
|
+
original_text = self._reply_text_map.pop(id(reply), self._last_text)
|
|
172
|
+
|
|
173
|
+
if reply.error() != QNetworkReply.NetworkError.NoError:
|
|
174
|
+
err_msg = f"MiniMax network error: {reply.errorString()}"
|
|
175
|
+
logger.error("MiniMaxReader._on_reply_finished: %s", err_msg)
|
|
176
|
+
self.error_occurred.emit(err_msg, original_text)
|
|
177
|
+
reply.deleteLater()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
data = reply.readAll()
|
|
181
|
+
if data.isEmpty():
|
|
182
|
+
logger.error("MiniMaxReader._on_reply_finished: empty response")
|
|
183
|
+
self.error_occurred.emit("MiniMax returned empty response", original_text)
|
|
184
|
+
reply.deleteLater()
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
response_text = data.data().decode("utf-8")
|
|
189
|
+
logger.debug("MiniMaxReader._on_reply_finished: response=%s", response_text[:500])
|
|
190
|
+
response_json = json.loads(response_text)
|
|
191
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
192
|
+
logger.error("MiniMaxReader._on_reply_finished: failed to parse response: %s", e)
|
|
193
|
+
self.error_occurred.emit(f"MiniMax response parse error: {e}", original_text)
|
|
194
|
+
reply.deleteLater()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Check base_resp for API errors
|
|
198
|
+
base_resp = response_json.get("base_resp", {})
|
|
199
|
+
status_code_api = base_resp.get("status_code", 0)
|
|
200
|
+
if status_code_api != 0:
|
|
201
|
+
status_msg = base_resp.get("status_msg", "unknown error")
|
|
202
|
+
err_msg = f"MiniMax API error {status_code_api}: {status_msg}"
|
|
203
|
+
logger.error("MiniMaxReader._on_reply_finished: %s", err_msg)
|
|
204
|
+
self.error_occurred.emit(err_msg, original_text)
|
|
205
|
+
reply.deleteLater()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Extract hex-encoded audio
|
|
209
|
+
audio_hex = response_json.get("data", {}).get("audio", "")
|
|
210
|
+
if not audio_hex:
|
|
211
|
+
logger.error("MiniMaxReader._on_reply_finished: no audio in response")
|
|
212
|
+
self.error_occurred.emit("MiniMax returned no audio data", self._last_text)
|
|
213
|
+
reply.deleteLater()
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
audio_bytes = binascii.unhexlify(audio_hex)
|
|
218
|
+
logger.info("MiniMaxReader._on_reply_finished: decoded %d bytes of audio", len(audio_bytes))
|
|
219
|
+
except binascii.Error as e:
|
|
220
|
+
logger.error("MiniMaxReader._on_reply_finished: failed to decode audio hex: %s", e)
|
|
221
|
+
self.error_occurred.emit(f"MiniMax audio decode error: {e}", self._last_text)
|
|
222
|
+
reply.deleteLater()
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
self._buffer = QByteArray(audio_bytes)
|
|
226
|
+
# Use uuid to avoid filename collisions between concurrent requests
|
|
227
|
+
audio_path = os.path.join(tempfile.gettempdir(), f"messageflight_{uuid.uuid4().hex}.mp3")
|
|
228
|
+
with open(audio_path, "wb") as f:
|
|
229
|
+
f.write(self._buffer.data())
|
|
230
|
+
|
|
231
|
+
self._active_audio_files.add(audio_path)
|
|
232
|
+
logger.info("MiniMaxReader._on_reply_finished: saved audio to %s (%d bytes)", audio_path, len(audio_bytes))
|
|
233
|
+
|
|
234
|
+
# Use pygame.mixer to play audio (reliable cross-platform MP3 playback)
|
|
235
|
+
self._play_audio_with_pygame(audio_path)
|
|
236
|
+
reply.deleteLater()
|
|
237
|
+
|
|
238
|
+
def _play_audio_with_pygame(self, audio_path: str) -> None:
|
|
239
|
+
"""Play audio file using pygame.mixer."""
|
|
240
|
+
try:
|
|
241
|
+
import pygame
|
|
242
|
+
|
|
243
|
+
# Initialize pygame mixer if not already initialized
|
|
244
|
+
if not pygame.mixer.get_init():
|
|
245
|
+
pygame.mixer.init(frequency=32000)
|
|
246
|
+
logger.info("MiniMaxReader: pygame.mixer initialized")
|
|
247
|
+
|
|
248
|
+
# Load and play the audio
|
|
249
|
+
sound = pygame.mixer.Sound(audio_path)
|
|
250
|
+
sound.set_volume(self._vol)
|
|
251
|
+
sound.play()
|
|
252
|
+
logger.info("MiniMaxReader: playing audio from %s", audio_path)
|
|
253
|
+
|
|
254
|
+
# Track this file for cleanup
|
|
255
|
+
timer = QTimer(self)
|
|
256
|
+
timer.setSingleShot(True)
|
|
257
|
+
# Capture audio_path by value to avoid closure issues
|
|
258
|
+
timer.timeout.connect(lambda _path=audio_path, _timer=timer: self._cleanup_after_playback(_path, _timer))
|
|
259
|
+
timer.start(100) # Check every 100ms
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error("MiniMaxReader: failed to play audio with pygame: %s", e)
|
|
263
|
+
self._remove_audio_file(audio_path)
|
|
264
|
+
self.error_occurred.emit(f"Audio playback error: {e}", self._last_text)
|
|
265
|
+
|
|
266
|
+
def _cleanup_after_playback(self, audio_path: str, timer: QTimer) -> None:
|
|
267
|
+
"""Check if pygame audio playback has finished and clean up the file."""
|
|
268
|
+
try:
|
|
269
|
+
import pygame
|
|
270
|
+
if not pygame.mixer.get_busy():
|
|
271
|
+
logger.debug("MiniMaxReader: playback finished for %s", audio_path)
|
|
272
|
+
self.playback_finished.emit()
|
|
273
|
+
timer.stop()
|
|
274
|
+
timer.deleteLater()
|
|
275
|
+
self._remove_audio_file(audio_path)
|
|
276
|
+
else:
|
|
277
|
+
# Still playing, check again
|
|
278
|
+
timer.start(100)
|
|
279
|
+
except Exception:
|
|
280
|
+
timer.stop()
|
|
281
|
+
timer.deleteLater()
|
|
282
|
+
self._remove_audio_file(audio_path)
|
|
283
|
+
|
|
284
|
+
def _remove_audio_file(self, audio_path: str) -> None:
|
|
285
|
+
"""Remove a temporary audio file and update tracking."""
|
|
286
|
+
self._active_audio_files.discard(audio_path)
|
|
287
|
+
if os.path.exists(audio_path):
|
|
288
|
+
try:
|
|
289
|
+
os.remove(audio_path)
|
|
290
|
+
logger.debug("MiniMaxReader: removed temp file %s", audio_path)
|
|
291
|
+
except OSError as e:
|
|
292
|
+
logger.warning("MiniMaxReader: failed to remove temp file %s: %s", audio_path, e)
|
|
293
|
+
|
|
294
|
+
def cleanup(self) -> None:
|
|
295
|
+
"""Remove all active temp audio files. Called on application exit."""
|
|
296
|
+
for path in list(self._active_audio_files):
|
|
297
|
+
self._remove_audio_file(path)
|
|
298
|
+
self._active_audio_files.clear()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""TTS provider manager with automatic fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
8
|
+
|
|
9
|
+
from message_flight.config import AppConfig
|
|
10
|
+
from message_flight.tts import MiniMaxReader, SAPIReader, TTSReader
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TTSManager(QObject):
|
|
16
|
+
"""Manages TTS providers and handles automatic fallback.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
mgr = TTSManager(config)
|
|
20
|
+
mgr.speak("notification text")
|
|
21
|
+
|
|
22
|
+
On MiniMax error, automatically falls back to SAPIReader.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
provider_changed = pyqtSignal(str)
|
|
26
|
+
fallback_triggered = pyqtSignal(str)
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: AppConfig, parent: Optional[QObject] = None):
|
|
29
|
+
super().__init__(parent)
|
|
30
|
+
self._config = config
|
|
31
|
+
self._current_provider_name = config.tts_provider
|
|
32
|
+
logger.info("TTSManager: initialized with provider=%s", self._current_provider_name)
|
|
33
|
+
self._providers: dict[str, TTSReader] = {}
|
|
34
|
+
self._last_message = ""
|
|
35
|
+
self._init_providers()
|
|
36
|
+
|
|
37
|
+
def _init_providers(self) -> None:
|
|
38
|
+
"""Initialize all known providers."""
|
|
39
|
+
self._providers["sapi"] = SAPIReader(
|
|
40
|
+
enabled=True,
|
|
41
|
+
title_template="您有新消息了。{message}",
|
|
42
|
+
)
|
|
43
|
+
key_preview = self._config.minimax_subscription_key[:8] + "..." if self._config.minimax_subscription_key else "(empty)"
|
|
44
|
+
logger.info("TTSManager._init_providers: creating MiniMaxReader with key=%s", key_preview)
|
|
45
|
+
self._providers["minimax"] = MiniMaxReader(
|
|
46
|
+
api_key=self._config.minimax_subscription_key,
|
|
47
|
+
enabled=True,
|
|
48
|
+
title_template="您有新消息了。{message}",
|
|
49
|
+
)
|
|
50
|
+
# Connect MiniMax error signal to fallback handler
|
|
51
|
+
minimax = self._providers.get("minimax")
|
|
52
|
+
if isinstance(minimax, MiniMaxReader):
|
|
53
|
+
minimax.error_occurred.connect(self._on_minimax_error)
|
|
54
|
+
|
|
55
|
+
def speak(self, message: str) -> None:
|
|
56
|
+
"""Speak a message using the current provider."""
|
|
57
|
+
logger.info("TTSManager.speak: provider=%s message=%r", self._current_provider_name, message[:50])
|
|
58
|
+
self._last_message = message
|
|
59
|
+
provider = self._providers.get(self._current_provider_name)
|
|
60
|
+
if provider is None:
|
|
61
|
+
logger.warning("TTSManager.speak: unknown provider %r, falling back to sapi", self._current_provider_name)
|
|
62
|
+
provider = self._providers.get("sapi")
|
|
63
|
+
if provider is not None:
|
|
64
|
+
provider.speak(message)
|
|
65
|
+
|
|
66
|
+
def update_config(self, config: AppConfig) -> None:
|
|
67
|
+
"""Hot-update configuration (e.g. after settings dialog)."""
|
|
68
|
+
logger.info("TTSManager.update_config: provider=%s -> %s", self._current_provider_name, config.tts_provider)
|
|
69
|
+
self._config = config
|
|
70
|
+
old_provider = self._current_provider_name
|
|
71
|
+
self._current_provider_name = config.tts_provider
|
|
72
|
+
|
|
73
|
+
# Update MiniMaxReader API key if changed
|
|
74
|
+
minimax = self._providers.get("minimax")
|
|
75
|
+
if isinstance(minimax, MiniMaxReader):
|
|
76
|
+
old_key = minimax._api_key[:8] + "..." if minimax._api_key else "(empty)"
|
|
77
|
+
new_key = config.minimax_subscription_key[:8] + "..." if config.minimax_subscription_key else "(empty)"
|
|
78
|
+
logger.info("TTSManager.update_config: updating MiniMax key %s -> %s", old_key, new_key)
|
|
79
|
+
minimax._api_key = config.minimax_subscription_key
|
|
80
|
+
|
|
81
|
+
if old_provider != self._current_provider_name:
|
|
82
|
+
self.provider_changed.emit(self._current_provider_name)
|
|
83
|
+
|
|
84
|
+
def _on_minimax_error(self, error_msg: str, original_text: str) -> None:
|
|
85
|
+
"""Handle MiniMax error by falling back to SAPI.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
error_msg: Human-readable error description.
|
|
89
|
+
original_text: The text that was supposed to be spoken;
|
|
90
|
+
passed explicitly to avoid race conditions with
|
|
91
|
+
rapid successive speak() calls.
|
|
92
|
+
"""
|
|
93
|
+
logger.error("TTSManager: MiniMax failed (%s), falling back to SAPI", error_msg)
|
|
94
|
+
self.fallback_triggered.emit(error_msg)
|
|
95
|
+
sapi = self._providers.get("sapi")
|
|
96
|
+
if sapi is not None and original_text:
|
|
97
|
+
logger.info("TTSManager: falling back to SAPI for message=%r", original_text[:50])
|
|
98
|
+
sapi.speak(original_text)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: messageflight
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Let Windows notifications fly across your screen like a little plane.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wx528/MessageFlight
|
|
6
|
+
Project-URL: Issues, https://github.com/wx528/MessageFlight/issues
|
|
7
|
+
Author: MessageFlight Contributors
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 MessageFlight Contributors
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Python: >=3.8
|
|
31
|
+
Requires-Dist: pygame>=2.5.0
|
|
32
|
+
Requires-Dist: pyqt6>=6.5.0
|
|
33
|
+
Requires-Dist: pywin32>=227; sys_platform == 'win32'
|
|
34
|
+
Requires-Dist: winsdk>=1.0.0b10
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: mypy<1.15,>=1.8; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-qt>=4.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest<9,>=7.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: types-pywin32>=311; (python_version >= '3.9') and extra == 'dev'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# MessageFlight
|
|
43
|
+
|
|
44
|
+
[中文](README.zh.md) | English | [日本語](README.ja.md) | [한국어](README.ko.md) | [Bahasa Indonesia](README.id.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Bahasa Melayu](README.ms.md)
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<img src="https://img.shields.io/badge/Python-3.8%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
|
|
48
|
+
<img src="https://img.shields.io/badge/GUI-PyQt6-41CD52?style=flat" alt="PyQt6">
|
|
49
|
+
<img src="https://img.shields.io/badge/Platform-Windows%2010%2F11-0078D6?style=flat&logo=windows&logoColor=white" alt="Windows">
|
|
50
|
+
<a href="https://pypi.org/project/messageflight/"><img src="https://img.shields.io/pypi/v/messageflight?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
51
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
|
|
52
|
+
<a href="https://github.com/wx528/MessageFlight/actions/workflows/ci.yml"><img src="https://github.com/wx528/MessageFlight/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
53
|
+
<br>
|
|
54
|
+
<img src="https://img.shields.io/badge/Languages-zh%20%7C%20en%20%7C%20ja%20%7C%20ko%20%7C%20id%20%7C%20th%20%7C%20vi%20%7C%20ms-8A2BE2?style=flat" alt="Languages">
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
Let Windows notifications fly across your screen like a little plane.
|
|
58
|
+
|
|
59
|
+
## Screenshots
|
|
60
|
+
|
|
61
|
+
| | | |
|
|
62
|
+
|:---:|:---:|:---:|
|
|
63
|
+
|  |  |  |
|
|
64
|
+
|  | | |
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- Animated plane overlay for real Windows notifications
|
|
69
|
+
- System tray controls for pause, demo notification, do-not-disturb, settings, autostart, and quit
|
|
70
|
+
- Lightweight UI languages: Chinese, English, Japanese, Korean, Indonesian, Thai, Vietnamese, and Malay
|
|
71
|
+
- Custom colors, flight paths, and vehicle presets
|
|
72
|
+
- Optional TTS support through SAPI or MiniMax
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
Requires Windows 10/11 and Python 3.8+.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git clone https://github.com/wx528/MessageFlight.git
|
|
80
|
+
cd MessageFlight
|
|
81
|
+
python -m venv .venv
|
|
82
|
+
.venv\Scripts\activate
|
|
83
|
+
pip install .
|
|
84
|
+
python message_flight.py
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Using `uv`:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync
|
|
91
|
+
uv run python message_flight.py
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
message_flight/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
message_flight/autostart.py,sha256=ugzxII8aih_kXl85d4fHvoZd6p0F1TNU4_sG3tCmzWw,1150
|
|
3
|
+
message_flight/config.py,sha256=8pA-EEJpnV3wnbIt-G-XO8hTby1rwx7g8LrAJktcAAk,17287
|
|
4
|
+
message_flight/demo_notifications.py,sha256=c_XqbRTZ37Ojej7obppyA-cB_RNYmLSndo9uEQhZxZs,350
|
|
5
|
+
message_flight/flight_widget.py,sha256=SSHfs_8KFi-Lf-GKZz_AqEjTBiHDbQ3vFPs0Co9N-1w,19306
|
|
6
|
+
message_flight/i18n.py,sha256=vxk8thbAPLXVyhZKFQvvdRteAL9dbkEVWwfXKpY6qws,18681
|
|
7
|
+
message_flight/notification_queue.py,sha256=hqoJaAxmLNNJnK61HawblhSu0_PaOcKwT1ON83KiD3A,2315
|
|
8
|
+
message_flight/notification_worker.py,sha256=xPEBVRiRBb9tx1ivfQbfJNtOFOzj3JxAuVbACOy40HI,3687
|
|
9
|
+
message_flight/plane_banner.py,sha256=YrdwOi5Ymjl3VPO4CxG1a7ySOLpTQ5h6aqm5p1q7F9w,14369
|
|
10
|
+
message_flight/preset_editor.py,sha256=az2JNA-LdeuXPBLE5B-MBeb2XVLqn52L53HKcIRQ-Cw,13239
|
|
11
|
+
message_flight/settings_dialog.py,sha256=WyttfOB9CnP8saV-AfRtBOT0cugy8mFZMHaTG79MBh0,10600
|
|
12
|
+
message_flight/tray_app.py,sha256=y89A5Wm0IqtlPyGpR-U62PJh3mUnDeXvDDkqX05_MsQ,11116
|
|
13
|
+
message_flight/tts.py,sha256=A5vU8A5e-bqXQbn90Lrm5jYH84ufGyhMFgoeYuXqt_Y,12248
|
|
14
|
+
message_flight/tts_manager.py,sha256=UkUJ1xLHJeQbgMPRAz16UaJAAPmxULBuD7nVV-ZzwP4,4307
|
|
15
|
+
message_flight/plane_presets/__init__.py,sha256=zQxkbsNgPTWICirpcYnqXz1Ad3D-FmyyktqWOhcaVE0,602
|
|
16
|
+
message_flight/plane_presets/airplane.py,sha256=jhfoctq3GCW9FZzKkaQI6fCmd92Une1xbVaOr7ndOXA,4287
|
|
17
|
+
message_flight/plane_presets/base.py,sha256=Lw6HeHIvwPitaG9mvQDQtzqkviTtSngPUA8hIIt5NHM,668
|
|
18
|
+
message_flight/plane_presets/bird.py,sha256=gVvAjuwUk6BpV3unr9zjevV4-GfvPUm7SuHi-4fmniA,3287
|
|
19
|
+
message_flight/plane_presets/rocket.py,sha256=HJ3PGbDjYZEUgquypx5jn9KngAsu2v8oUoMWwhpMXaI,3509
|
|
20
|
+
message_flight/plane_presets/ufo.py,sha256=cDXYpy-QnNV2vw58j85LEFfd5_UAHWtgPQwfnbJyXPk,2676
|
|
21
|
+
messageflight-0.2.4.dist-info/METADATA,sha256=sEpeAHBo7IrPFzP4Z7ayyHmdYotfzCvP9Cg4AnYn31Q,4324
|
|
22
|
+
messageflight-0.2.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
23
|
+
messageflight-0.2.4.dist-info/licenses/LICENSE,sha256=B_KZ3UmKfNSncgBrsubFqqHRhWwFIPt2gunQzzoCLg0,1104
|
|
24
|
+
messageflight-0.2.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MessageFlight Contributors
|
|
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
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|