shuotype 0.5.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.
- shuotype/__init__.py +3 -0
- shuotype/__main__.py +4 -0
- shuotype/asr.py +106 -0
- shuotype/assets/shuotype-icon-orange.png +0 -0
- shuotype/assets/shuotype-icon-orange.svg +10 -0
- shuotype/config.py +163 -0
- shuotype/daemon.py +699 -0
- shuotype/hotkey.py +424 -0
- shuotype/injector.py +144 -0
- shuotype/live_inject.py +123 -0
- shuotype/log.py +23 -0
- shuotype/polish.py +121 -0
- shuotype/recorder.py +196 -0
- shuotype/stream_asr.py +279 -0
- shuotype/token.py +164 -0
- shuotype/tray.py +201 -0
- shuotype/tray_anim.py +65 -0
- shuotype-0.5.2.0.dist-info/METADATA +11 -0
- shuotype-0.5.2.0.dist-info/RECORD +25 -0
- shuotype-0.5.2.0.dist-info/WHEEL +5 -0
- shuotype-0.5.2.0.dist-info/entry_points.txt +2 -0
- shuotype-0.5.2.0.dist-info/top_level.txt +3 -0
- shuotype_cli/__init__.py +0 -0
- shuotype_cli/cli.py +395 -0
- shuotype_help/version.txt +1 -0
shuotype/__init__.py
ADDED
shuotype/__main__.py
ADDED
shuotype/asr.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .token import TokenManager
|
|
9
|
+
|
|
10
|
+
ASR_URL = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsrError(Exception):
|
|
14
|
+
"""语音识别失败。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsrAuthError(AsrError):
|
|
18
|
+
"""NLS Token 被拒绝。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsrTransientError(AsrError):
|
|
22
|
+
"""可重试的识别错误。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsrClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config: Config,
|
|
29
|
+
tokens: TokenManager,
|
|
30
|
+
session=None,
|
|
31
|
+
timeout: float = 15,
|
|
32
|
+
sleep=time.sleep,
|
|
33
|
+
):
|
|
34
|
+
self.config = config
|
|
35
|
+
self.tokens = tokens
|
|
36
|
+
self.session = session or requests.Session()
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.sleep = sleep
|
|
39
|
+
|
|
40
|
+
def recognize(self, pcm: bytes) -> str:
|
|
41
|
+
auth_retried = transient_retried = False
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
return self._post(pcm, self.tokens.get())
|
|
45
|
+
except AsrAuthError:
|
|
46
|
+
if auth_retried:
|
|
47
|
+
raise
|
|
48
|
+
auth_retried = True
|
|
49
|
+
self.tokens.invalidate()
|
|
50
|
+
except AsrTransientError:
|
|
51
|
+
if transient_retried:
|
|
52
|
+
raise
|
|
53
|
+
transient_retried = True
|
|
54
|
+
self.sleep(0.5)
|
|
55
|
+
|
|
56
|
+
def _post(self, pcm: bytes, token: str) -> str:
|
|
57
|
+
params = {
|
|
58
|
+
"appkey": self.config.app_key,
|
|
59
|
+
"format": "pcm",
|
|
60
|
+
"sample_rate": "16000",
|
|
61
|
+
"enable_punctuation_prediction": "true",
|
|
62
|
+
"enable_inverse_text_normalization": "true",
|
|
63
|
+
}
|
|
64
|
+
try:
|
|
65
|
+
response = self.session.post(
|
|
66
|
+
ASR_URL,
|
|
67
|
+
params=params,
|
|
68
|
+
data=pcm,
|
|
69
|
+
headers={
|
|
70
|
+
"X-NLS-Token": token,
|
|
71
|
+
"Content-Type": "application/octet-stream",
|
|
72
|
+
},
|
|
73
|
+
timeout=self.timeout,
|
|
74
|
+
)
|
|
75
|
+
except requests.RequestException as exc:
|
|
76
|
+
raise AsrTransientError(
|
|
77
|
+
f"识别请求失败: {type(exc).__name__}"
|
|
78
|
+
) from None
|
|
79
|
+
if response.status_code in (408, 429) or response.status_code >= 500:
|
|
80
|
+
raise AsrTransientError(
|
|
81
|
+
f"识别服务暂时不可用: HTTP {response.status_code}"
|
|
82
|
+
)
|
|
83
|
+
if response.status_code != 200:
|
|
84
|
+
body = (response.text or "")[:200]
|
|
85
|
+
detail = f"识别请求失败: HTTP {response.status_code}"
|
|
86
|
+
if body:
|
|
87
|
+
detail = f"{detail} {body}"
|
|
88
|
+
raise AsrError(detail)
|
|
89
|
+
try:
|
|
90
|
+
data = response.json()
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise AsrError("识别响应不是合法 JSON") from exc
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
raise AsrError("识别响应格式异常")
|
|
95
|
+
status = data.get("status")
|
|
96
|
+
if status == 20000000:
|
|
97
|
+
return data.get("result") or ""
|
|
98
|
+
if status == 40000001:
|
|
99
|
+
raise AsrAuthError("NLS Token 无效或已过期")
|
|
100
|
+
message = data.get("message") or ""
|
|
101
|
+
detail = f"{status} {message}".strip()
|
|
102
|
+
if status == 40000005:
|
|
103
|
+
raise AsrTransientError(f"识别服务限流: {detail}")
|
|
104
|
+
if status == 50000000:
|
|
105
|
+
raise AsrTransientError(f"识别服务端错误: {detail}")
|
|
106
|
+
raise AsrError(f"识别失败: {detail}")
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="512" height="512" role="img" aria-label="ShuoType app icon" xmlns:c2pa="http://c2pa.org/manifest">
|
|
2
|
+
<rect width="160" height="160" rx="36" fill="#D97757"/>
|
|
3
|
+
<rect x="29" y="68" width="10" height="24" rx="5" fill="#141413"/>
|
|
4
|
+
<rect x="49" y="55" width="10" height="50" rx="5" fill="#141413"/>
|
|
5
|
+
<rect x="101" y="55" width="10" height="50" rx="5" fill="#141413"/>
|
|
6
|
+
<rect x="121" y="68" width="10" height="24" rx="5" fill="#141413"/>
|
|
7
|
+
<rect x="76" y="40" width="8" height="80" rx="4" fill="#FAF9F5"/>
|
|
8
|
+
<rect x="66" y="38" width="28" height="7" rx="3.5" fill="#FAF9F5"/>
|
|
9
|
+
<rect x="66" y="115" width="28" height="7" rx="3.5" fill="#FAF9F5"/>
|
|
10
|
+
<metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5N2VjOWExNi1lZmI1LTQwNmYtOWM0MS03NDJkM2U1Zjg0YzUAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaHTRW7reDLLvuRNoYQQ2NakAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDoyMjRlYzQxMy0wMmZjLTQyMDgtYjY4Mi03YzA4YjliZjA1MWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNouM3+FeIlml5wezVnY/RKPgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggsf1dzntgxtLgtM6aKtUzpy0wE+XtrWgDMwiDiR5vC42kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaBH+BWR6yx13LAb1WXajqw8AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRMAAAAAAAAAAAAAAAAZGhhc2hYIAaDziOUWz+7p2KAj0VupM7kEr9817osQVDanCTT/hyaZG5hbWVuanVtYmYgbWFuaWZlc3RqZXhjbHVzaW9uc4GiZXN0YXJ0GQLSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjk3ZWM5YTE2LWVmYjUtNDA2Zi05YzQxLTc0MmQzZTVmODRjNS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjAyZDYzMmM0LTk0MjYtNDljMC04YjY5LWRkMTJkZDRiN2NjYnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCx/V3Oe2DG0uC0zpoq1TOnLTAT5e2taAMzCIOJHm8LjaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggmyEy8ypEkSXVYEVOtjnnH1JO/pMsc8yi+upBA2LcUo2iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggi2Ymn1Gy6qapuntAUFd/izebB/OEsflskyafPC1LwD10Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQMCF4t79NseMlNyqts16cQzOB+Czs6ZTGd3+FwNct093gwh0W5CDAHOQ34N+Ijbtn2JRegT9z7L/CIz4P4244aE=</c2pa:manifest></metadata></svg>
|
shuotype/config.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
from .hotkey import HotkeyError, parse_hotkey
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
"""配置缺失或格式错误。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _xdg_dir(name: str, fallback: Path) -> Path:
|
|
18
|
+
value = os.environ.get(name, "")
|
|
19
|
+
if value:
|
|
20
|
+
candidate = Path(value)
|
|
21
|
+
if candidate.is_absolute():
|
|
22
|
+
return candidate
|
|
23
|
+
return fallback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_dir() -> Path:
|
|
27
|
+
return _xdg_dir("XDG_CONFIG_HOME", Path.home() / ".config") / "shuotype"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_path() -> Path:
|
|
31
|
+
return config_dir() / "config.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cache_dir() -> Path:
|
|
35
|
+
return _xdg_dir("XDG_CACHE_HOME", Path.home() / ".cache") / "shuotype"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def runtime_dir() -> Path:
|
|
39
|
+
value = os.environ.get("XDG_RUNTIME_DIR", "")
|
|
40
|
+
if value and Path(value).is_absolute():
|
|
41
|
+
return Path(value) / "shuotype"
|
|
42
|
+
return Path(tempfile.gettempdir()) / f"shuotype-{os.getuid()}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
POLISH_MODES = {"off", "fillers", "polish"}
|
|
46
|
+
PASTE_KEYS = {"ctrl+shift+v", "ctrl+v", "shift+insert"}
|
|
47
|
+
ASR_MODES = {"oneshot", "streaming"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Config:
|
|
52
|
+
access_key_id: str = ""
|
|
53
|
+
access_key_secret: str = field(default="", repr=False)
|
|
54
|
+
app_key: str = ""
|
|
55
|
+
region: str = "cn-shanghai"
|
|
56
|
+
deepseek_api_key: str = field(default="", repr=False)
|
|
57
|
+
deepseek_base_url: str = "https://api.deepseek.com"
|
|
58
|
+
deepseek_model: str = "deepseek-chat"
|
|
59
|
+
polish_mode: str = "off"
|
|
60
|
+
asr_mode: str = "oneshot"
|
|
61
|
+
hotkey: str = "右Ctrl"
|
|
62
|
+
paste_keys: str = "ctrl+shift+v"
|
|
63
|
+
audio_device: str | int = "pulse"
|
|
64
|
+
max_seconds: int = 60
|
|
65
|
+
notifications: bool = True
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(
|
|
69
|
+
cls,
|
|
70
|
+
path: Path | None = None,
|
|
71
|
+
environ: Mapping[str, str] | None = None,
|
|
72
|
+
) -> "Config":
|
|
73
|
+
path = path or config_path()
|
|
74
|
+
env = dict(os.environ if environ is None else environ)
|
|
75
|
+
data: dict = {}
|
|
76
|
+
if path.exists():
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise ConfigError(f"配置文件格式错误: {path}: {exc}") from exc
|
|
81
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
82
|
+
raise ConfigError(f"无法读取配置文件: {path}: {exc}") from exc
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise ConfigError(f"配置文件根节点必须是 JSON 对象: {path}")
|
|
85
|
+
if data.get("hotkey") == "<Control><Alt>v":
|
|
86
|
+
# 旧版本默认值;GNOME 快捷键已移除,迁移为新的默认热键。
|
|
87
|
+
data["hotkey"] = cls.__dataclass_fields__["hotkey"].default
|
|
88
|
+
known = set(cls.__dataclass_fields__)
|
|
89
|
+
cfg = cls(**{key: value for key, value in data.items() if key in known})
|
|
90
|
+
cfg.access_key_id = env.get("SHUOTYPE_AK_ID") or cfg.access_key_id
|
|
91
|
+
cfg.access_key_secret = env.get("SHUOTYPE_AK_SECRET") or cfg.access_key_secret
|
|
92
|
+
cfg.app_key = env.get("SHUOTYPE_APPKEY") or cfg.app_key
|
|
93
|
+
cfg.deepseek_api_key = env.get("DEEPSEEK_API_KEY") or cfg.deepseek_api_key
|
|
94
|
+
cfg.validate()
|
|
95
|
+
return cfg
|
|
96
|
+
|
|
97
|
+
def validate(self) -> None:
|
|
98
|
+
for name in (
|
|
99
|
+
"access_key_id",
|
|
100
|
+
"access_key_secret",
|
|
101
|
+
"app_key",
|
|
102
|
+
"deepseek_api_key",
|
|
103
|
+
"deepseek_base_url",
|
|
104
|
+
"deepseek_model",
|
|
105
|
+
"polish_mode",
|
|
106
|
+
"asr_mode",
|
|
107
|
+
"hotkey",
|
|
108
|
+
"paste_keys",
|
|
109
|
+
"region",
|
|
110
|
+
):
|
|
111
|
+
if not isinstance(getattr(self, name), str):
|
|
112
|
+
raise ConfigError(f"{name} 必须是字符串")
|
|
113
|
+
if not isinstance(self.notifications, bool):
|
|
114
|
+
raise ConfigError("notifications 必须是布尔值")
|
|
115
|
+
if isinstance(self.audio_device, bool) or not isinstance(
|
|
116
|
+
self.audio_device, (str, int)
|
|
117
|
+
):
|
|
118
|
+
raise ConfigError("audio_device 必须是字符串或整数")
|
|
119
|
+
if self.region != "cn-shanghai":
|
|
120
|
+
raise ConfigError("目前仅支持 cn-shanghai")
|
|
121
|
+
if self.polish_mode not in POLISH_MODES:
|
|
122
|
+
raise ConfigError(f"polish_mode 必须是 {sorted(POLISH_MODES)} 之一")
|
|
123
|
+
if self.asr_mode not in ASR_MODES:
|
|
124
|
+
raise ConfigError(f"asr_mode 必须是 {sorted(ASR_MODES)} 之一")
|
|
125
|
+
if (
|
|
126
|
+
isinstance(self.max_seconds, bool)
|
|
127
|
+
or not isinstance(self.max_seconds, int)
|
|
128
|
+
or self.max_seconds <= 0
|
|
129
|
+
):
|
|
130
|
+
raise ConfigError("max_seconds 必须是正整数")
|
|
131
|
+
if self.max_seconds > 60:
|
|
132
|
+
raise ConfigError("max_seconds 不能超过 60(一句话识别上限)")
|
|
133
|
+
if self.paste_keys not in PASTE_KEYS:
|
|
134
|
+
raise ConfigError("paste_keys 只支持 ctrl+shift+v / ctrl+v / shift+insert")
|
|
135
|
+
try:
|
|
136
|
+
parse_hotkey(self.hotkey)
|
|
137
|
+
except HotkeyError as exc:
|
|
138
|
+
raise ConfigError(f"hotkey 配置无效: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
def require_asr(self) -> None:
|
|
141
|
+
missing = [
|
|
142
|
+
name
|
|
143
|
+
for name in ("access_key_id", "access_key_secret", "app_key")
|
|
144
|
+
if not getattr(self, name)
|
|
145
|
+
]
|
|
146
|
+
if missing:
|
|
147
|
+
raise ConfigError(f"缺少配置: {', '.join(missing)}(运行 shuotype init)")
|
|
148
|
+
|
|
149
|
+
def require_deepseek(self) -> None:
|
|
150
|
+
if not self.deepseek_api_key:
|
|
151
|
+
raise ConfigError("polish_mode 已开启,但缺少 deepseek_api_key")
|
|
152
|
+
|
|
153
|
+
def save(self, path: Path | None = None) -> Path:
|
|
154
|
+
self.validate()
|
|
155
|
+
path = path or config_path()
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
payload = asdict(self)
|
|
158
|
+
content = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
159
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
160
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
161
|
+
handle.write(content)
|
|
162
|
+
os.chmod(path, 0o600)
|
|
163
|
+
return path
|