Compare commits

..

10 Commits

Author SHA1 Message Date
Sakamoto Shiina
942a257e79 Merge branch 'bugfix_ui_endpoint' into develop 2026-02-19 14:49:03 +09:00
Sakamoto Shiina
6b2be0f22b [Fix] UI: Add 'run' endpoint for selected translation and transcription compute type. 2026-02-19 14:47:04 +09:00
Sakamoto Shiina
242c022e33 Merge branch 'version' into develop 2026-01-20 03:43:39 +09:00
Sakamoto Shiina
0765908184 👍️[Update] Version 3.4.1 -> 3.4.2 2026-01-20 03:42:23 +09:00
Sakamoto Shiina
f6a3b0e8fd Merge branch 'hotfix_update_available_error' into develop 2026-01-20 03:39:36 +09:00
Sakamoto Shiina
5811158231 [Hotfix] UI: SoftwareUpdateAvailableButton: Fix update check logic and rendering order. 2026-01-20 03:36:59 +09:00
misyaguziya
0cb2b7e6ae Merge branch 'version' into develop 2026-01-19 22:13:07 +09:00
misyaguziya
623d17e885 [Update] バージョンを3.4.0から3.4.1に更新 2026-01-19 22:12:32 +09:00
misyaguziya
4c95b66dd8 Merge branch 'bugfix_telemetry' into develop 2026-01-19 21:14:03 +09:00
misyaguziya
1656620ce4 [Update] テレメトリ機能の改善と不要なコードの削除 2026-01-19 21:12:16 +09:00
13 changed files with 184 additions and 313 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "vrct", "name": "vrct",
"private": true, "private": true,
"version": "3.4.0", "version": "3.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"setup-python": "bat\\install.bat", "setup-python": "bat\\install.bat",

View File

@@ -724,7 +724,7 @@ class Config:
NOTIFICATION_VRC_SFX = ManagedProperty('NOTIFICATION_VRC_SFX', type_=bool) NOTIFICATION_VRC_SFX = ManagedProperty('NOTIFICATION_VRC_SFX', type_=bool)
WEBSOCKET_HOST = ManagedProperty('WEBSOCKET_HOST', type_=str) WEBSOCKET_HOST = ManagedProperty('WEBSOCKET_HOST', type_=str)
WEBSOCKET_PORT = ManagedProperty('WEBSOCKET_PORT', type_=int) WEBSOCKET_PORT = ManagedProperty('WEBSOCKET_PORT', type_=int)
# --- Telemetry Settings --- # --- Telemetry Settings ---
ENABLE_TELEMETRY = ManagedProperty('ENABLE_TELEMETRY', type_=bool) ENABLE_TELEMETRY = ManagedProperty('ENABLE_TELEMETRY', type_=bool)
@@ -764,7 +764,7 @@ class Config:
def init_config(self): def init_config(self):
# Read Only # Read Only
self._VERSION = "3.4.0" self._VERSION = "3.4.2"
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
self._PATH_LOCAL = os_path.dirname(sys.executable) self._PATH_LOCAL = os_path.dirname(sys.executable)
else: else:

View File

@@ -284,7 +284,6 @@ class Controller:
) )
elif isinstance(message, str) and len(message) > 0: elif isinstance(message, str) and len(message) > 0:
model.telemetryTouchActivity()
model.telemetryTrackCoreFeature("mic_speech_to_text") model.telemetryTrackCoreFeature("mic_speech_to_text")
translation = [] translation = []
transliteration_message = [] transliteration_message = []
@@ -457,7 +456,6 @@ class Controller:
}, },
) )
elif isinstance(message, str) and len(message) > 0: elif isinstance(message, str) and len(message) > 0:
model.telemetryTouchActivity()
model.telemetryTrackCoreFeature("speaker_speech_to_text") model.telemetryTrackCoreFeature("speaker_speech_to_text")
translation = [] translation = []
transliteration_message = [] transliteration_message = []
@@ -640,7 +638,6 @@ class Controller:
id = data["id"] id = data["id"]
message = data["message"] message = data["message"]
if len(message) > 0: if len(message) > 0:
model.telemetryTouchActivity()
model.telemetryTrackCoreFeature("text_input") model.telemetryTrackCoreFeature("text_input")
translation = [] translation = []
transliteration_message: List[Any] = [] transliteration_message: List[Any] = []
@@ -2607,7 +2604,7 @@ class Controller:
def setEnableTelemetry(*args, **kwargs) -> dict: def setEnableTelemetry(*args, **kwargs) -> dict:
if config.ENABLE_TELEMETRY is False: if config.ENABLE_TELEMETRY is False:
config.ENABLE_TELEMETRY = True config.ENABLE_TELEMETRY = True
model.telemetryInit(enabled=config.ENABLE_TELEMETRY, app_version=config.VERSION) model.telemetryInit(enabled=True, app_version=config.VERSION)
return {"status":200, "result":config.ENABLE_TELEMETRY} return {"status":200, "result":config.ENABLE_TELEMETRY}
@staticmethod @staticmethod

View File

