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.
- pygpt_net/CHANGELOG.txt +15 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +7 -1
- pygpt_net/app_core.py +3 -1
- pygpt_net/config.py +3 -1
- pygpt_net/controller/__init__.py +9 -2
- pygpt_net/controller/audio/audio.py +38 -1
- pygpt_net/controller/audio/ui.py +2 -2
- pygpt_net/controller/chat/audio.py +1 -8
- pygpt_net/controller/chat/common.py +23 -62
- pygpt_net/controller/chat/handler/__init__.py +0 -0
- pygpt_net/controller/chat/handler/stream_worker.py +1124 -0
- pygpt_net/controller/chat/output.py +8 -3
- pygpt_net/controller/chat/stream.py +3 -1071
- pygpt_net/controller/chat/text.py +3 -2
- pygpt_net/controller/kernel/kernel.py +11 -3
- pygpt_net/controller/kernel/reply.py +5 -1
- pygpt_net/controller/lang/custom.py +2 -2
- pygpt_net/controller/media/__init__.py +12 -0
- pygpt_net/controller/media/media.py +115 -0
- pygpt_net/controller/realtime/__init__.py +12 -0
- pygpt_net/controller/realtime/manager.py +53 -0
- pygpt_net/controller/realtime/realtime.py +293 -0
- pygpt_net/controller/ui/mode.py +23 -2
- pygpt_net/controller/ui/ui.py +19 -1
- pygpt_net/core/audio/audio.py +6 -1
- pygpt_net/core/audio/backend/native/__init__.py +12 -0
- pygpt_net/core/audio/backend/{native.py → native/native.py} +426 -127
- pygpt_net/core/audio/backend/native/player.py +139 -0
- pygpt_net/core/audio/backend/native/realtime.py +250 -0
- pygpt_net/core/audio/backend/pyaudio/__init__.py +12 -0
- pygpt_net/core/audio/backend/pyaudio/playback.py +194 -0
- pygpt_net/core/audio/backend/pyaudio/pyaudio.py +923 -0
- pygpt_net/core/audio/backend/pyaudio/realtime.py +312 -0
- pygpt_net/core/audio/backend/pygame/__init__.py +12 -0
- pygpt_net/core/audio/backend/{pygame.py → pygame/pygame.py} +130 -19
- pygpt_net/core/audio/backend/shared/__init__.py +38 -0
- pygpt_net/core/audio/backend/shared/conversions.py +211 -0
- pygpt_net/core/audio/backend/shared/envelope.py +38 -0
- pygpt_net/core/audio/backend/shared/player.py +137 -0
- pygpt_net/core/audio/backend/shared/rt.py +52 -0
- pygpt_net/core/audio/capture.py +5 -0
- pygpt_net/core/audio/output.py +14 -2
- pygpt_net/core/audio/whisper.py +6 -2
- pygpt_net/core/bridge/bridge.py +2 -1
- pygpt_net/core/bridge/worker.py +4 -1
- pygpt_net/core/dispatcher/dispatcher.py +37 -1
- pygpt_net/core/events/__init__.py +2 -1
- pygpt_net/core/events/realtime.py +55 -0
- pygpt_net/core/image/image.py +56 -5
- pygpt_net/core/realtime/__init__.py +0 -0
- pygpt_net/core/realtime/options.py +87 -0
- pygpt_net/core/realtime/shared/__init__.py +0 -0
- pygpt_net/core/realtime/shared/audio.py +213 -0
- pygpt_net/core/realtime/shared/loop.py +64 -0
- pygpt_net/core/realtime/shared/session.py +59 -0
- pygpt_net/core/realtime/shared/text.py +37 -0
- pygpt_net/core/realtime/shared/tools.py +276 -0
- pygpt_net/core/realtime/shared/turn.py +38 -0
- pygpt_net/core/realtime/shared/types.py +16 -0
- pygpt_net/core/realtime/worker.py +160 -0
- pygpt_net/core/render/web/body.py +24 -3
- pygpt_net/core/text/utils.py +54 -2
- pygpt_net/core/types/__init__.py +1 -0
- pygpt_net/core/types/image.py +54 -0
- pygpt_net/core/video/__init__.py +12 -0
- pygpt_net/core/video/video.py +290 -0
- pygpt_net/data/config/config.json +26 -5
- pygpt_net/data/config/models.json +221 -103
- pygpt_net/data/config/settings.json +244 -6
- pygpt_net/data/css/web-blocks.css +6 -0
- pygpt_net/data/css/web-chatgpt.css +6 -0
- pygpt_net/data/css/web-chatgpt_wide.css +6 -0
- pygpt_net/data/locale/locale.de.ini +35 -7
- pygpt_net/data/locale/locale.en.ini +56 -17
- pygpt_net/data/locale/locale.es.ini +35 -7
- pygpt_net/data/locale/locale.fr.ini +35 -7
- pygpt_net/data/locale/locale.it.ini +35 -7
- pygpt_net/data/locale/locale.pl.ini +38 -7
- pygpt_net/data/locale/locale.uk.ini +35 -7
- pygpt_net/data/locale/locale.zh.ini +31 -3
- pygpt_net/data/locale/plugin.audio_input.en.ini +4 -0
- pygpt_net/data/locale/plugin.audio_output.en.ini +4 -0
- pygpt_net/data/locale/plugin.cmd_web.en.ini +8 -0
- pygpt_net/item/model.py +22 -1
- pygpt_net/plugin/audio_input/plugin.py +37 -4
- pygpt_net/plugin/audio_input/simple.py +57 -8
- pygpt_net/plugin/cmd_files/worker.py +3 -0
- pygpt_net/provider/api/google/__init__.py +76 -7
- pygpt_net/provider/api/google/audio.py +8 -1
- pygpt_net/provider/api/google/chat.py +45 -6
- pygpt_net/provider/api/google/image.py +226 -86
- pygpt_net/provider/api/google/realtime/__init__.py +12 -0
- pygpt_net/provider/api/google/realtime/client.py +1945 -0
- pygpt_net/provider/api/google/realtime/realtime.py +186 -0
- pygpt_net/provider/api/google/video.py +364 -0
- pygpt_net/provider/api/openai/__init__.py +22 -2
- pygpt_net/provider/api/openai/realtime/__init__.py +12 -0
- pygpt_net/provider/api/openai/realtime/client.py +1828 -0
- pygpt_net/provider/api/openai/realtime/realtime.py +193 -0
- pygpt_net/provider/audio_input/google_genai.py +103 -0
- pygpt_net/provider/audio_output/google_genai_tts.py +229 -0
- pygpt_net/provider/audio_output/google_tts.py +0 -12
- pygpt_net/provider/audio_output/openai_tts.py +8 -5
- pygpt_net/provider/core/config/patch.py +241 -178
- pygpt_net/provider/core/model/patch.py +28 -2
- pygpt_net/provider/llms/google.py +8 -9
- pygpt_net/provider/web/duckduck_search.py +212 -0
- pygpt_net/ui/layout/toolbox/audio.py +55 -0
- pygpt_net/ui/layout/toolbox/footer.py +14 -42
- pygpt_net/ui/layout/toolbox/image.py +7 -13
- pygpt_net/ui/layout/toolbox/raw.py +52 -0
- pygpt_net/ui/layout/toolbox/split.py +48 -0
- pygpt_net/ui/layout/toolbox/toolbox.py +8 -8
- pygpt_net/ui/layout/toolbox/video.py +49 -0
- pygpt_net/ui/widget/option/combo.py +15 -1
- {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/METADATA +46 -22
- {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/RECORD +121 -73
- pygpt_net/core/audio/backend/pyaudio.py +0 -554
- {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.30.dist-info → pygpt_net-2.6.32.dist-info}/WHEEL +0 -0
- {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
|