pygpt-net 2.6.30__py3-none-any.whl → 2.6.32__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.
Files changed (122) hide show
  1. pygpt_net/CHANGELOG.txt +15 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +7 -1
  4. pygpt_net/app_core.py +3 -1
  5. pygpt_net/config.py +3 -1
  6. pygpt_net/controller/__init__.py +9 -2
  7. pygpt_net/controller/audio/audio.py +38 -1
  8. pygpt_net/controller/audio/ui.py +2 -2
  9. pygpt_net/controller/chat/audio.py +1 -8
  10. pygpt_net/controller/chat/common.py +23 -62
  11. pygpt_net/controller/chat/handler/__init__.py +0 -0
  12. pygpt_net/controller/chat/handler/stream_worker.py +1124 -0
  13. pygpt_net/controller/chat/output.py +8 -3
  14. pygpt_net/controller/chat/stream.py +3 -1071
  15. pygpt_net/controller/chat/text.py +3 -2
  16. pygpt_net/controller/kernel/kernel.py +11 -3
  17. pygpt_net/controller/kernel/reply.py +5 -1
  18. pygpt_net/controller/lang/custom.py +2 -2
  19. pygpt_net/controller/media/__init__.py +12 -0
  20. pygpt_net/controller/media/media.py +115 -0
  21. pygpt_net/controller/realtime/__init__.py +12 -0
  22. pygpt_net/controller/realtime/manager.py +53 -0
  23. pygpt_net/controller/realtime/realtime.py +293 -0
  24. pygpt_net/controller/ui/mode.py +23 -2
  25. pygpt_net/controller/ui/ui.py +19 -1
  26. pygpt_net/core/audio/audio.py +6 -1
  27. pygpt_net/core/audio/backend/native/__init__.py +12 -0
  28. pygpt_net/core/audio/backend/{native.py → native/native.py} +426 -127
  29. pygpt_net/core/audio/backend/native/player.py +139 -0
  30. pygpt_net/core/audio/backend/native/realtime.py +250 -0
  31. pygpt_net/core/audio/backend/pyaudio/__init__.py +12 -0
  32. pygpt_net/core/audio/backend/pyaudio/playback.py +194 -0
  33. pygpt_net/core/audio/backend/pyaudio/pyaudio.py +923 -0
  34. pygpt_net/core/audio/backend/pyaudio/realtime.py +312 -0
  35. pygpt_net/core/audio/backend/pygame/__init__.py +12 -0
  36. pygpt_net/core/audio/backend/{pygame.py → pygame/pygame.py} +130 -19
  37. pygpt_net/core/audio/backend/shared/__init__.py +38 -0
  38. pygpt_net/core/audio/backend/shared/conversions.py +211 -0
  39. pygpt_net/core/audio/backend/shared/envelope.py +38 -0
  40. pygpt_net/core/audio/backend/shared/player.py +137 -0
  41. pygpt_net/core/audio/backend/shared/rt.py +52 -0
  42. pygpt_net/core/audio/capture.py +5 -0
  43. pygpt_net/core/audio/output.py +14 -2
  44. pygpt_net/core/audio/whisper.py +6 -2
  45. pygpt_net/core/bridge/bridge.py +2 -1
  46. pygpt_net/core/bridge/worker.py +4 -1
  47. pygpt_net/core/dispatcher/dispatcher.py +37 -1
  48. pygpt_net/core/events/__init__.py +2 -1
  49. pygpt_net/core/events/realtime.py +55 -0
  50. pygpt_net/core/image/image.py +56 -5
  51. pygpt_net/core/realtime/__init__.py +0 -0
  52. pygpt_net/core/realtime/options.py +87 -0
  53. pygpt_net/core/realtime/shared/__init__.py +0 -0
  54. pygpt_net/core/realtime/shared/audio.py +213 -0
  55. pygpt_net/core/realtime/shared/loop.py +64 -0
  56. pygpt_net/core/realtime/shared/session.py +59 -0
  57. pygpt_net/core/realtime/shared/text.py +37 -0
  58. pygpt_net/core/realtime/shared/tools.py +276 -0
  59. pygpt_net/core/realtime/shared/turn.py +38 -0
  60. pygpt_net/core/realtime/shared/types.py +16 -0
  61. pygpt_net/core/realtime/worker.py +160 -0
  62. pygpt_net/core/render/web/body.py +24 -3
  63. pygpt_net/core/text/utils.py +54 -2
  64. pygpt_net/core/types/__init__.py +1 -0
  65. pygpt_net/core/types/image.py +54 -0
  66. pygpt_net/core/video/__init__.py +12 -0
  67. pygpt_net/core/video/video.py +290 -0
  68. pygpt_net/data/config/config.json +26 -5
  69. pygpt_net/data/config/models.json +221 -103
  70. pygpt_net/data/config/settings.json +244 -6
  71. pygpt_net/data/css/web-blocks.css +6 -0
  72. pygpt_net/data/css/web-chatgpt.css +6 -0
  73. pygpt_net/data/css/web-chatgpt_wide.css +6 -0
  74. pygpt_net/data/locale/locale.de.ini +35 -7
  75. pygpt_net/data/locale/locale.en.ini +56 -17
  76. pygpt_net/data/locale/locale.es.ini +35 -7
  77. pygpt_net/data/locale/locale.fr.ini +35 -7
  78. pygpt_net/data/locale/locale.it.ini +35 -7
  79. pygpt_net/data/locale/locale.pl.ini +38 -7
  80. pygpt_net/data/locale/locale.uk.ini +35 -7
  81. pygpt_net/data/locale/locale.zh.ini +31 -3
  82. pygpt_net/data/locale/plugin.audio_input.en.ini +4 -0
  83. pygpt_net/data/locale/plugin.audio_output.en.ini +4 -0
  84. pygpt_net/data/locale/plugin.cmd_web.en.ini +8 -0
  85. pygpt_net/item/model.py +22 -1
  86. pygpt_net/plugin/audio_input/plugin.py +37 -4
  87. pygpt_net/plugin/audio_input/simple.py +57 -8
  88. pygpt_net/plugin/cmd_files/worker.py +3 -0
  89. pygpt_net/provider/api/google/__init__.py +76 -7
  90. pygpt_net/provider/api/google/audio.py +8 -1
  91. pygpt_net/provider/api/google/chat.py +45 -6
  92. pygpt_net/provider/api/google/image.py +226 -86
  93. pygpt_net/provider/api/google/realtime/__init__.py +12 -0
  94. pygpt_net/provider/api/google/realtime/client.py +1945 -0
  95. pygpt_net/provider/api/google/realtime/realtime.py +186 -0
  96. pygpt_net/provider/api/google/video.py +364 -0
  97. pygpt_net/provider/api/openai/__init__.py +22 -2
  98. pygpt_net/provider/api/openai/realtime/__init__.py +12 -0
  99. pygpt_net/provider/api/openai/realtime/client.py +1828 -0
  100. pygpt_net/provider/api/openai/realtime/realtime.py +193 -0
  101. pygpt_net/provider/audio_input/google_genai.py +103 -0
  102. pygpt_net/provider/audio_output/google_genai_tts.py +229 -0
  103. pygpt_net/provider/audio_output/google_tts.py +0 -12
  104. pygpt_net/provider/audio_output/openai_tts.py +8 -5
  105. pygpt_net/provider/core/config/patch.py +241 -178
  106. pygpt_net/provider/core/model/patch.py +28 -2
  107. pygpt_net/provider/llms/google.py +8 -9
  108. pygpt_net/provider/web/duckduck_search.py +212 -0
  109. pygpt_net/ui/layout/toolbox/audio.py +55 -0
  110. pygpt_net/ui/layout/toolbox/footer.py +14 -42
  111. pygpt_net/ui/layout/toolbox/image.py +7 -13
  112. pygpt_net/ui/layout/toolbox/raw.py +52 -0
  113. pygpt_net/ui/layout/toolbox/split.py +48 -0
  114. pygpt_net/ui/layout/toolbox/toolbox.py +8 -8
  115. pygpt_net/ui/layout/toolbox/video.py +49 -0
  116. pygpt_net/ui/widget/option/combo.py +15 -1
  117. {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/METADATA +46 -22
  118. {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/RECORD +121 -73
  119. pygpt_net/core/audio/backend/pyaudio.py +0 -554
  120. {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/LICENSE +0 -0
  121. {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/WHEEL +0 -0
  122. {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.31 04:00:00 #
10
+ # ================================================== #
11
+
12
+ from typing import Optional, Callable
13
+
14
+ import os
15
+ from PySide6.QtCore import QObject, QTimer, QUrl
16
+ from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
17
+
18
+ from ..shared import compute_envelope_from_file
19
+
20
+ class NativePlayer(QObject):
21
+ """
22
+ Wrapper for QtMultimedia audio playback with level metering.
23
+ """
24
+ def __init__(self, window=None, chunk_ms: int = 10):
25
+ """
26
+ Initialize the NativePlayer.
27
+
28
+ :param window: Parent window
29
+ :param chunk_ms: Chunk size in milliseconds for volume envelope calculation
30
+ """
31
+ super().__init__(window)
32
+ self.window = window
33
+ self.chunk_ms = int(chunk_ms)
34
+ self.audio_output: Optional[QAudioOutput] = None
35
+ self.player: Optional[QMediaPlayer] = None
36
+ self.playback_timer: Optional[QTimer] = None
37
+ self.volume_timer: Optional[QTimer] = None
38
+ self.envelope = []
39
+
40
+ def stop_timers(self):
41
+ """Stop playback timers."""
42
+ if self.playback_timer is not None:
43
+ self.playback_timer.stop()
44
+ self.playback_timer = None
45
+ if self.volume_timer is not None:
46
+ self.volume_timer.stop()
47
+ self.volume_timer = None
48
+
49
+ def stop(self, signals=None):
50
+ """Stop playback and timers."""
51
+ if self.player is not None:
52
+ try:
53
+ self.player.stop()
54
+ except Exception:
55
+ pass
56
+ self.stop_timers()
57
+ if signals is not None:
58
+ try:
59
+ signals.volume_changed.emit(0)
60
+ except Exception:
61
+ pass
62
+
63
+ def update_volume(self, signals=None):
64
+ """
65
+ Update the volume based on the current position in the audio file.
66
+
67
+ :param signals: Signals to emit volume changes
68
+ """
69
+ if not self.player:
70
+ return
71
+ pos = self.player.position()
72
+ index = int(pos / self.chunk_ms)
73
+ volume = self.envelope[index] if index < len(self.envelope) else 0
74
+ if signals is not None:
75
+ signals.volume_changed.emit(volume)
76
+
77
+ def play_after(
78
+ self,
79
+ audio_file: str,
80
+ event_name: str,
81
+ stopped: Callable[[], bool],
82
+ signals=None,
83
+ auto_convert_to_wav: bool = False,
84
+ select_output_device: Optional[Callable[[], object]] = None,
85
+ ):
86
+ """
87
+ Start audio playback using QtMultimedia with periodic volume updates.
88
+
89
+ :param audio_file: Path to audio file
90
+ :param event_name: Event name to emit on playback start
91
+ :param stopped: Callable returning True when playback should stop
92
+ :param signals: Signals to emit on playback
93
+ :param auto_convert_to_wav: auto convert mp3 to wav if True
94
+ :param select_output_device: callable returning QAudioDevice for output
95
+ """
96
+ self.audio_output = QAudioOutput()
97
+ self.audio_output.setVolume(1.0)
98
+
99
+ if callable(select_output_device):
100
+ try:
101
+ self.audio_output.setDevice(select_output_device())
102
+ except Exception:
103
+ pass
104
+
105
+ if auto_convert_to_wav and audio_file.lower().endswith('.mp3'):
106
+ tmp_dir = self.window.core.audio.get_cache_dir()
107
+ base_name = os.path.splitext(os.path.basename(audio_file))[0]
108
+ dst_file = os.path.join(tmp_dir, "_" + base_name + ".wav")
109
+ wav_file = self.window.core.audio.mp3_to_wav(audio_file, dst_file)
110
+ if wav_file:
111
+ audio_file = wav_file
112
+
113
+ def check_stop():
114
+ if stopped():
115
+ self.stop(signals=signals)
116
+ else:
117
+ if self.player:
118
+ if self.player.playbackState() == QMediaPlayer.StoppedState:
119
+ self.stop(signals=signals)
120
+
121
+ self.envelope = compute_envelope_from_file(audio_file, chunk_ms=self.chunk_ms)
122
+ self.player = QMediaPlayer()
123
+ self.player.setAudioOutput(self.audio_output)
124
+ self.player.setSource(QUrl.fromLocalFile(audio_file))
125
+ self.player.play()
126
+
127
+ self.playback_timer = QTimer()
128
+ self.playback_timer.setInterval(100)
129
+ self.playback_timer.timeout.connect(check_stop)
130
+
131
+ self.volume_timer = QTimer(self)
132
+ self.volume_timer.setInterval(10)
133
+ self.volume_timer.timeout.connect(lambda: self.update_volume(signals))
134
+
135
+ self.playback_timer.start()
136
+ self.volume_timer.start()
137
+ if signals is not None:
138
+ signals.volume_changed.emit(0)
139
+ signals.playback.emit(event_name)
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.31 23:00:00 #
10
+ # ================================================== #
11
+
12
+ import numpy as np
13
+
14
+ from PySide6.QtCore import Qt
15
+ from PySide6.QtMultimedia import QAudioFormat, QAudioSink
16
+ from PySide6.QtCore import QTimer, QObject
17
+
18
+
19
+ class RealtimeSession(QObject):
20
+ """Global realtime session: pumps PCM bytes to QAudioSink without blocking the GUI."""
21
+ def __init__(
22
+ self,
23
+ device,
24
+ fmt: QAudioFormat,
25
+ parent=None,
26
+ volume_emitter: callable = None
27
+ ):
28
+ """
29
+ Initialize the session.
30
+
31
+ :param device: QAudioDeviceInfo
32
+ :param fmt: QAudioFormat
33
+ :param parent: QObject
34
+ :param volume_emitter: optional callable to emit volume level (0-100)
35
+ """
36
+ super().__init__(parent)
37
+ self._device = device
38
+ self.format = fmt
39
+
40
+ # NOTE: very simple sink with modest HW buffer (~300 ms) to avoid underruns
41
+ self.sink = QAudioSink(device, fmt)
42
+ hw_ms = 300
43
+ bs = int(max(fmt.sampleRate() * fmt.channelCount() * fmt.bytesPerSample() * (hw_ms / 1000.0), 8192))
44
+ self.sink.setBufferSize(bs)
45
+
46
+ self.io = self.sink.start()
47
+ if self.io is None:
48
+ raise RuntimeError("QAudioSink.start() returned None (no IO for writing)")
49
+
50
+ # user buffer and simple timing
51
+ self.buffer = bytearray()
52
+ self.final = False
53
+
54
+ # format helpers
55
+ self.frame_bytes = max(1, fmt.channelCount() * fmt.bytesPerSample())
56
+ self.bytes_per_ms = max(1, int(fmt.sampleRate() * fmt.channelCount() * fmt.bytesPerSample() / 1000))
57
+
58
+ # NOTE: keep writes reasonably sized
59
+ self.min_write_bytes = max(self.bytes_per_ms * 20, self.frame_bytes) # ~20 ms
60
+ self.max_write_bytes = max(self.bytes_per_ms * 100, self.frame_bytes) # ~100 ms
61
+
62
+ # small tail of silence to avoid end clicks
63
+ self.tail_ms = 60 # ~60 ms
64
+
65
+ # very small pump
66
+ self.timer = QTimer(self)
67
+ self.timer.setTimerType(Qt.PreciseTimer)
68
+ self.timer.setInterval(10) # ~10 ms
69
+ self.timer.timeout.connect(self._pump)
70
+ self.timer.start()
71
+
72
+ # simple volume metering (optional)
73
+ self.volume_emitter = volume_emitter
74
+ self.vol_window_bytes = max(1, self.bytes_per_ms * 100) # ~100 ms
75
+ self.vol_buffer = bytearray()
76
+ self.vol_timer = QTimer(self)
77
+ self.vol_timer.setTimerType(Qt.PreciseTimer)
78
+ self.vol_timer.setInterval(33)
79
+ self.vol_timer.timeout.connect(self._emit_volume_tick)
80
+ self.vol_timer.start()
81
+ self._sf = fmt.sampleFormat()
82
+
83
+ self.on_stopped = None # callback set by NativeBackend
84
+
85
+ def feed(self, data: bytes) -> None:
86
+ """
87
+ Feed PCM bytes (already in device format).
88
+
89
+ :param data: bytes
90
+ """
91
+ if not data or self.io is None:
92
+ return
93
+ self.buffer.extend(data)
94
+ # NOTE: try pump quickly (non-blocking)
95
+ self._pump()
96
+
97
+ def mark_final(self) -> None:
98
+ """Mark no-more-data and add small silence tail."""
99
+ if not self.final:
100
+ pad = self._align_down(self.bytes_per_ms * self.tail_ms)
101
+ if pad > 0:
102
+ self.buffer.extend(self._silence(pad))
103
+ self.final = True
104
+ self._pump()
105
+
106
+ def stop(self) -> None:
107
+ """Stop the session and clean up."""
108
+ try:
109
+ if self.timer:
110
+ self.timer.stop()
111
+ except Exception:
112
+ pass
113
+ try:
114
+ if self.vol_timer:
115
+ self.vol_timer.stop()
116
+ except Exception:
117
+ pass
118
+ try:
119
+ if self.sink:
120
+ self.sink.stop()
121
+ except Exception:
122
+ pass
123
+
124
+ # zero volume instantly
125
+ try:
126
+ self.vol_buffer.clear()
127
+ if self.volume_emitter:
128
+ self.volume_emitter(0)
129
+ except Exception:
130
+ pass
131
+
132
+ self.io = None
133
+ self.sink = None
134
+
135
+ cb = self.on_stopped
136
+ self.on_stopped = None
137
+ if cb:
138
+ try:
139
+ cb()
140
+ except Exception:
141
+ pass
142
+
143
+ self.deleteLater()
144
+
145
+ def _pump(self) -> None:
146
+ """Write as much as device can take right now."""
147
+ if self.io is None:
148
+ return
149
+
150
+ free = int(self.sink.bytesFree()) if self.sink else 0
151
+ if free <= 0 and not self.buffer:
152
+ return
153
+
154
+ to_write = min(len(self.buffer), free, self.max_write_bytes)
155
+ to_write = self._align_down(to_write)
156
+
157
+ # avoid tiny writes unless finishing
158
+ if not self.final and 0 < to_write < self.min_write_bytes:
159
+ return
160
+
161
+ if to_write > 0:
162
+ chunk = bytes(self.buffer[:to_write])
163
+ written = self.io.write(chunk)
164
+ if written and written > 0:
165
+ del self.buffer[:written]
166
+ # simple volume window
167
+ self._vol_push(chunk[:self._align_down(written)])
168
+
169
+ # stop when: final AND our buffer empty AND device queue empty
170
+ if self.final and not self.buffer:
171
+ pend = 0
172
+ try:
173
+ pend = int(self.sink.bufferSize()) - int(self.sink.bytesFree())
174
+ except Exception:
175
+ pass
176
+ if pend <= 0:
177
+ self.stop()
178
+
179
+ def _align_down(self, n: int) -> int:
180
+ """
181
+ Align to frame boundary.
182
+
183
+ :param n: number of bytes
184
+ :return: aligned number of bytes
185
+ """
186
+ if self.frame_bytes <= 1:
187
+ return n
188
+ rem = n % self.frame_bytes
189
+ return n - rem
190
+
191
+ def _silence(self, n: int) -> bytes:
192
+ """
193
+ Generate n bytes of silence.
194
+ NOTE: for Int16 silence is all zeros; for UInt8 it is 0x80.
195
+
196
+ :param n: number of bytes
197
+ :return: bytes of silence
198
+ """
199
+ if n <= 0:
200
+ return b""
201
+ sf = self.format.sampleFormat()
202
+ if sf == QAudioFormat.SampleFormat.UInt8:
203
+ return bytes([128]) * n
204
+ return b"\x00" * n
205
+
206
+ def _vol_push(self, chunk: bytes) -> None:
207
+ """
208
+ Push chunk to volume buffer and trim if needed.
209
+
210
+ :param chunk: bytes to push to volume buffer
211
+ """
212
+ if not chunk:
213
+ return
214
+ self.vol_buffer.extend(chunk)
215
+ if len(self.vol_buffer) > self.vol_window_bytes:
216
+ del self.vol_buffer[:len(self.vol_buffer) - self.vol_window_bytes]
217
+
218
+ def _emit_volume_tick(self) -> None:
219
+ """Emit volume level (0-100) based on current vol_buffer content."""
220
+ if self.volume_emitter is None:
221
+ return
222
+ try:
223
+ if not self.vol_buffer:
224
+ self.volume_emitter(0)
225
+ return
226
+
227
+ sf = self._sf
228
+ if sf == QAudioFormat.SampleFormat.UInt8:
229
+ arr = np.frombuffer(self.vol_buffer, dtype=np.uint8).astype(np.int16)
230
+ arr = (arr - 128).astype(np.float32) / 128.0
231
+ elif sf == QAudioFormat.SampleFormat.Int16:
232
+ arr = np.frombuffer(self.vol_buffer, dtype=np.int16).astype(np.float32) / 32768.0
233
+ elif sf == QAudioFormat.SampleFormat.Int32:
234
+ arr = np.frombuffer(self.vol_buffer, dtype=np.int32).astype(np.float32) / 2147483648.0
235
+ elif sf == QAudioFormat.SampleFormat.Float:
236
+ arr = np.frombuffer(self.vol_buffer, dtype=np.float32).astype(np.float32)
237
+ else:
238
+ arr = np.frombuffer(self.vol_buffer, dtype=np.int16).astype(np.float32) / 32768.0
239
+
240
+ if arr.size == 0:
241
+ self.volume_emitter(0)
242
+ return
243
+
244
+ rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2)))
245
+ db = -60.0 if rms <= 1e-9 else 20.0 * float(np.log10(min(1.0, rms)))
246
+ db = max(-60.0, min(0.0, db))
247
+ volume = int(((db + 60.0) / 60.0) * 100.0)
248
+ self.volume_emitter(volume)
249
+ except Exception:
250
+ pass
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.31 23:00:00 #
10
+ # ================================================== #
11
+
12
+ from .pyaudio import PyaudioBackend
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.08.31 04:00:00 #
10
+ # ================================================== #
11
+
12
+ import io
13
+ import threading
14
+ import wave as _wave
15
+
16
+ from PySide6.QtCore import QTimer
17
+
18
+ class _FilePlaybackThread(threading.Thread):
19
+ """
20
+ File playback worker that owns its PyAudio instance and stream.
21
+ All creation and teardown happen inside this thread to avoid cross-thread closes.
22
+ """
23
+ def __init__(self, device_index: int, audio_file: str, signals):
24
+ """
25
+ Initialize the playback thread.
26
+
27
+ :param device_index: output device index
28
+ :param audio_file: path to audio file
29
+ :param signals: signals object to emit volume changes
30
+ """
31
+ super().__init__(daemon=True)
32
+ self.device_index = int(device_index)
33
+ self.audio_file = audio_file
34
+ self.signals = signals
35
+ self._stop_evt = threading.Event()
36
+
37
+ def request_stop(self):
38
+ """Ask the worker to stop gracefully."""
39
+ self._stop_evt.set()
40
+
41
+ def _emit_main(self, fn, *args):
42
+ """
43
+ Emit via Qt main thread.
44
+
45
+ :param fn: function to call
46
+ :param args: arguments to pass
47
+ """
48
+ try:
49
+ QTimer.singleShot(0, lambda: fn(*args))
50
+ except Exception:
51
+ pass
52
+
53
+ def run(self):
54
+ """Thread entry point: play the audio file and emit volume changes."""
55
+ import pyaudio
56
+ import numpy as np
57
+ from pydub import AudioSegment
58
+
59
+ pa = None
60
+ wf = None
61
+ stream = None
62
+ try:
63
+ # prepare WAV in memory (normalize rate to 44100 to be safe)
64
+ audio = AudioSegment.from_file(self.audio_file)
65
+ audio = audio.set_frame_rate(44100)
66
+ wav_io = io.BytesIO()
67
+ audio.export(wav_io, format='wav')
68
+ wav_io.seek(0)
69
+ wf = _wave.open(wav_io, 'rb')
70
+
71
+ pa = pyaudio.PyAudio()
72
+
73
+ def _try_open(idx: int):
74
+ s = pa.open(
75
+ format=pa.get_format_from_width(wf.getsampwidth()),
76
+ channels=wf.getnchannels(),
77
+ rate=wf.getframerate(),
78
+ output=True,
79
+ output_device_index=idx,
80
+ frames_per_buffer=1024,
81
+ )
82
+ try:
83
+ s.start_stream()
84
+ except Exception:
85
+ pass
86
+ return s
87
+
88
+ # open output; if the specific device fails, try fallbacks
89
+ try:
90
+ stream = _try_open(self.device_index)
91
+ except Exception:
92
+ # try default
93
+ try:
94
+ di = pa.get_default_output_device_info()
95
+ stream = _try_open(int(di.get('index')))
96
+ except Exception:
97
+ # scan first output-capable
98
+ for i in range(pa.get_device_count()):
99
+ try:
100
+ di = pa.get_device_info_by_index(i)
101
+ if di.get('maxOutputChannels', 0) > 0:
102
+ stream = _try_open(i)
103
+ break
104
+ except Exception:
105
+ continue
106
+
107
+ if stream is None:
108
+ return # no device available
109
+
110
+ # dtype for meter
111
+ sw = wf.getsampwidth()
112
+ if sw == 1:
113
+ dtype = np.uint8
114
+ max_value = 255.0
115
+ offset = 128.0
116
+ is_u8 = True
117
+ elif sw == 2:
118
+ dtype = np.int16
119
+ max_value = 32767.0
120
+ offset = 0.0
121
+ is_u8 = False
122
+ elif sw == 4:
123
+ dtype = np.int32
124
+ max_value = 2147483647.0
125
+ offset = 0.0
126
+ is_u8 = False
127
+ else:
128
+ dtype = np.int16
129
+ max_value = 32767.0
130
+ offset = 0.0
131
+ is_u8 = False
132
+
133
+ chunk = 1024
134
+ data = wf.readframes(chunk)
135
+
136
+ while data and not self._stop_evt.is_set():
137
+ try:
138
+ stream.write(data)
139
+ except Exception:
140
+ break
141
+
142
+ # volume meter (emit on GUI thread)
143
+ if self.signals is not None:
144
+ try:
145
+ arr = np.frombuffer(data, dtype=dtype).astype(np.float32)
146
+ if arr.size > 0:
147
+ if is_u8:
148
+ arr -= offset
149
+ denom = 127.0
150
+ else:
151
+ denom = max_value
152
+ rms = float(np.sqrt(np.mean(arr * arr)))
153
+ if denom > 0.0 and rms > 0.0:
154
+ db = 20.0 * float(np.log10(max(1e-12, rms / denom)))
155
+ db = max(-60.0, min(0.0, db))
156
+ vol = int(((db + 60.0) / 60.0) * 100.0)
157
+ else:
158
+ vol = 0
159
+ else:
160
+ vol = 0
161
+ self._emit_main(self.signals.volume_changed.emit, vol)
162
+ except Exception:
163
+ pass
164
+
165
+ data = wf.readframes(chunk)
166
+
167
+ finally:
168
+ # teardown in the SAME thread
169
+ try:
170
+ if stream is not None:
171
+ try:
172
+ if stream.is_active():
173
+ stream.stop_stream()
174
+ except Exception:
175
+ pass
176
+ stream.close()
177
+ except Exception:
178
+ pass
179
+ try:
180
+ if pa is not None:
181
+ pa.terminate()
182
+ except Exception:
183
+ pass
184
+ try:
185
+ if wf is not None:
186
+ wf.close()
187
+ except Exception:
188
+ pass
189
+
190
+ if self.signals is not None:
191
+ try:
192
+ self._emit_main(self.signals.volume_changed.emit, 0)
193
+ except Exception:
194
+ pass