@@ -55,9 +55,8 @@ src-python/
│ └── telemetry/ │ └── telemetry/
│ ├── __init__.py # Public API + singleton管理 │ ├── __init__.py # Public API + singleton管理
│ ├── client.py # Aptabase wrapper │ ├── client.py # Aptabase wrapper
│ ├── state.py # Enable/disable, last_activity │ ├── state.py # Enable/disable、セッション内送信済み機能管理
── heartbeat.py # 5分間隔heartbeatスレッド ── core.py # イベント送信ロジック、重複検出
│ └── core.py # イベント送信ロジック
└── docs/ └── docs/
└── details/ └── details/
└── telemetry.md # 実装詳細ドキュメント └── telemetry.md # 実装詳細ドキュメント
@@ -67,10 +66,9 @@ src-python/
| モジュール | 責任 | | モジュール | 責任 |
|-----------|------| |-----------|------|
| `__init__.py` | パブリック API、シングルトン管理 | | `__init__.py` | パブリック API、シングルトン管理、イベントループ管理 |
| `client.py` | Aptabase SDK ラッパー、HTTP通信 | | `client.py` | Aptabase SDK ラッパー、HTTP通信、ログレベル設定 |
| `state.py` | ON/OFF 状態、最終操作時刻管理 | | `state.py` | ON/OFF 状態、セッション内送信済み機能の追跡 |
| `heartbeat.py` | 5分間隔スレッド、タイムアウト判定 |
| `core.py` | イベント構築・送信、重複検出 | | `core.py` | イベント構築・送信、重複検出 |
--- ---
@@ -83,7 +81,6 @@ src-python/
|-----------|------|----------| |-----------|------|----------|
| `app_started` | アプリ起動時 | `{}` | | `app_started` | アプリ起動時 | `{}` |
| `app_closed` | アプリ終了時 | `{}` | | `app_closed` | アプリ終了時 | `{}` |
| `session_heartbeat` | 5分間隔アクティブ確認 | `{}` |
| `core_feature` | コア機能開始 | `{"feature": "translation" \| "mic_speech_to_text" \| "speaker_speech_to_text" \| "text_input"}` | | `core_feature` | コア機能開始 | `{"feature": "translation" \| "mic_speech_to_text" \| "speaker_speech_to_text" \| "text_input"}` |
| `settings_opened` | 設定画面を開く | `{}` | | `settings_opened` | 設定画面を開く | `{}` |
| `config_changed` | 設定変更 | `{"section": str}` | | `config_changed` | 設定変更 | `{"section": str}` |
@@ -124,37 +121,6 @@ telemetry.track_core_feature("text_input")
send_message() # 実処理 send_message() # 実処理
``` ```
### Session Heartbeat 仕様
#### アクティブ判定条件
以下のいずれかが発生した場合、セッションはアクティブ
1. **テキスト入力**
- チャット送信
- メッセージボックスへの入力
2. **ASR 実処理**(マイク or スピーカー)
- マイク音声認識開始
- スピーカー音声認識開始
- 実際の音声処理UI 状態は使用しない)
#### 送信ルール
- **送信間隔**5分300秒
- **タイムアウト**最後のアクティビティから5分経過で送信停止
- **復帰条件**:アクティビティ発生 → 次の heartbeat から再開
- **無条件停止**:テレメトリ OFF 時は即座にスレッド停止
#### 実装概略図
```
Timeline:
├─ [Activity] touch_activity() →更新
├─ [300秒待機]
├─ [Check] 最後の操作 < 5分? → Yes → send heartbeat
├─ [300秒待機]
├─ [Check] 最後の操作 < 5分? → No → 待機のみ
└─ [Activity] touch_activity() →復帰 → 次heartbeat送信
```
--- ---
## データフロー ## データフロー
@@ -169,7 +135,6 @@ Timeline:
3. telemetry.init(enabled=config.ENABLE_TELEMETRY) 3. telemetry.init(enabled=config.ENABLE_TELEMETRY)
├─ enabled=True の場合 ├─ enabled=True の場合
│ ├─ Aptabase SDK 初期化 │ ├─ Aptabase SDK 初期化
│ ├─ heartbeat スレッド開始
│ └─ app_started イベント送信 │ └─ app_started イベント送信
└─ enabled=False の場合 └─ enabled=False の場合
└─ すべての処理をスキップ └─ すべての処理をスキップ
@@ -183,8 +148,6 @@ Timeline:
ENABLE_TELEMETRY チェック ENABLE_TELEMETRY チェック
├─ False → 何もしない ├─ False → 何もしない
└─ True └─ True
touch_activity() → 最終時刻更新
1セッション内で同一 feature 送信済み? 1セッション内で同一 feature 送信済み?
├─ Yes → スキップ ├─ Yes → スキップ
@@ -208,7 +171,7 @@ OFF 状態(設定保存)
telemetry.init(enabled=True) telemetry.init(enabled=True)
├─ 既知例外は握りつぶし ├─ 既知例外は握りつぶし
├─ Aptabase 初期化 ├─ Aptabase 初期化
├─ heartbeat スレッド開始 ├─ asyncio イベントループ開始
└─ 次のイベントから通常送信 └─ 次のイベントから通常送信
``` ```
@@ -220,7 +183,7 @@ config.json 更新
ENABLE_TELEMETRY: false に変更 ENABLE_TELEMETRY: false に変更
telemetry.shutdown() telemetry.shutdown()
├─ heartbeat スレッド停止 ├─ asyncio イベントループ停止
├─ 内部状態をリセット ├─ 内部状態をリセット
└─ メモリ解放 └─ メモリ解放
@@ -246,8 +209,9 @@ def telemetry.init(enabled: bool) -> None:
enabled (bool): テレメトリを有効にするか enabled (bool): テレメトリを有効にするか
Behavior: Behavior:
- enabled=True: Aptabase SDK初期化、heartbeat開始、app_started送信 - enabled=True: Aptabase SDK初期化、asyncioイベントループ開始、app_started送信
- enabled=False: すべてのスキップ、以後のtrack()は何もしない - enabled=False: すべてのスキップ、以後のtrack()は何もしない
- 複数回呼び出し時: _init_called フラグで二重初期化を防止
Exception: Exception:
- SDK初期化失敗時: 例外握りつぶし、機能は停止するが無言 - SDK初期化失敗時: 例外握りつぶし、機能は停止するが無言
@@ -261,7 +225,7 @@ def telemetry.shutdown() -> None:
Behavior: Behavior:
- app_closed イベント送信 - app_closed イベント送信
- heartbeat スレッド停止 - asyncio イベントループ停止
- 内部状態をリセット - 内部状態をリセット
- 例外は握りつぶし(無言) - 例外は握りつぶし(無言)
""" """
@@ -316,25 +280,18 @@ def telemetry.track_core_feature(feature: str) -> None:
# ========================================================================= # =========================================================================
# ACTIVITY TRACKING # SHUTDOWN
# ========================================================================= # =========================================================================
def telemetry.touch_activity() -> None: def telemetry.shutdown() -> None:
""" """
最終アクティビティ時刻を更新heartbeat判定用 テレメトリシャットダウン
Behavior: Behavior:
- enabled=False なら何もしない - app_closed イベント送信
- 内部的に datetime.now() を記録 - イベントループ停止(フラッシュ待機)
- 自動呼び出し: track_core_feature() 内から呼び出される - Aptabase クライアント停止
- 手動呼び出し: テキスト入力検出時など、明示的に呼び出し可 - Tauri sidecar 環境では呼び出し後にプロセス終了
Example:
# テキスト入力時
telemetry.touch_activity()
# track_core_feature() は内部で自動呼び出し
telemetry.track_core_feature("translation")
""" """
pass pass
@@ -360,9 +317,7 @@ def telemetry.get_state() -> dict:
Returns: Returns:
dict: { dict: {
"enabled": bool, "enabled": bool,
"last_activity": datetime | None,
"session_features_sent": list[str], "session_features_sent": list[str],
"heartbeat_active": bool,
} }
""" """
pass pass
@@ -471,8 +426,11 @@ def setUiLanguage(data):
パブリック API を提供し、内部実装を隠蔽する。 パブリック API を提供し、内部実装を隠蔽する。
""" """
import asyncio
import threading
from concurrent.futures import Future
from .state import TelemetryState from .state import TelemetryState
from .heartbeat import HeartbeatManager
from .core import TelemetryCore from .core import TelemetryCore
@@ -489,56 +447,128 @@ class Telemetry:
if self._initialized: if self._initialized:
return return
self.state = TelemetryState() self.state = TelemetryState()
self.heartbeat = HeartbeatManager(self.state)
self.core = TelemetryCore(self.state) self.core = TelemetryCore(self.state)
self._loop = None
self._loop_thread = None
self._init_called = False
self._initialized = True self._initialized = True
def init(self, enabled: bool): def _start_event_loop(self):
"""テレメトリ初期化""" """バックグラウンドでイベントループを開始"""
if self._loop_thread is not None and self._loop_thread.is_alive():
return
def run_loop():
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_forever()
self._loop_thread = threading.Thread(target=run_loop, daemon=True, name="telemetry_loop")
self._loop_thread.start()
# ループが開始されるまで待機
while self._loop is None:
pass
def _stop_event_loop(self, timeout: float = 5.0):
"""イベントループを停止(フラッシュ完了を待つ)"""
if self._loop is None:
return
# ループにstop()を予約
self._loop.call_soon_threadsafe(self._loop.stop)
# ループスレッド終了を待機
if self._loop_thread is not None:
self._loop_thread.join(timeout=timeout)
self._loop = None
self._loop_thread = None
def _run_async(self, coro):
"""同期コンテキストから非同期関数を実行"""
if self._loop is None:
return
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
try:
return future.result(timeout=5.0)
except Exception:
pass
def _schedule_async(self, coro):
"""非同期タスクをバックグラウンドでスケジュール(待機しない)"""
if self._loop is None:
return
try:
asyncio.run_coroutine_threadsafe(coro, self._loop)
except Exception:
pass
def init(self, enabled: bool, app_version: str = "1.0.0"):
"""
テレメトリ初期化(同期インターフェース)
重要:このメソッドは冪等です。複数回呼ばれても安全です。
既に初期化済みの場合は、有効/無効の状態のみを更新します。
"""
# 既に初期化済みの場合は、状態の更新のみ
if self._init_called:
self.state.set_enabled(enabled)
return
# 初回初期化
self._init_called = True
self.state.set_enabled(enabled) self.state.set_enabled(enabled)
if enabled: if enabled:
try: self._start_event_loop()
self.core.send_event("app_started") self._run_async(self._init_async(app_version))
self.heartbeat.start()
except Exception: async def _init_async(self, app_version: str):
pass # 握りつぶし """非同期初期化処理"""
await self.core.start(app_version=app_version)
await self.core.send_event("app_started")
def shutdown(self): def shutdown(self):
"""テレメトリ終了""" """テレメトリ終了"""
if self.state.is_enabled(): try:
try: if self.state.is_enabled():
self.core.send_event("app_closed") try:
except Exception: self._run_async(self._shutdown_async())
pass except Exception:
self.heartbeat.stop() pass
self.state.reset()
self._stop_event_loop(timeout=5.0)
except Exception:
pass
finally:
self.state.reset()
self._init_called = False
async def _shutdown_async(self):
"""非同期終了処理"""
await self.core.send_event("app_closed")
await self.core.stop()
def track(self, event: str, payload: dict = None): def track(self, event: str, payload: dict = None):
"""汎用イベント送信""" """汎用イベント送信"""
if not self.state.is_enabled(): if not self.state.is_enabled():
return return
try: self._schedule_async(self.core.send_event(event, payload))
self.core.send_event(event, payload)
except Exception:
pass
def track_core_feature(self, feature: str): def track_core_feature(self, feature: str):
"""コア機能イベント送信(重複検出付き)""" """コア機能イベント送信"""
if not self.state.is_enabled(): if not self.state.is_enabled():
return return
self.state.touch_activity()
if self.core.is_duplicate_core_feature(feature): if self.core.is_duplicate_core_feature(feature):
return return
try: self._schedule_async(self._track_core_feature_async(feature))
self.core.send_event("core_feature", {"feature": feature})
self.state.record_feature_sent(feature)
except Exception:
pass
def touch_activity(self): async def _track_core_feature_async(self, feature: str):
"""アクティビティ時刻更新""" """非同期コア機能送信処理"""
if self.state.is_enabled(): await self.core.send_event("core_feature", {"feature": feature})
self.state.touch_activity() self.state.record_feature_sent(feature)
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""有効状態確認""" """有効状態確認"""
@@ -559,19 +589,17 @@ telemetry = Telemetry()
""" """
テレメトリ状態管理 テレメトリ状態管理
- enable/disable フラグ - enable/disable フラグ
- 最終操作時刻
- セッション内送信済み機能リスト - セッション内送信済み機能リスト
""" """
from datetime import datetime
from threading import Lock from threading import Lock
class TelemetryState: class TelemetryState:
def __init__(self): def __init__(self):
self._enabled = True # デフォルト有効 self._enabled = True # デフォルト有効
self._last_activity = None
self._session_features_sent = set() self._session_features_sent = set()
self._lock = Lock() self._lock = Lock()
self._lock = Lock()
def set_enabled(self, value: bool): def set_enabled(self, value: bool):
"""有効/無効設定""" """有効/無効設定"""
@@ -585,15 +613,20 @@ class TelemetryState:
with self._lock: with self._lock:
return self._enabled return self._enabled
def touch_activity(self): def record_feature_sent(self, feature: str):
"""最終操作時刻更新""" """送信済み機能を記録"""
with self._lock: with self._lock:
self._last_activity = datetime.now() self._session_features_sent.add(feature)
def get_last_activity(self) -> datetime: def has_feature_been_sent(self, feature: str) -> bool:
"""最終操作時刻取得""" """機能がこのセッション内で送信済みか"""
with self._lock: with self._lock:
return self._last_activity return feature in self._session_features_sent
def reset(self):
"""状態をリセット"""
with self._lock:
self._session_features_sent.clear()
def record_feature_sent(self, feature: str): def record_feature_sent(self, feature: str):
"""送信済み機能を記録""" """送信済み機能を記録"""
@@ -608,7 +641,6 @@ class TelemetryState:
def reset(self): def reset(self):
"""状態をリセット""" """状態をリセット"""
with self._lock: with self._lock:
self._last_activity = None
self._session_features_sent.clear() self._session_features_sent.clear()
def get_debug_info(self) -> dict: def get_debug_info(self) -> dict:
@@ -616,74 +648,10 @@ class TelemetryState:
with self._lock: with self._lock:
return { return {
"enabled": self._enabled, "enabled": self._enabled,
"last_activity": self._last_activity.isoformat() if self._last_activity else None,
"session_features_sent": list(self._session_features_sent), "session_features_sent": list(self._session_features_sent),
} }
``` ```
### `models/telemetry/heartbeat.py`
```python
"""
Heartbeat スレッド管理
- 5分間隔でアクティブ確認
- 操作なしで5分経過したら送信停止
"""
from datetime import datetime, timedelta
from threading import Thread, Event
import time
class HeartbeatManager:
INTERVAL = 300 # 5 minutes
TIMEOUT = 300 # 5 minutes
def __init__(self, state):
self.state = state
self.thread = None
self._stop_event = Event()
def start(self):
"""Heartbeat スレッド開始"""
if self.thread is not None and self.thread.is_alive():
return
self._stop_event.clear()
self.thread = Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self):
"""Heartbeat スレッド停止"""
self._stop_event.set()
if self.thread is not None:
self.thread.join(timeout=1.0)
self.thread = None
def _run(self):
"""Heartbeat ループ"""
while not self._stop_event.is_set():
try:
time.sleep(self.INTERVAL)
if not self.state.is_enabled():
continue
last_activity = self.state.get_last_activity()
if last_activity is None:
continue
elapsed = (datetime.now() - last_activity).total_seconds()
if elapsed < self.TIMEOUT:
# アクティブなら heartbeat 送信
from .core import TelemetryCore
try:
core = TelemetryCore(self.state)
core.send_event("session_heartbeat")
except Exception:
pass # 握りつぶし
except Exception:
pass # スレッド不停止のため握りつぶし
```
### `models/telemetry/client.py` ### `models/telemetry/client.py`
```python ```python
@@ -828,7 +796,7 @@ class Main:
th.join(timeout=remaining) th.join(timeout=remaining)
``` ```
**重要**: `controller.shutdown()` **スレッド停止前** に呼び出す必要があります。そうしないと heartbeat スレッドが動作していない状態で shutdown に失敗する可能性があります。 **重要**: `controller.shutdown()`最初に呼び出され、その中で `model.shutdown()` が実行され、最後に `telemetry.shutdown()` が呼び出されます。
#### パターン2: Controller.setWatchdogCallback #### パターン2: Controller.setWatchdogCallback
@@ -882,7 +850,7 @@ def start(self) -> None:
main_instance.stop() 呼び出し main_instance.stop() 呼び出し
1. try: 1. try:
telemetry.shutdown() # app_closed 送信 + heartbeat停止 self.controller.shutdown() # app_closed 送信 + すべてのクリーンアップ
except: except:
pass # 握りつぶし(通信失敗でも続行) pass # 握りつぶし(通信失敗でも続行)
@@ -890,9 +858,7 @@ main_instance.stop() 呼び出し
3. スレッド停止待機(最大 2.0秒) 3. スレッド停止待機(最大 2.0秒)
4. モデルクリーンアップif model が初期化されていれば) 4. メモリ解放
5. メモリ解放
プロセス終了 プロセス終了
``` ```
@@ -967,11 +933,10 @@ def stop(self, wait: float = 2.0) -> None:
def shutdown(self): def shutdown(self):
if self.state.is_enabled(): if self.state.is_enabled():
try: try:
self.core.send_event("app_closed") # ← 実行されない self.core.send_event("app_closed") # ← 実行され
except Exception: except Exception:
pass pass
self.heartbeat.stop() # OFF時も停止するスレッド安全
self.state.reset() # 状態リセット self.state.reset() # 状態リセット
``` ```
@@ -1027,16 +992,6 @@ def test_track_core_feature_no_duplicate():
# 2回目はスキップ内部的に重複検出 # 2回目はスキップ内部的に重複検出
def test_heartbeat_sends_when_active():
"""5分操作なしなら heartbeat 送信"""
# heartbeat スレッドがちゃんと5分待機後に
# touch_activity() 後のアクティブ判定で送信
def test_heartbeat_stops_when_inactive():
"""5分以上操作なしなら heartbeat 停止"""
# last_activity が 5分以上古い場合、
# heartbeat 送信しない
def test_shutdown_sends_app_closed(): def test_shutdown_sends_app_closed():
"""shutdown() が app_closed を送信""" """shutdown() が app_closed を送信"""
telemetry = Telemetry() telemetry = Telemetry()
@@ -1059,10 +1014,6 @@ def test_full_flow():
# 操作 # 操作
telemetry.track_core_feature("translation") telemetry.track_core_feature("translation")
telemetry.touch_activity()
# 5分待機テスト時は短縮
# heartbeat 送信確認
# 終了 # 終了
telemetry.shutdown() telemetry.shutdown()
@@ -1099,7 +1050,6 @@ ENABLE_TELEMETRY = True # デフォルト有効
- [ ] イベントペイロードに個人情報が含まれていない - [ ] イベントペイロードに個人情報が含まれていない
- [ ] マイク/スピーカーイベントが分離している - [ ] マイク/スピーカーイベントが分離している
- [ ] heartbeat が UI 状態に依存していない
- [ ] OFF 時にすべての通信が停止する - [ ] OFF 時にすべての通信が停止する
- [ ] 例外が握りつぶされ、ユーザーに見えない - [ ] 例外が握りつぶされ、ユーザーに見えない
- [ ] Aptabase SDK の最新版を使用 - [ ] Aptabase SDK の最新版を使用
@@ -1121,13 +1071,12 @@ UI に以下を表示:
|---------|--------|--------| |---------|--------|--------|
| 1 | `__init__.py`, `state.py` 作成 | 高 | | 1 | `__init__.py`, `state.py` 作成 | 高 |
| 2 | `client.py`, `core.py` 作成 | 高 | | 2 | `client.py`, `core.py` 作成 | 高 |
| 3 | `heartbeat.py` 作成・テスト | 高 | | 3 | `config.py` に `ENABLE_TELEMETRY` 追加 | 高 |
| 4 | `config.py` に `ENABLE_TELEMETRY` 追加 | 高 | | 4 | `controller.py` に API 呼び出し追加 | 高 |
| 5 | `controller.py` に API 呼び出し追加 | 高 | | 5 | `mainloop.py` に init/shutdown 追加 | 高 |
| 6 | `mainloop.py` に init/shutdown 追加 | 高 | | 6 | ユニット・統合テスト | 高 |
| 7 | ユニット・統合テスト | | | 7 | ドキュメント更新 | |
| 8 | ドキュメント更新 | 中 | | 8 | UI 文言追加 | 中 |
| 9 | UI 文言追加 | 中 |
--- ---
@@ -1136,7 +1085,6 @@ UI に以下を表示:
- [x] テレメトリ OFF 時、一切の通信が発生しない - [x] テレメトリ OFF 時、一切の通信が発生しない
- [x] オフライン状態でもアプリが正常に動作する - [x] オフライン状態でもアプリが正常に動作する
- [x] `core_feature` は1セッション中に1回のみ送信される - [x] `core_feature` は1セッション中に1回のみ送信される
- [x] heartbeat は 5分以上操作がないと送信を停止する
- [x] 個人情報・入力内容・音声データは絶対に送信されない - [x] 個人情報・入力内容・音声データは絶対に送信されない
- [x] 送信失敗時は例外を握りつぶし、無言で続行する - [x] 送信失敗時は例外を握りつぶし、無言で続行する
- [x] マイク/スピーカーイベントが分離している - [x] マイク/スピーカーイベントが分離している
@@ -1149,3 +1097,4 @@ UI に以下を表示:
- [Aptabase Dashboard](https://aptabase.com) - [Aptabase Dashboard](https://aptabase.com)
- [Privacy by Design](https://en.wikipedia.org/wiki/Privacy_by_design) - [Privacy by Design](https://en.wikipedia.org/wiki/Privacy_by_design)

View File

@@ -112,18 +112,13 @@ Aptabase を用いた匿名な使用状況データ収集。デフォルト有
### イベント種別(送信固定) ### イベント種別(送信固定)
- `app_started`: アプリ起動 - `app_started`: アプリ起動
- `app_closed`: アプリ終了(最後のイベント) - `app_closed`: アプリ終了(最後のイベント)
- `session_heartbeat`: 5 分間隔アクティブ確認
- `core_feature`: 機能開始translation / mic_speech_to_text / speaker_speech_to_text / text_input - `core_feature`: 機能開始translation / mic_speech_to_text / speaker_speech_to_text / text_input
- `settings_opened`: 設定画面開閉
- `config_changed`: 設定変更
- `error`: エラー発生
### 動作フロー ### 動作フロー
1. アプリ起動時に telemetryInit() 呼び出し 1. アプリ起動時に telemetryInit() 呼び出し
2. ユーザーアクティビティ検出時に telemetryTouchActivity() 呼び出し 2. 機能開始時に track_core_feature() で 1 セッション 1 回のみ送信
3. 機能開始時に track_core_feature() で 1 セッション 1 回のみ送信 3. アプリ終了時に telemetryShutdown() で app_closed 送信
4. アプリ終了時に telemetryShutdown() で app_closed 送信 4. config.ENABLE_TELEMETRY = False で一切の通信・スレッド停止
5. config.ENABLE_TELEMETRY = False で一切の通信・スレッド停止
### 設定・制御 ### 設定・制御
- `config.ENABLE_TELEMETRY`: True/False で機能制御 - `config.ENABLE_TELEMETRY`: True/False で機能制御
@@ -137,6 +132,7 @@ Aptabase を用いた匿名な使用状況データ収集。デフォルト有
- API 通信失敗時: 例外握りつぶし、アプリ動作に影響なし - API 通信失敗時: 例外握りつぶし、アプリ動作に影響なし
- オフライン時: 機能停止のみ、再送・バッファリングなし - オフライン時: 機能停止のみ、再送・バッファリングなし
- 無効化時: 一切の通信・スレッド・処理停止 - 無効化時: 一切の通信・スレッド・処理停止
- Aptabase SDK ログ: CRITICAL レベルのみに制限(ノイズ削減)
## モデル重みダウンロード ## モデル重みダウンロード
- `models.translation.translation_utils``models.transcription.transcription_whisper` にダウンロード/チェック関数があり、チェックサムやファイル存在を検証する。 - `models.translation.translation_utils``models.transcription.transcription_whisper` にダウンロード/チェック関数があり、チェックサムやファイル存在を検証する。

View File

@@ -1331,9 +1331,4 @@ class Model:
if hasattr(self, "telemetry") and self.telemetry: if hasattr(self, "telemetry") and self.telemetry:
self.telemetry.track_core_feature(feature) self.telemetry.track_core_feature(feature)
def telemetryTouchActivity(self):
"""テレメトリアクティビティ更新 (Model ラッパー)"""
if hasattr(self, "telemetry") and self.telemetry:
self.telemetry.touch_activity()
model = Model() model = Model()

View File

@@ -10,7 +10,6 @@ from concurrent.futures import Future
# Allow running as a script for quick verification. # Allow running as a script for quick verification.
try: try:
from .state import TelemetryState from .state import TelemetryState
from .heartbeat import HeartbeatManager
from .core import TelemetryCore from .core import TelemetryCore
except ImportError: except ImportError:
import os import os
@@ -18,7 +17,6 @@ except ImportError:
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from models.telemetry.state import TelemetryState from models.telemetry.state import TelemetryState
from models.telemetry.heartbeat import HeartbeatManager
from models.telemetry.core import TelemetryCore from models.telemetry.core import TelemetryCore
@@ -36,9 +34,9 @@ class Telemetry:
return return
self.state = TelemetryState() self.state = TelemetryState()
self.core = TelemetryCore(self.state) self.core = TelemetryCore(self.state)
self.heartbeat = HeartbeatManager(self.state, self.core, self._schedule_async_with_loop)
self._loop = None self._loop = None
self._loop_thread = None self._loop_thread = None
self._init_called = False # init()が呼ばれたかを追跡(重複初期化防止)
self._initialized = True self._initialized = True
def _start_event_loop(self): def _start_event_loop(self):
@@ -96,12 +94,20 @@ class Telemetry:
except Exception: except Exception:
pass pass
def _schedule_async_with_loop(self, coro):
"""ループ取得とスケジューリングのヘルパーheartbeat用"""
return self._schedule_async(coro)
def init(self, enabled: bool, app_version: str = "1.0.0"): def init(self, enabled: bool, app_version: str = "1.0.0"):
"""テレメトリ初期化(同期インターフェース)""" """テレメトリ初期化(同期インターフェース)
重要:このメソッドは冪等です。複数回呼ばれても安全です。
既に初期化済みの場合は、有効/無効の状態のみを更新します。
これにより、設定変更時などに誤ってapp_startedイベントが重複送信される問題を防ぎます。
"""
# 既に初期化済みの場合は、状態の更新のみ
if self._init_called:
self.state.set_enabled(enabled)
return
# 初回初期化
self._init_called = True
self.state.set_enabled(enabled) self.state.set_enabled(enabled)
if enabled: if enabled:
self._start_event_loop() self._start_event_loop()
@@ -111,23 +117,18 @@ class Telemetry:
"""非同期初期化処理""" """非同期初期化処理"""
await self.core.start(app_version=app_version) await self.core.start(app_version=app_version)
await self.core.send_event("app_started") await self.core.send_event("app_started")
self.heartbeat.start()
def shutdown(self): def shutdown(self):
"""テレメトリ終了(同期インターフェース) """テレメトリ終了(同期インターフェース)
重要Tauri sidecar環境では、このメソッド実行後にプロセス終了が発生します。 重要Tauri sidecar環境では、このメソッド実行後にプロセス終了が発生します。
app_closed イベントが確実に送信されるように、以下の手順を実行します: app_closed イベントが確実に送信されるように、以下の手順を実行します:
1. heartbeat スレッド停止 1. app_closed イベント送信を同期待機
2. app_closed イベント送信を同期待機 2. Aptabase クライアント停止(フラッシュ含む)
3. Aptabase クライアント停止(フラッシュ含む) 3. イベントループ完全停止を待機
4. イベントループ完全停止を待機
""" """
try: try:
# Step 1: Heartbeat 停止5分待機中の送信を防ぐ # app_closed 送信とクライアント停止を同期待機
self.heartbeat.stop()
# Step 2-3: app_closed 送信とクライアント停止を同期待機
if self.state.is_enabled(): if self.state.is_enabled():
try: try:
# _run_async で最大5秒間待機Aptabase のフラッシュ含む) # _run_async で最大5秒間待機Aptabase のフラッシュ含む)
@@ -135,7 +136,7 @@ class Telemetry:
except Exception: except Exception:
pass pass
# Step 4: イベントループを完全停止(フラッシュ確認を待つ) # イベントループを完全停止(フラッシュ確認を待つ)
# sidecar 終了前に確実に完了させるため、タイムアウトを長めに設定 # sidecar 終了前に確実に完了させるため、タイムアウトを長めに設定
self._stop_event_loop(timeout=5.0) self._stop_event_loop(timeout=5.0)
except Exception: except Exception:
@@ -144,6 +145,7 @@ class Telemetry:
finally: finally:
# 状態をリセット # 状態をリセット
self.state.reset() self.state.reset()
self._init_called = False # 初期化フラグもリセット
async def _shutdown_async(self): async def _shutdown_async(self):
"""非同期終了処理""" """非同期終了処理"""
@@ -160,7 +162,6 @@ class Telemetry:
"""コア機能イベント送信(同期インターフェース)""" """コア機能イベント送信(同期インターフェース)"""
if not self.state.is_enabled(): if not self.state.is_enabled():
return return
self.state.touch_activity()
if self.core.is_duplicate_core_feature(feature): if self.core.is_duplicate_core_feature(feature):
return return
self._schedule_async(self._track_core_feature_async(feature)) self._schedule_async(self._track_core_feature_async(feature))
@@ -170,11 +171,6 @@ class Telemetry:
await self.core.send_event("core_feature", {"feature": feature}) await self.core.send_event("core_feature", {"feature": feature})
self.state.record_feature_sent(feature) self.state.record_feature_sent(feature)
def touch_activity(self):
"""アクティビティ時刻更新"""
if self.state.is_enabled():
self.state.touch_activity()
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""有効状態確認""" """有効状態確認"""
return self.state.is_enabled() return self.state.is_enabled()
@@ -190,7 +186,6 @@ if __name__ == "__main__":
telemetry.init(enabled=True) telemetry.init(enabled=True)
telemetry.track("debug_test", {"message": "telemetry main demo"}) telemetry.track("debug_test", {"message": "telemetry main demo"})
telemetry.track_core_feature("text_input") telemetry.track_core_feature("text_input")
telemetry.touch_activity()
print("state:", telemetry.get_state()) print("state:", telemetry.get_state())
# イベント送信完了を待つ # イベント送信完了を待つ

