voice-input 1.0.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.
@@ -0,0 +1,4 @@
1
+ """跨设备语音输入传输系统 - 将手机端语音识别文本传送到电脑"""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "mofanx"
@@ -0,0 +1,5 @@
1
+ """支持 python -m voice_input 方式运行"""
2
+
3
+ from .cli import main
4
+
5
+ main()
voice_input/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """命令行入口 - 支持参数与配置文件"""
2
+
3
+ import argparse
4
+ import logging
5
+ import platform
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .config import build_config
10
+ from .utils import get_local_ip
11
+
12
+
13
+ def parse_args(argv=None):
14
+ p = argparse.ArgumentParser(
15
+ prog="voice-input",
16
+ description="跨设备语音输入传输系统 - 将手机端语音识别文本传送到电脑",
17
+ )
18
+ p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
19
+ p.add_argument("-c", "--config", metavar="FILE", help="YAML 配置文件路径")
20
+
21
+ # 网络
22
+ net = p.add_argument_group("网络")
23
+ net.add_argument("-H", "--host", metavar="ADDR", help="监听地址 (默认 0.0.0.0)")
24
+ net.add_argument("-p", "--port", type=int, metavar="PORT", help="监听端口 (默认 8080)")
25
+ net.add_argument(
26
+ "--allowed-ips",
27
+ metavar="CIDR",
28
+ help="IP 白名单,逗号分隔 (如 192.168.0.0/16,10.0.0.0/8)",
29
+ )
30
+
31
+ # 安全
32
+ sec = p.add_argument_group("安全")
33
+ sec.add_argument("-t", "--token", metavar="TOKEN", help="鉴权 Token")
34
+ sec.add_argument(
35
+ "--require-token",
36
+ action="store_true",
37
+ default=None,
38
+ help="强制启用 Token 鉴权 (未设 --token 时自动生成)",
39
+ )
40
+
41
+ # 行为
42
+ beh = p.add_argument_group("行为")
43
+ beh.add_argument(
44
+ "--no-auto-paste",
45
+ action="store_true",
46
+ default=False,
47
+ help="默认不自动粘贴,仅复制到剪贴板",
48
+ )
49
+ beh.add_argument("--history-size", type=int, metavar="N", help="历史记录条数 (默认 50)")
50
+
51
+ # 生产
52
+ prod = p.add_argument_group("生产部署")
53
+ prod.add_argument(
54
+ "--production",
55
+ action="store_true",
56
+ default=False,
57
+ help="使用 waitress 作为生产 WSGI 服务器",
58
+ )
59
+ prod.add_argument("--workers", type=int, metavar="N", help="WSGI 工作线程数 (默认 4)")
60
+ prod.add_argument(
61
+ "--log-level",
62
+ choices=["debug", "info", "warning", "error"],
63
+ help="日志级别 (默认 info)",
64
+ )
65
+
66
+ return p.parse_args(argv)
67
+
68
+
69
+ def main(argv=None):
70
+ args = parse_args(argv)
71
+
72
+ # 将 argparse 结果转为 config dict(None 表示未指定)
73
+ cli_dict = {}
74
+ if args.host is not None:
75
+ cli_dict["host"] = args.host
76
+ if args.port is not None:
77
+ cli_dict["port"] = args.port
78
+ if args.allowed_ips is not None:
79
+ cli_dict["allowed_ips"] = [s.strip() for s in args.allowed_ips.split(",") if s.strip()]
80
+ if args.token is not None:
81
+ cli_dict["token"] = args.token
82
+ if args.require_token is True:
83
+ cli_dict["require_token"] = True
84
+ if args.no_auto_paste:
85
+ cli_dict["auto_paste"] = False
86
+ if args.history_size is not None:
87
+ cli_dict["history_size"] = args.history_size
88
+ if args.workers is not None:
89
+ cli_dict["workers"] = args.workers
90
+ if args.log_level is not None:
91
+ cli_dict["log_level"] = args.log_level
92
+
93
+ # 构建配置
94
+ cfg = build_config(cli_args=cli_dict, config_file=args.config)
95
+
96
+ # 配置日志
97
+ logging.basicConfig(
98
+ level=getattr(logging, cfg.log_level.upper(), logging.INFO),
99
+ format="%(asctime)s - %(levelname)s - %(message)s",
100
+ )
101
+
102
+ # 创建应用
103
+ from .server import create_app
104
+
105
+ app = create_app(cfg)
106
+
107
+ # 打印启动信息
108
+ local_ip = get_local_ip()
109
+ banner = f"""
110
+ {'=' * 60}
111
+ 跨设备语音输入传输系统 v{__version__}
112
+ {'=' * 60}
113
+ 服务地址: http://{local_ip}:{cfg.port}
114
+ 手机页面: http://{local_ip}:{cfg.port}/
115
+ 状态检查: http://{local_ip}:{cfg.port}/status
116
+ API 接口: http://{local_ip}:{cfg.port}/input (POST)
117
+ 历史记录: http://{local_ip}:{cfg.port}/history"""
118
+
119
+ if cfg.token:
120
+ banner += f"\n Token: {cfg.token}"
121
+ else:
122
+ banner += "\n Token: 未启用"
123
+
124
+ is_windows = platform.system() == "Windows"
125
+ banner += f"""
126
+ {'=' * 60}
127
+ 提示: 手机和电脑需在同一局域网"""
128
+ if not is_windows:
129
+ banner += "\n 提示: 自动粘贴功能需要 root/sudo 权限 (Linux)"
130
+ banner += f"\n{'=' * 60}"
131
+ print(banner)
132
+
133
+ # 启动服务器
134
+ if args.production:
135
+ _run_production(app, cfg)
136
+ else:
137
+ app.run(host=cfg.host, port=cfg.port, debug=False)
138
+
139
+
140
+ def _run_production(app, cfg):
141
+ """使用 waitress 启动生产服务器"""
142
+ try:
143
+ from waitress import serve
144
+
145
+ workers = cfg.workers if cfg.workers > 1 else 4
146
+ print(f" [waitress] threads={workers}")
147
+ serve(app, host=cfg.host, port=cfg.port, threads=workers)
148
+ except ImportError:
149
+ print(
150
+ "waitress 未安装,回退到 Flask 开发服务器。\n"
151
+ "生产环境请执行: pip install waitress",
152
+ file=sys.stderr,
153
+ )
154
+ app.run(host=cfg.host, port=cfg.port, debug=False)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
voice_input/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """配置管理 - 支持 YAML 配置文件、环境变量、CLI 参数三级合并"""
2
+
3
+ import os
4
+ import secrets
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Optional
7
+
8
+
9
+ @dataclass
10
+ class AppConfig:
11
+ """应用配置,优先级:CLI 参数 > 环境变量 > 配置文件 > 默认值"""
12
+
13
+ # 网络
14
+ host: str = "0.0.0.0"
15
+ port: int = 8080
16
+
17
+ # 安全
18
+ allowed_ips: List[str] = field(
19
+ default_factory=lambda: ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"]
20
+ )
21
+ token: str = ""
22
+ require_token: bool = False
23
+
24
+ # 行为
25
+ auto_paste: bool = True
26
+ history_size: int = 50
27
+ max_content_length: int = 10 * 1024 # 10KB
28
+
29
+ # 生产部署
30
+ workers: int = 1
31
+ log_level: str = "info"
32
+
33
+ def __post_init__(self):
34
+ if self.require_token and not self.token:
35
+ self.token = secrets.token_urlsafe(18)
36
+
37
+
38
+ def _parse_bool(val: str) -> bool:
39
+ return val.strip().lower() in {"1", "true", "yes", "on"}
40
+
41
+
42
+ def load_from_yaml(path: str) -> dict:
43
+ """从 YAML 文件加载配置"""
44
+ try:
45
+ import yaml
46
+ except ImportError:
47
+ raise ImportError(
48
+ "需要 PyYAML 来读取配置文件,请执行: pip install pyyaml"
49
+ )
50
+ with open(path, "r", encoding="utf-8") as f:
51
+ data = yaml.safe_load(f) or {}
52
+ return data
53
+
54
+
55
+ def load_from_env() -> dict:
56
+ """从环境变量加载配置(VOICE_INPUT_ 前缀)"""
57
+ mapping = {
58
+ "VOICE_INPUT_HOST": "host",
59
+ "VOICE_INPUT_PORT": "port",
60
+ "VOICE_INPUT_ALLOWED_IPS": "allowed_ips",
61
+ "VOICE_INPUT_TOKEN": "token",
62
+ "VOICE_INPUT_REQUIRE_TOKEN": "require_token",
63
+ "VOICE_INPUT_AUTO_PASTE": "auto_paste",
64
+ "VOICE_INPUT_HISTORY_SIZE": "history_size",
65
+ "VOICE_INPUT_MAX_CONTENT_LENGTH": "max_content_length",
66
+ "VOICE_INPUT_WORKERS": "workers",
67
+ "VOICE_INPUT_LOG_LEVEL": "log_level",
68
+ }
69
+ result = {}
70
+ for env_key, cfg_key in mapping.items():
71
+ val = os.environ.get(env_key)
72
+ if val is None:
73
+ continue
74
+ result[cfg_key] = val
75
+ return result
76
+
77
+
78
+ def _coerce(key: str, val):
79
+ """将字符串值转为目标类型"""
80
+ bool_keys = {"require_token", "auto_paste"}
81
+ int_keys = {"port", "history_size", "max_content_length", "workers"}
82
+ list_keys = {"allowed_ips"}
83
+
84
+ if key in bool_keys and isinstance(val, str):
85
+ return _parse_bool(val)
86
+ if key in int_keys and isinstance(val, str):
87
+ return int(val)
88
+ if key in list_keys and isinstance(val, str):
89
+ return [s.strip() for s in val.split(",") if s.strip()]
90
+ return val
91
+
92
+
93
+ def build_config(
94
+ cli_args: Optional[dict] = None,
95
+ config_file: Optional[str] = None,
96
+ ) -> AppConfig:
97
+ """三级合并构建最终配置"""
98
+ merged: dict = {}
99
+
100
+ # 1. 配置文件
101
+ if config_file:
102
+ merged.update(load_from_yaml(config_file))
103
+
104
+ # 2. 环境变量覆盖
105
+ merged.update(load_from_env())
106
+
107
+ # 3. CLI 参数覆盖(过滤 None 值)
108
+ if cli_args:
109
+ for k, v in cli_args.items():
110
+ if v is not None:
111
+ merged[k] = v
112
+
113
+ # 类型转换
114
+ coerced = {k: _coerce(k, v) for k, v in merged.items()}
115
+
116
+ return AppConfig(**coerced)
voice_input/server.py ADDED
@@ -0,0 +1,341 @@
1
+ """Flask 应用与路由定义"""
2
+
3
+ import io
4
+ import csv
5
+ import json
6
+ import os
7
+ import platform
8
+ import time
9
+ import logging
10
+ import threading
11
+ from collections import deque
12
+
13
+ from flask import Flask, request, jsonify, render_template, Response
14
+
15
+ from .config import AppConfig
16
+ from .utils import get_local_ip, get_client_ip, is_ip_allowed, is_token_valid
17
+
18
+
19
+ _PASTE_DELAY = 0.08 # 剪贴板写入后等待时间(秒),确保 X11 剪贴板同步完成
20
+ _RESTORE_DELAY = 0.15 # 粘贴操作后等待时间(秒),确保目标程序完成读取剪贴板
21
+
22
+
23
+ def _do_keyboard_action(action: str, text: str):
24
+ """执行键盘操作(跨平台),在 copy 之后调用"""
25
+ try:
26
+ import keyboard
27
+ except ImportError:
28
+ logging.warning("keyboard 模块未安装,跳过自动粘贴(文本已复制到剪贴板)")
29
+ return
30
+ except Exception as e:
31
+ logging.warning(f"keyboard 模块加载失败: {e}")
32
+ return
33
+
34
+ try:
35
+ time.sleep(_PASTE_DELAY)
36
+ is_windows = platform.system() == "Windows"
37
+ if action == "paste":
38
+ keyboard.press_and_release("ctrl+v")
39
+ logging.info("已执行粘贴操作")
40
+ elif action == "paste_terminal":
41
+ if is_windows:
42
+ keyboard.press_and_release("ctrl+v")
43
+ else:
44
+ keyboard.press_and_release("ctrl+shift+v")
45
+ logging.info("已执行终端粘贴操作")
46
+ elif action == "type":
47
+ keyboard.write(text)
48
+ logging.info("已执行键入操作")
49
+ except Exception as e:
50
+ logging.warning(f"自动粘贴失败(文本已复制到剪贴板): {e}")
51
+
52
+
53
+ def _save_clipboard() -> bytes | None:
54
+ """保存当前剪贴板内容,失败返回 None"""
55
+ try:
56
+ import pyclip
57
+ return pyclip.paste()
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _restore_clipboard(old_content: bytes | None):
63
+ """恢复剪贴板内容"""
64
+ if old_content is None:
65
+ return
66
+ try:
67
+ import pyclip
68
+ time.sleep(_RESTORE_DELAY)
69
+ pyclip.copy(old_content)
70
+ logging.info("已恢复原有剪贴板内容")
71
+ except Exception as e:
72
+ logging.warning(f"恢复剪贴板失败: {e}")
73
+
74
+
75
+ def create_app(config: AppConfig) -> Flask:
76
+ """应用工厂:根据配置创建 Flask 实例"""
77
+
78
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
79
+ app = Flask(__name__, template_folder=template_dir)
80
+ app.config["MAX_CONTENT_LENGTH"] = config.max_content_length
81
+
82
+ # 存储配置到 app 上下文
83
+ app.voice_config = config
84
+ app.voice_history = deque(maxlen=config.history_size)
85
+ app.voice_history_lock = threading.Lock()
86
+ app.voice_history_counter = 0
87
+
88
+ # ==================== 路由 ====================
89
+
90
+ @app.route("/", methods=["GET"])
91
+ def index():
92
+ local_ip = get_local_ip()
93
+ cfg = app.voice_config
94
+ return render_template(
95
+ "index.html",
96
+ server_ip=local_ip,
97
+ port=cfg.port,
98
+ require_token=bool(cfg.token) or cfg.require_token,
99
+ auto_paste=cfg.auto_paste,
100
+ platform_name=platform.system(),
101
+ )
102
+
103
+ @app.route("/status", methods=["GET"])
104
+ def status():
105
+ local_ip = get_local_ip()
106
+ cfg = app.voice_config
107
+ return jsonify(
108
+ {
109
+ "code": 200,
110
+ "message": "service running",
111
+ "version": "2.0.0",
112
+ "server_ip": local_ip,
113
+ "port": cfg.port,
114
+ "platform": platform.system(),
115
+ "require_token": bool(cfg.token) or cfg.require_token,
116
+ "auto_paste": cfg.auto_paste,
117
+ "history_size": cfg.history_size,
118
+ "timestamp": int(time.time() * 1000),
119
+ }
120
+ )
121
+
122
+ @app.route("/history", methods=["GET"])
123
+ def get_history():
124
+ with app.voice_history_lock:
125
+ items = list(app.voice_history)
126
+ return jsonify(
127
+ {
128
+ "code": 200,
129
+ "message": "success",
130
+ "items": items,
131
+ "timestamp": int(time.time() * 1000),
132
+ }
133
+ )
134
+
135
+ @app.route("/history/<int:item_id>", methods=["DELETE"])
136
+ def delete_history_item(item_id):
137
+ with app.voice_history_lock:
138
+ before = len(app.voice_history)
139
+ app.voice_history = deque(
140
+ (item for item in app.voice_history if item.get("id") != item_id),
141
+ maxlen=config.history_size,
142
+ )
143
+ removed = before - len(app.voice_history)
144
+ return jsonify({"code": 200, "message": "success", "removed": removed})
145
+
146
+ @app.route("/history", methods=["DELETE"])
147
+ def clear_history():
148
+ with app.voice_history_lock:
149
+ count = len(app.voice_history)
150
+ app.voice_history.clear()
151
+ return jsonify({"code": 200, "message": "success", "cleared": count})
152
+
153
+ @app.route("/history/export", methods=["GET"])
154
+ def export_history():
155
+ fmt = request.args.get("format", "json")
156
+ with app.voice_history_lock:
157
+ items = list(app.voice_history)
158
+
159
+ if fmt == "csv":
160
+ output = io.StringIO()
161
+ writer = csv.writer(output)
162
+ writer.writerow(["id", "time", "text", "action", "device_id", "client_ip"])
163
+ for item in items:
164
+ t = time.strftime(
165
+ "%Y-%m-%d %H:%M:%S", time.localtime(item["server_time"] / 1000)
166
+ )
167
+ writer.writerow(
168
+ [
169
+ item.get("id", ""),
170
+ t,
171
+ item.get("text", ""),
172
+ item.get("action", ""),
173
+ item.get("device_id", ""),
174
+ item.get("client_ip", ""),
175
+ ]
176
+ )
177
+ return Response(
178
+ output.getvalue(),
179
+ mimetype="text/csv",
180
+ headers={"Content-Disposition": "attachment; filename=voice_history.csv"},
181
+ )
182
+ else:
183
+ return Response(
184
+ json.dumps(items, ensure_ascii=False, indent=2),
185
+ mimetype="application/json",
186
+ headers={
187
+ "Content-Disposition": "attachment; filename=voice_history.json"
188
+ },
189
+ )
190
+
191
+ @app.route("/input", methods=["POST"])
192
+ def handle_input():
193
+ cfg = app.voice_config
194
+
195
+ # 1. IP 白名单验证
196
+ client_ip = get_client_ip(request)
197
+ if not is_ip_allowed(client_ip, cfg.allowed_ips):
198
+ logging.warning(f"IP未授权访问: {client_ip}")
199
+ return (
200
+ jsonify(
201
+ {
202
+ "code": 403,
203
+ "message": "IP not allowed",
204
+ "error_detail": "Your IP address is not in the whitelist",
205
+ }
206
+ ),
207
+ 403,
208
+ )
209
+
210
+ # 2. JSON 解析
211
+ try:
212
+ data = request.get_json(force=True)
213
+ except Exception as e:
214
+ logging.error(f"JSON解析失败: {e}")
215
+ return (
216
+ jsonify(
217
+ {
218
+ "code": 400,
219
+ "message": "Invalid JSON format",
220
+ "error_detail": "Request body must be valid JSON",
221
+ }
222
+ ),
223
+ 400,
224
+ )
225
+
226
+ # 3. Token 校验
227
+ if not is_token_valid(request, data, cfg.token, cfg.require_token):
228
+ logging.warning(f"Token校验失败: {client_ip}")
229
+ return (
230
+ jsonify(
231
+ {
232
+ "code": 401,
233
+ "message": "Unauthorized",
234
+ "error_detail": "Invalid or missing token",
235
+ }
236
+ ),
237
+ 401,
238
+ )
239
+
240
+ # 4. 必需字段
241
+ if not data or "text" not in data:
242
+ logging.error("缺少必需字段 'text'")
243
+ return (
244
+ jsonify(
245
+ {
246
+ "code": 400,
247
+ "message": "Missing required field: text",
248
+ "error_detail": 'The "text" field is required',
249
+ }
250
+ ),
251
+ 400,
252
+ )
253
+
254
+ # 5. 解析字段
255
+ text = str(data["text"])
256
+ timestamp = data.get("timestamp", int(time.time() * 1000))
257
+ device_id = data.get("device_id", "unknown")
258
+ action = data.get("action", "paste" if cfg.auto_paste else "copy")
259
+ restore_clipboard = bool(data.get("restore_clipboard", False))
260
+
261
+ # 6. 时间戳偏差警告
262
+ current_time = int(time.time() * 1000)
263
+ if abs(current_time - timestamp) > 30000:
264
+ logging.warning(f"时间戳偏差过大: {current_time - timestamp}ms")
265
+
266
+ # 7. 执行剪贴板和键盘操作
267
+ try:
268
+ import pyclip
269
+
270
+ # 仅在需要恢复且不是"仅复制"模式时保存原剪贴板
271
+ need_restore = restore_clipboard and action != "copy"
272
+ old_clipboard = _save_clipboard() if need_restore else None
273
+
274
+ pyclip.copy(text)
275
+
276
+ with app.voice_history_lock:
277
+ app.voice_history_counter += 1
278
+ app.voice_history.appendleft(
279
+ {
280
+ "id": app.voice_history_counter,
281
+ "server_time": current_time,
282
+ "client_ip": client_ip,
283
+ "device_id": device_id,
284
+ "action": action,
285
+ "text": text,
286
+ }
287
+ )
288
+
289
+ logging.info(
290
+ f"已复制到剪贴板 (长度: {len(text)}, 设备: {device_id}, "
291
+ f"IP: {client_ip}, action: {action})"
292
+ )
293
+
294
+ if action in ("paste", "paste_terminal", "type"):
295
+ _do_keyboard_action(action, text)
296
+
297
+ # 恢复原有剪贴板内容
298
+ if need_restore:
299
+ _restore_clipboard(old_clipboard)
300
+
301
+ return jsonify(
302
+ {
303
+ "code": 200,
304
+ "message": "success",
305
+ "server_time": current_time,
306
+ "processed_text_length": len(text),
307
+ "action": action,
308
+ "device_id": device_id,
309
+ "clipboard_restored": need_restore,
310
+ }
311
+ )
312
+
313
+ except Exception as e:
314
+ logging.error(f"服务器内部错误: {e}")
315
+ return (
316
+ jsonify(
317
+ {
318
+ "code": 500,
319
+ "message": "Internal server error",
320
+ "error_detail": str(e),
321
+ }
322
+ ),
323
+ 500,
324
+ )
325
+
326
+ # ==================== 错误处理器 ====================
327
+
328
+ @app.errorhandler(413)
329
+ def request_entity_too_large(error):
330
+ return (
331
+ jsonify(
332
+ {
333
+ "code": 413,
334
+ "message": "Payload too large",
335
+ "error_detail": f"Exceeds {config.max_content_length} bytes",
336
+ }
337
+ ),
338
+ 413,
339
+ )
340
+
341
+ return app