shadow-clerk 0.2.0__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.
- shadow_clerk/__init__.py +46 -0
- shadow_clerk/_daemon_audio.py +477 -0
- shadow_clerk/_daemon_config.py +58 -0
- shadow_clerk/_daemon_constants.py +187 -0
- shadow_clerk/_daemon_dashboard.py +5 -0
- shadow_clerk/_daemon_dashboard_base.py +358 -0
- shadow_clerk/_daemon_dashboard_css.py +253 -0
- shadow_clerk/_daemon_dashboard_handler.py +8 -0
- shadow_clerk/_daemon_dashboard_html.py +298 -0
- shadow_clerk/_daemon_dashboard_js.py +6 -0
- shadow_clerk/_daemon_dashboard_js_core.py +558 -0
- shadow_clerk/_daemon_dashboard_js_panels.py +589 -0
- shadow_clerk/_daemon_dashboard_ops.py +1001 -0
- shadow_clerk/_daemon_log_buffer.py +195 -0
- shadow_clerk/_daemon_main.py +206 -0
- shadow_clerk/_daemon_recorder.py +9 -0
- shadow_clerk/_daemon_recorder_capture.py +335 -0
- shadow_clerk/_daemon_recorder_command.py +581 -0
- shadow_clerk/_daemon_recorder_transcribe.py +725 -0
- shadow_clerk/_daemon_transcriber.py +208 -0
- shadow_clerk/_daemon_vad.py +99 -0
- shadow_clerk/_llm_config.py +151 -0
- shadow_clerk/_llm_glossary.py +260 -0
- shadow_clerk/_llm_summarize.py +359 -0
- shadow_clerk/_llm_translate.py +362 -0
- shadow_clerk/_transcript_name.py +198 -0
- shadow_clerk/clerk_daemon.py +25 -0
- shadow_clerk/clerk_util.py +510 -0
- shadow_clerk/domain/__init__.py +18 -0
- shadow_clerk/domain/language.py +34 -0
- shadow_clerk/domain/meeting_session.py +48 -0
- shadow_clerk/domain/speaker.py +24 -0
- shadow_clerk/domain/summary.py +24 -0
- shadow_clerk/domain/transcript_line.py +55 -0
- shadow_clerk/domain/translation.py +24 -0
- shadow_clerk/gcal_monitor.py +318 -0
- shadow_clerk/i18n.py +928 -0
- shadow_clerk/llm_client.py +234 -0
- shadow_clerk-0.2.0.dist-info/METADATA +650 -0
- shadow_clerk-0.2.0.dist-info/RECORD +44 -0
- shadow_clerk-0.2.0.dist-info/WHEEL +5 -0
- shadow_clerk-0.2.0.dist-info/entry_points.txt +3 -0
- shadow_clerk-0.2.0.dist-info/licenses/LICENSE +21 -0
- shadow_clerk-0.2.0.dist-info/top_level.txt +1 -0
shadow_clerk/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""shadow-clerk: Web会議 議事録アシスタント"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_microsoft_store_python() -> bool:
|
|
11
|
+
"""Microsoft Store 版 Python(AppContainer サンドボックスあり)で動作中か判定。"""
|
|
12
|
+
if sys.platform != "win32":
|
|
13
|
+
return False
|
|
14
|
+
exe = (sys.executable or "").replace("\\", "/").lower()
|
|
15
|
+
return (
|
|
16
|
+
"windowsapps/pythonsoftwarefoundation" in exe
|
|
17
|
+
or "/packages/pythonsoftwarefoundation" in exe
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_data_dir() -> str:
|
|
22
|
+
"""データディレクトリのパスを返す。
|
|
23
|
+
|
|
24
|
+
SHADOW_CLERK_DATA_DIR 環境変数で上書き可能。
|
|
25
|
+
デフォルト:
|
|
26
|
+
- Windows: %APPDATA%\\shadow-clerk
|
|
27
|
+
- Linux/その他: ~/.local/share/shadow-clerk
|
|
28
|
+
|
|
29
|
+
注意: Microsoft Store 版 Python では %APPDATA% が AppContainer
|
|
30
|
+
サンドボックス(`%LOCALAPPDATA%\\Packages\\<pkg-id>\\LocalCache\\Roaming\\`)
|
|
31
|
+
にリダイレクトされるため、Python マイナーバージョンが変わるとデータが
|
|
32
|
+
別パスに移ることになる。uv 管理 Python(`uv python install`)か
|
|
33
|
+
python.org 版 Python を推奨。
|
|
34
|
+
"""
|
|
35
|
+
if env := os.environ.get("SHADOW_CLERK_DATA_DIR"):
|
|
36
|
+
return env
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
appdata = os.environ.get("APPDATA")
|
|
39
|
+
if appdata:
|
|
40
|
+
return os.path.join(appdata, "shadow-clerk")
|
|
41
|
+
return os.path.expanduser("~/AppData/Roaming/shadow-clerk")
|
|
42
|
+
return os.path.expanduser("~/.local/share/shadow-clerk")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DATA_DIR = get_data_dir()
|
|
46
|
+
CONFIG_FILE = os.path.join(DATA_DIR, "config.yaml")
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Shadow-clerk daemon: 音声バックエンド"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any
|
|
10
|
+
from shadow_clerk.i18n import t
|
|
11
|
+
from shadow_clerk._daemon_constants import SAMPLE_RATE, CHANNELS, FRAME_SIZE
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("shadow-clerk")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AudioBackend:
|
|
17
|
+
"""音声バックエンド基底クラス"""
|
|
18
|
+
|
|
19
|
+
def detect_monitor_source(self) -> str | None:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def list_devices(self) -> None:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PipeWireBackend(AudioBackend):
|
|
27
|
+
"""PipeWire バックエンド"""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def is_available() -> bool:
|
|
31
|
+
return shutil.which("pw-record") is not None
|
|
32
|
+
|
|
33
|
+
def detect_monitor_source(self) -> str | None:
|
|
34
|
+
# wpctl でデフォルト Sink のノード ID を取得
|
|
35
|
+
if shutil.which("wpctl"):
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"],
|
|
39
|
+
capture_output=True, text=True, timeout=5,
|
|
40
|
+
)
|
|
41
|
+
# 1行目: "id 74, type PipeWire:Interface:Node"
|
|
42
|
+
first = result.stdout.split("\n", 1)[0]
|
|
43
|
+
if first.startswith("id "):
|
|
44
|
+
node_id = first.split(",")[0].split()[1]
|
|
45
|
+
logger.info("PipeWire デフォルト Sink ノード ID: %s (wpctl)", node_id)
|
|
46
|
+
return node_id
|
|
47
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, IndexError, ValueError):
|
|
48
|
+
pass
|
|
49
|
+
# wpctl でノード ID が取れなかった場合は pw-record では使えないため None を返す。
|
|
50
|
+
# 呼び出し側が PulseAudio バックエンドへフォールバックする。
|
|
51
|
+
logger.debug("PipeWire: wpctl からノード ID を取得できませんでした。PulseAudio にフォールバックします。")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def list_devices(self) -> None:
|
|
55
|
+
print(t("rec.pipewire_devices"))
|
|
56
|
+
if shutil.which("wpctl"):
|
|
57
|
+
try:
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["wpctl", "status"],
|
|
60
|
+
capture_output=True, text=True, timeout=5,
|
|
61
|
+
)
|
|
62
|
+
if result.stdout.strip():
|
|
63
|
+
print(result.stdout)
|
|
64
|
+
else:
|
|
65
|
+
print(t("rec.no_devices"))
|
|
66
|
+
return
|
|
67
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
68
|
+
pass
|
|
69
|
+
if shutil.which("pactl"):
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
["pactl", "list", "short", "sinks"],
|
|
73
|
+
capture_output=True, text=True, timeout=5,
|
|
74
|
+
)
|
|
75
|
+
if result.stdout.strip():
|
|
76
|
+
print(result.stdout)
|
|
77
|
+
else:
|
|
78
|
+
print(t("rec.no_devices"))
|
|
79
|
+
return
|
|
80
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
81
|
+
pass
|
|
82
|
+
print(t("rec.pw_unavailable"))
|
|
83
|
+
|
|
84
|
+
def start_monitor_capture(self, target: str, audio_queue: queue.Queue,
|
|
85
|
+
stop_event: threading.Event) -> None:
|
|
86
|
+
"""pw-record でモニターソースをキャプチャ"""
|
|
87
|
+
cmd = [
|
|
88
|
+
"pw-record", "--target", target,
|
|
89
|
+
"--rate", str(SAMPLE_RATE),
|
|
90
|
+
"--channels", str(CHANNELS),
|
|
91
|
+
"--format", "s16",
|
|
92
|
+
"-",
|
|
93
|
+
]
|
|
94
|
+
logger.info("PipeWire monitor capture: %s", " ".join(cmd))
|
|
95
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
96
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
97
|
+
try:
|
|
98
|
+
while not stop_event.is_set():
|
|
99
|
+
data = proc.stdout.read(FRAME_SIZE * 2)
|
|
100
|
+
if not data:
|
|
101
|
+
break
|
|
102
|
+
if len(data) == FRAME_SIZE * 2:
|
|
103
|
+
import numpy as np
|
|
104
|
+
samples = np.frombuffer(data, dtype=np.int16)
|
|
105
|
+
audio_queue.put(samples)
|
|
106
|
+
finally:
|
|
107
|
+
proc.terminate()
|
|
108
|
+
proc.wait()
|
|
109
|
+
err = proc.stderr.read()
|
|
110
|
+
if err:
|
|
111
|
+
logger.warning("pw-record stderr: %s", err.decode("utf-8", errors="replace").strip())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PulseAudioBackend(AudioBackend):
|
|
115
|
+
"""PulseAudio バックエンド"""
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def is_available() -> bool:
|
|
119
|
+
return shutil.which("pactl") is not None
|
|
120
|
+
|
|
121
|
+
def detect_monitor_source(self) -> str | None:
|
|
122
|
+
try:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["pactl", "list", "short", "sources"],
|
|
125
|
+
capture_output=True, text=True, timeout=5,
|
|
126
|
+
)
|
|
127
|
+
for line in result.stdout.splitlines():
|
|
128
|
+
if ".monitor" in line:
|
|
129
|
+
parts = line.split("\t")
|
|
130
|
+
if len(parts) >= 2:
|
|
131
|
+
return parts[1]
|
|
132
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
133
|
+
pass
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def list_devices(self) -> None:
|
|
137
|
+
print(t("rec.pulseaudio_sources"))
|
|
138
|
+
try:
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["pactl", "list", "short", "sources"],
|
|
141
|
+
capture_output=True, text=True, timeout=5,
|
|
142
|
+
)
|
|
143
|
+
if result.stdout.strip():
|
|
144
|
+
print(result.stdout)
|
|
145
|
+
else:
|
|
146
|
+
print(t("rec.no_sources"))
|
|
147
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
148
|
+
print(t("rec.pa_unavailable"))
|
|
149
|
+
|
|
150
|
+
def start_monitor_capture(self, source: str, audio_queue: queue.Queue,
|
|
151
|
+
stop_event: threading.Event) -> None:
|
|
152
|
+
"""parec でモニターソースをキャプチャ"""
|
|
153
|
+
cmd = [
|
|
154
|
+
"parec",
|
|
155
|
+
f"--device={source}",
|
|
156
|
+
f"--rate={SAMPLE_RATE}",
|
|
157
|
+
"--channels=1",
|
|
158
|
+
"--format=s16le",
|
|
159
|
+
]
|
|
160
|
+
logger.info("PulseAudio monitor capture: %s", " ".join(cmd))
|
|
161
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
162
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
163
|
+
try:
|
|
164
|
+
while not stop_event.is_set():
|
|
165
|
+
data = proc.stdout.read(FRAME_SIZE * 2)
|
|
166
|
+
if not data:
|
|
167
|
+
break
|
|
168
|
+
if len(data) == FRAME_SIZE * 2:
|
|
169
|
+
import numpy as np
|
|
170
|
+
samples = np.frombuffer(data, dtype=np.int16)
|
|
171
|
+
audio_queue.put(samples)
|
|
172
|
+
finally:
|
|
173
|
+
proc.terminate()
|
|
174
|
+
proc.wait()
|
|
175
|
+
err = proc.stderr.read()
|
|
176
|
+
if err:
|
|
177
|
+
logger.warning("parec stderr: %s", err.decode("utf-8", errors="replace").strip())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_backend(preferred: str = "auto") -> tuple[str, AudioBackend | None]:
|
|
181
|
+
"""音声バックエンドを検出"""
|
|
182
|
+
if preferred == "pipewire":
|
|
183
|
+
if PipeWireBackend.is_available():
|
|
184
|
+
return "pipewire", PipeWireBackend()
|
|
185
|
+
logger.warning("PipeWire が利用できません、sounddevice にフォールバック")
|
|
186
|
+
return "sounddevice", None
|
|
187
|
+
|
|
188
|
+
if preferred == "pulseaudio":
|
|
189
|
+
if PulseAudioBackend.is_available():
|
|
190
|
+
return "pulseaudio", PulseAudioBackend()
|
|
191
|
+
logger.warning("PulseAudio が利用できません、sounddevice にフォールバック")
|
|
192
|
+
return "sounddevice", None
|
|
193
|
+
|
|
194
|
+
if preferred == "wasapi":
|
|
195
|
+
if WasapiBackend.is_available():
|
|
196
|
+
return "wasapi", WasapiBackend()
|
|
197
|
+
logger.warning("PyAudioWPatch が利用できません、sounddevice にフォールバック")
|
|
198
|
+
return "sounddevice", None
|
|
199
|
+
|
|
200
|
+
if preferred == "sounddevice":
|
|
201
|
+
return "sounddevice", None
|
|
202
|
+
|
|
203
|
+
# auto: Windows → WasapiBackend / Linux → PipeWire → PulseAudio → sounddevice
|
|
204
|
+
if sys.platform == "win32":
|
|
205
|
+
if WasapiBackend.is_available():
|
|
206
|
+
return "wasapi", WasapiBackend()
|
|
207
|
+
return "sounddevice", None
|
|
208
|
+
if PipeWireBackend.is_available():
|
|
209
|
+
return "pipewire", PipeWireBackend()
|
|
210
|
+
if PulseAudioBackend.is_available():
|
|
211
|
+
return "pulseaudio", PulseAudioBackend()
|
|
212
|
+
return "sounddevice", None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _get_default_sink_name() -> str | None:
|
|
216
|
+
"""wpctl/pactl でデフォルト Sink の名前を取得"""
|
|
217
|
+
# wpctl (PipeWire)
|
|
218
|
+
if shutil.which("wpctl"):
|
|
219
|
+
try:
|
|
220
|
+
result = subprocess.run(
|
|
221
|
+
["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"],
|
|
222
|
+
capture_output=True, text=True, timeout=5,
|
|
223
|
+
)
|
|
224
|
+
for line in result.stdout.splitlines():
|
|
225
|
+
line = line.strip().lstrip("* ")
|
|
226
|
+
if line.startswith("node.name"):
|
|
227
|
+
# node.name = "alsa_output.usb-Shokz..."
|
|
228
|
+
parts = line.split("=", 1)
|
|
229
|
+
if len(parts) == 2:
|
|
230
|
+
name = parts[1].strip().strip('"')
|
|
231
|
+
logger.debug("デフォルト Sink (wpctl): %s", name)
|
|
232
|
+
return name
|
|
233
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# pactl (PulseAudio)
|
|
237
|
+
if shutil.which("pactl"):
|
|
238
|
+
try:
|
|
239
|
+
result = subprocess.run(
|
|
240
|
+
["pactl", "get-default-sink"],
|
|
241
|
+
capture_output=True, text=True, timeout=5,
|
|
242
|
+
)
|
|
243
|
+
name = result.stdout.strip()
|
|
244
|
+
if name:
|
|
245
|
+
logger.debug("デフォルト Sink (pactl): %s", name)
|
|
246
|
+
return name
|
|
247
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_rdp_audio(name: str) -> bool:
|
|
254
|
+
"""RDP の virtual audio device か判定。loopback 候補から除外する。"""
|
|
255
|
+
if not name:
|
|
256
|
+
return False
|
|
257
|
+
n = name.lower()
|
|
258
|
+
return (
|
|
259
|
+
"リモート オーディオ" in name # ja (full-width space)
|
|
260
|
+
or "リモート デスクトップ" in name # ja (RDP redirected device)
|
|
261
|
+
or "remote audio" in n # en
|
|
262
|
+
or "remote desktop" in n # en
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class WasapiBackend(AudioBackend):
|
|
267
|
+
"""Windows WASAPI ループバックバックエンド (PyAudioWPatch)"""
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def is_available() -> bool:
|
|
271
|
+
if sys.platform != "win32":
|
|
272
|
+
return False
|
|
273
|
+
try:
|
|
274
|
+
import pyaudiowpatch as pyaudio
|
|
275
|
+
except ImportError as e:
|
|
276
|
+
logger.warning("pyaudiowpatch のインポート失敗: %s", e)
|
|
277
|
+
return False
|
|
278
|
+
if not hasattr(pyaudio, "paWASAPI"):
|
|
279
|
+
logger.warning("pyaudiowpatch に paWASAPI が無い")
|
|
280
|
+
return False
|
|
281
|
+
if not hasattr(pyaudio.PyAudio, "get_loopback_device_info_generator"):
|
|
282
|
+
logger.warning("pyaudiowpatch に get_loopback_device_info_generator が無い")
|
|
283
|
+
return False
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _find_loopback_info(p: Any, prefer_name: str = "") -> dict | None:
|
|
288
|
+
"""RDP 以外の WASAPI loopback デバイス情報を返す。
|
|
289
|
+
|
|
290
|
+
prefer_name が指定されていれば部分一致で優先する。
|
|
291
|
+
指定がなければ既定の出力デバイスに対応する loopback を優先する。
|
|
292
|
+
"""
|
|
293
|
+
import pyaudiowpatch as pyaudio
|
|
294
|
+
try:
|
|
295
|
+
host_info = p.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
296
|
+
except OSError:
|
|
297
|
+
return None
|
|
298
|
+
default_idx = host_info.get("defaultOutputDevice")
|
|
299
|
+
default_name = ""
|
|
300
|
+
if default_idx is not None and default_idx >= 0:
|
|
301
|
+
try:
|
|
302
|
+
default_name = p.get_device_info_by_index(default_idx)["name"]
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
if _is_rdp_audio(default_name):
|
|
306
|
+
logger.info("既定の WASAPI 出力 (%s) は RDP デバイス、別を探す",
|
|
307
|
+
default_name)
|
|
308
|
+
default_name = ""
|
|
309
|
+
|
|
310
|
+
target = None
|
|
311
|
+
fallback = None
|
|
312
|
+
for info in p.get_loopback_device_info_generator():
|
|
313
|
+
name = info["name"]
|
|
314
|
+
if _is_rdp_audio(name):
|
|
315
|
+
logger.debug("RDP デバイススキップ: %s", name)
|
|
316
|
+
continue
|
|
317
|
+
if prefer_name and prefer_name in name:
|
|
318
|
+
return info
|
|
319
|
+
if default_name and (default_name in name or name in default_name):
|
|
320
|
+
if target is None:
|
|
321
|
+
target = info
|
|
322
|
+
if fallback is None:
|
|
323
|
+
fallback = info
|
|
324
|
+
return target or fallback
|
|
325
|
+
|
|
326
|
+
def detect_monitor_source(self) -> str | None:
|
|
327
|
+
try:
|
|
328
|
+
import pyaudiowpatch as pyaudio
|
|
329
|
+
p = pyaudio.PyAudio()
|
|
330
|
+
try:
|
|
331
|
+
info = self._find_loopback_info(p)
|
|
332
|
+
return info["name"] if info else None
|
|
333
|
+
finally:
|
|
334
|
+
p.terminate()
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.warning("PyAudio loopback デバイス取得失敗: %s", e)
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def list_devices(self) -> None:
|
|
340
|
+
try:
|
|
341
|
+
import pyaudiowpatch as pyaudio
|
|
342
|
+
p = pyaudio.PyAudio()
|
|
343
|
+
try:
|
|
344
|
+
print(t("rec.wasapi_loopback_mics"))
|
|
345
|
+
for info in p.get_loopback_device_info_generator():
|
|
346
|
+
name = info["name"]
|
|
347
|
+
marker = " (RDP — skipped)" if _is_rdp_audio(name) else ""
|
|
348
|
+
print(f" {name}{marker}")
|
|
349
|
+
finally:
|
|
350
|
+
p.terminate()
|
|
351
|
+
except ImportError:
|
|
352
|
+
print(t("rec.wasapi_soundcard_unavailable"))
|
|
353
|
+
|
|
354
|
+
def start_monitor_capture(self, source: str, audio_queue: queue.Queue,
|
|
355
|
+
stop_event: threading.Event) -> None:
|
|
356
|
+
"""PyAudioWPatch の WASAPI loopback でキャプチャ (polling)。
|
|
357
|
+
|
|
358
|
+
デバイスの native rate / channels で開き、Python 側で 16kHz mono に
|
|
359
|
+
間引き + ミックスダウンしてから既存パイプラインに流す。
|
|
360
|
+
"""
|
|
361
|
+
import pyaudiowpatch as pyaudio
|
|
362
|
+
import numpy as np
|
|
363
|
+
p = pyaudio.PyAudio()
|
|
364
|
+
stream = None
|
|
365
|
+
try:
|
|
366
|
+
info = self._find_loopback_info(p, prefer_name=source or "")
|
|
367
|
+
if info is None:
|
|
368
|
+
logger.error("WASAPI loopback デバイスが見つかりません(RDP 除外後)")
|
|
369
|
+
return
|
|
370
|
+
if _is_rdp_audio(info["name"]):
|
|
371
|
+
logger.error("RDP デバイス (%s) ではキャプチャしない", info["name"])
|
|
372
|
+
return
|
|
373
|
+
native_rate = int(info["defaultSampleRate"])
|
|
374
|
+
channels = int(info["maxInputChannels"]) or 1
|
|
375
|
+
decimate = max(1, native_rate // SAMPLE_RATE)
|
|
376
|
+
native_block = FRAME_SIZE * decimate
|
|
377
|
+
logger.info("WASAPI loopback キャプチャ開始: %s "
|
|
378
|
+
"(index=%d, native=%dHz/%dch → %dHz mono)",
|
|
379
|
+
info["name"], info["index"], native_rate, channels, SAMPLE_RATE)
|
|
380
|
+
stream = p.open(
|
|
381
|
+
format=pyaudio.paInt16,
|
|
382
|
+
channels=channels,
|
|
383
|
+
rate=native_rate,
|
|
384
|
+
input=True,
|
|
385
|
+
input_device_index=info["index"],
|
|
386
|
+
frames_per_buffer=native_block,
|
|
387
|
+
)
|
|
388
|
+
while not stop_event.is_set():
|
|
389
|
+
raw = stream.read(native_block, exception_on_overflow=False)
|
|
390
|
+
arr = np.frombuffer(raw, dtype=np.int16)
|
|
391
|
+
if channels > 1:
|
|
392
|
+
# ステレオ以上 → モノラルにミックスダウン
|
|
393
|
+
arr = arr.reshape(-1, channels).mean(axis=1).astype(np.int16)
|
|
394
|
+
# native_rate → SAMPLE_RATE に間引き(整数比のみ、エイリアスは
|
|
395
|
+
# 音声認識帯域には影響しない)
|
|
396
|
+
if decimate > 1:
|
|
397
|
+
arr = arr[::decimate]
|
|
398
|
+
audio_queue.put(arr.copy())
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.error("WASAPI loopback キャプチャエラー: %s", e)
|
|
401
|
+
finally:
|
|
402
|
+
if stream is not None:
|
|
403
|
+
try:
|
|
404
|
+
stream.stop_stream()
|
|
405
|
+
stream.close()
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
p.terminate()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def find_monitor_device_sd() -> tuple[int, dict[str, Any]] | None:
|
|
412
|
+
"""sounddevice でモニターデバイスを検索 (Linux のみ)
|
|
413
|
+
|
|
414
|
+
戻り値: (デバイスID, sd.InputStream に追加で渡す kwargs) または None。
|
|
415
|
+
Windows は WasapiBackend を使うため None を返す。
|
|
416
|
+
"""
|
|
417
|
+
if sys.platform == "win32":
|
|
418
|
+
return None
|
|
419
|
+
return _find_monitor_device_linux()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _find_monitor_device_linux() -> tuple[int, dict[str, Any]] | None:
|
|
423
|
+
"""Linux (PipeWire/PulseAudio) でモニターデバイスを検索
|
|
424
|
+
|
|
425
|
+
PipeWire: `.monitor` サフィックスを持つ入力デバイス
|
|
426
|
+
PulseAudio: "Monitor of " プレフィックスを持つ入力デバイス
|
|
427
|
+
デフォルト Sink に対応するモニターを優先する。
|
|
428
|
+
"""
|
|
429
|
+
import sounddevice as sd
|
|
430
|
+
devices = sd.query_devices()
|
|
431
|
+
candidates = []
|
|
432
|
+
for i, dev in enumerate(devices):
|
|
433
|
+
name = dev["name"]
|
|
434
|
+
is_monitor = (
|
|
435
|
+
name.endswith(".monitor")
|
|
436
|
+
or name.lower().startswith("monitor of ")
|
|
437
|
+
)
|
|
438
|
+
if is_monitor and dev["max_input_channels"] > 0:
|
|
439
|
+
candidates.append((i, name))
|
|
440
|
+
logger.debug("monitor 候補: #%d %s", i, name)
|
|
441
|
+
|
|
442
|
+
if not candidates:
|
|
443
|
+
logger.debug("monitor 候補なし")
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
# デフォルト Sink に対応するモニターを優先
|
|
447
|
+
default_sink = _get_default_sink_name()
|
|
448
|
+
if default_sink:
|
|
449
|
+
expected_monitor = default_sink + ".monitor"
|
|
450
|
+
for idx, name in candidates:
|
|
451
|
+
if name == expected_monitor:
|
|
452
|
+
logger.debug("デフォルト Sink のモニター選択: #%d %s", idx, name)
|
|
453
|
+
return idx, {}
|
|
454
|
+
|
|
455
|
+
# 見つからなければ最初の候補
|
|
456
|
+
logger.debug("デフォルト Sink 不明、最初の候補を選択: #%d %s", *candidates[0])
|
|
457
|
+
return candidates[0][0], {}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def list_all_devices(backend_name: str, backend: AudioBackend | None) -> None:
|
|
461
|
+
"""全デバイス一覧表示"""
|
|
462
|
+
import sounddevice as sd
|
|
463
|
+
print(t("rec.sounddevice_devices"))
|
|
464
|
+
print(sd.query_devices())
|
|
465
|
+
|
|
466
|
+
if backend:
|
|
467
|
+
backend.list_devices()
|
|
468
|
+
|
|
469
|
+
monitor_sd = find_monitor_device_sd()
|
|
470
|
+
if monitor_sd is not None:
|
|
471
|
+
device_idx, _ = monitor_sd
|
|
472
|
+
print(t("rec.auto_detect_sd", device=device_idx))
|
|
473
|
+
|
|
474
|
+
if backend:
|
|
475
|
+
monitor = backend.detect_monitor_source()
|
|
476
|
+
if monitor:
|
|
477
|
+
print(t("rec.auto_detect_backend", backend=backend_name, source=monitor))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shadow-clerk daemon: 設定管理"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import yaml
|
|
6
|
+
from shadow_clerk import CONFIG_FILE
|
|
7
|
+
from shadow_clerk.i18n import t
|
|
8
|
+
from shadow_clerk._daemon_constants import DEFAULT_CONFIG
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("shadow-clerk")
|
|
11
|
+
|
|
12
|
+
_config_cache: dict | None = None
|
|
13
|
+
_config_mtime: float = 0.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> dict:
|
|
17
|
+
"""config.yaml を読み込む。ファイルがなければデフォルト値を返す。
|
|
18
|
+
|
|
19
|
+
mtime ベースのキャッシュにより、ファイルが変更されていなければ再パースしない。
|
|
20
|
+
"""
|
|
21
|
+
global _config_cache, _config_mtime
|
|
22
|
+
try:
|
|
23
|
+
st = os.stat(CONFIG_FILE)
|
|
24
|
+
except OSError:
|
|
25
|
+
return dict(DEFAULT_CONFIG)
|
|
26
|
+
if _config_cache is not None and st.st_mtime == _config_mtime:
|
|
27
|
+
return dict(_config_cache)
|
|
28
|
+
try:
|
|
29
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
30
|
+
user_config = yaml.safe_load(f)
|
|
31
|
+
if isinstance(user_config, dict):
|
|
32
|
+
merged = dict(DEFAULT_CONFIG)
|
|
33
|
+
merged.update(user_config)
|
|
34
|
+
_config_cache = merged
|
|
35
|
+
_config_mtime = st.st_mtime
|
|
36
|
+
return dict(merged)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.warning("config.yaml の読み込みに失敗: %s", e)
|
|
39
|
+
return dict(DEFAULT_CONFIG)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_translation_provider(config: dict) -> str:
|
|
43
|
+
"""翻訳プロバイダーを返す。translation_provider が未設定なら llm_provider にフォールバック。"""
|
|
44
|
+
provider = config.get("translation_provider")
|
|
45
|
+
if provider:
|
|
46
|
+
return provider
|
|
47
|
+
return config.get("llm_provider", "claude")
|
|
48
|
+
|
|
49
|
+
def _builtin_command_descs():
|
|
50
|
+
return [
|
|
51
|
+
{"command": "start_meeting", "description": t("vcmd.start_meeting")},
|
|
52
|
+
{"command": "end_meeting", "description": t("vcmd.end_meeting")},
|
|
53
|
+
{"command": "translate_start", "description": t("vcmd.translate_start")},
|
|
54
|
+
{"command": "translate_stop", "description": t("vcmd.translate_stop")},
|
|
55
|
+
{"command": "set_language ja", "description": t("vcmd.set_language_ja")},
|
|
56
|
+
{"command": "set_language en", "description": t("vcmd.set_language_en")},
|
|
57
|
+
{"command": "unset_language", "description": t("vcmd.unset_language")},
|
|
58
|
+
]
|