View File

@@ -1,6 +1,7 @@
""" """
Aptabase SDK ラッパー(非同期版) Aptabase SDK ラッパー(非同期版)
""" """
import logging
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
# Aptabase SDK のインポート # Aptabase SDK のインポート
@@ -11,10 +12,12 @@ except ImportError:
class AptabaseWrapper: class AptabaseWrapper:
APP_KEY = "A-US-2082730845" APP_KEY = "A-US-6044063021"
def __init__(self): def __init__(self):
self.client = None self.client = None
# Suppress noisy logs from the Aptabase SDK (only CRITICAL allowed)
logging.getLogger("aptabase").setLevel(logging.CRITICAL)
async def start(self, app_version: str = "1.0.0"): async def start(self, app_version: str = "1.0.0"):
"""Aptabase クライアント開始""" """Aptabase クライアント開始"""

View File

@@ -1,53 +0,0 @@
"""
Heartbeat スレッド管理
- 5分間隔でアクティブ確認
- 操作なしで5分経過したら送信停止
"""
from datetime import datetime
from threading import Thread, Event
import time
class HeartbeatManager:
INTERVAL = 300 # 5 minutes
TIMEOUT = 300 # 5 minutes
def __init__(self, state, core, schedule_async):
self.state = state
self.core = core
self.schedule_async = schedule_async
self.thread = None
self._stop_event = Event()
def start(self):
"""Heartbeat スレッド開始"""
if self.thread is not None and self.thread.is_alive():
return
self._stop_event.clear()
self.thread = Thread(target=self._run, daemon=True, name="telemetry_heartbeat")
self.thread.start()
def stop(self):
"""Heartbeat スレッド停止"""
self._stop_event.set()
if self.thread is not None:
self.thread.join(timeout=1.0)
self.thread = None
def _run(self):
"""Heartbeat ループ"""
while not self._stop_event.is_set():
time.sleep(self.INTERVAL)
if not self.state.is_enabled():
continue
last_activity = self.state.get_last_activity()
if last_activity is None:
continue
elapsed = (datetime.now() - last_activity).total_seconds()
if elapsed < self.TIMEOUT:
# アクティブなら heartbeat 送信(非同期スケジュール)
if self.core.client is not None:
self.schedule_async(self.core.send_event("session_heartbeat"))

View File

@@ -11,7 +11,6 @@ from threading import Lock
class TelemetryState: class TelemetryState:
def __init__(self): def __init__(self):
self._enabled = True # デフォルト有効 self._enabled = True # デフォルト有効
self._last_activity = None
self._session_features_sent = set() self._session_features_sent = set()
self._lock = Lock() self._lock = Lock()
@@ -27,16 +26,6 @@ class TelemetryState:
with self._lock: with self._lock:
return self._enabled return self._enabled
def touch_activity(self):
"""最終操作時刻更新"""
with self._lock:
self._last_activity = datetime.now()
def get_last_activity(self):
"""最終操作時刻取得"""
with self._lock:
return self._last_activity
def record_feature_sent(self, feature: str): def record_feature_sent(self, feature: str):
"""送信済み機能を記録""" """送信済み機能を記録"""
with self._lock: with self._lock:
@@ -50,7 +39,6 @@ class TelemetryState:
def reset(self): def reset(self):
"""状態をリセット""" """状態をリセット"""
with self._lock: with self._lock:
self._last_activity = None
self._session_features_sent.clear() self._session_features_sent.clear()
def get_debug_info(self) -> dict: def get_debug_info(self) -> dict:
@@ -58,6 +46,5 @@ class TelemetryState:
with self._lock: with self._lock:
return { return {
"enabled": self._enabled, "enabled": self._enabled,
"last_activity": self._last_activity.isoformat() if self._last_activity else None,
"session_features_sent": list(self._session_features_sent), "session_features_sent": list(self._session_features_sent),
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "VRCT", "productName": "VRCT",
"version": "3.4.0", "version": "3.4.2",
"identifier": "com.vrct.app", "identifier": "com.vrct.app",
"build": { "build": {
"beforeDevCommand": "", "beforeDevCommand": "",

View File

@@ -202,6 +202,7 @@ export const SETTINGS_ARRAY = [
default_value: "", default_value: "",
ui_template_id: "select", ui_template_id: "select",
logics_template_id: "get_set", logics_template_id: "get_set",
add_endpoint_run_array: ["from_backend"],
base_endpoint_name: "selected_translation_compute_type", base_endpoint_name: "selected_translation_compute_type",
}, },
{ {
@@ -517,6 +518,7 @@ export const SETTINGS_ARRAY = [
default_value: "", default_value: "",
ui_template_id: "select", ui_template_id: "select",
logics_template_id: "get_set", logics_template_id: "get_set",
add_endpoint_run_array: ["from_backend"],
base_endpoint_name: "selected_transcription_compute_type", base_endpoint_name: "selected_transcription_compute_type",
}, },
{ {

View File

@@ -99,12 +99,12 @@ const OpenVrcMicMuteSyncQuickSetting = () => {
}; };
const SoftwareUpdateAvailableButton = () => { const SoftwareUpdateAvailableButton = () => {
const { currentLatestSoftwareVersionInfo } = useSoftwareVersion();
const { t } = useI18n(); const { t } = useI18n();
if (currentLatestSoftwareVersionInfo.data.is_update_available === false) return null; const { currentLatestSoftwareVersionInfo } = useSoftwareVersion();
const { updateOpenedQuickSetting } = useStore_OpenedQuickSetting(); const { updateOpenedQuickSetting } = useStore_OpenedQuickSetting();
if (currentLatestSoftwareVersionInfo.data.is_update_available === false) return null;
return ( return (
<button className={styles.software_update_button} onClick={()=>updateOpenedQuickSetting("update_software")}> <button className={styles.software_update_button} onClick={()=>updateOpenedQuickSetting("update_software")}>
<RefreshSvg className={styles.refresh_svg}/> <RefreshSvg className={styles.refresh_svg}/>