pairvoice 0.1.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.
Files changed (49) hide show
  1. pairvoice/__init__.py +0 -0
  2. pairvoice/__main__.py +14 -0
  3. pairvoice/audio_state.py +147 -0
  4. pairvoice/bundle.py +18 -0
  5. pairvoice/checker.py +199 -0
  6. pairvoice/cli.py +361 -0
  7. pairvoice/client.py +26 -0
  8. pairvoice/config.py +258 -0
  9. pairvoice/eval_cases.tsv +26 -0
  10. pairvoice/evaluation.py +184 -0
  11. pairvoice/examples/dict.example.tsv +3 -0
  12. pairvoice/examples/prompt.example.txt +19 -0
  13. pairvoice/generations.py +47 -0
  14. pairvoice/install.py +171 -0
  15. pairvoice/launchd.py +25 -0
  16. pairvoice/lifecycle.py +475 -0
  17. pairvoice/llm.py +98 -0
  18. pairvoice/logs.py +86 -0
  19. pairvoice/menubar.py +357 -0
  20. pairvoice/mute.py +163 -0
  21. pairvoice/player.py +175 -0
  22. pairvoice/postprocess.py +117 -0
  23. pairvoice/profiles.py +114 -0
  24. pairvoice/server.py +219 -0
  25. pairvoice/studio/package.json +53 -0
  26. pairvoice/studio/server/app.ts +68 -0
  27. pairvoice/studio/server/history.ts +122 -0
  28. pairvoice/studio/server/http.ts +124 -0
  29. pairvoice/studio/server/paths.ts +30 -0
  30. pairvoice/studio/server/router.ts +80 -0
  31. pairvoice/studio/server/routes/corpus.ts +189 -0
  32. pairvoice/studio/server/routes/dict.ts +96 -0
  33. pairvoice/studio/server/routes/pairvoice.ts +153 -0
  34. pairvoice/studio/server/routes/profiles.ts +299 -0
  35. pairvoice/studio/server/routes/prompt.ts +45 -0
  36. pairvoice/studio/server/static.ts +51 -0
  37. pairvoice/studio/server/storage.ts +89 -0
  38. pairvoice/studio/server.ts +49 -0
  39. pairvoice/studio/shared/api-types.ts +125 -0
  40. pairvoice/studio/web/dist/assets/index-CpU73UpB.css +2 -0
  41. pairvoice/studio/web/dist/assets/index-Cyj45oOs.js +11 -0
  42. pairvoice/studio/web/dist/index.html +13 -0
  43. pairvoice/studio_process.py +128 -0
  44. pairvoice/tts.py +274 -0
  45. pairvoice-0.1.0.dist-info/METADATA +221 -0
  46. pairvoice-0.1.0.dist-info/RECORD +49 -0
  47. pairvoice-0.1.0.dist-info/WHEEL +4 -0
  48. pairvoice-0.1.0.dist-info/entry_points.txt +2 -0
  49. pairvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
pairvoice/__init__.py ADDED
File without changes
pairvoice/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """`python -m pairvoice` の入口。
2
+
3
+ serve がメニューバーを子として起こすときに使う。`uv run` を経由せず、
4
+ serve と同じ Python を直に使う。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from .cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
@@ -0,0 +1,147 @@
1
+ """CoreAudio の公開 API で、音声入出力中のプロセスを調べる。
2
+
3
+ 追加パッケージも Xcode も要らない。定数は AudioHardware.h の値。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ctypes
9
+ import os
10
+ import struct
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ _core_audio = ctypes.CDLL("/System/Library/Frameworks/CoreAudio.framework/CoreAudio")
16
+ _libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
17
+
18
+ _SYSTEM_OBJECT = 1
19
+ _PROC_PIDPATHINFO_MAXSIZE = 4096
20
+
21
+
22
+ def _fourcc(code: str) -> int:
23
+ return struct.unpack(">I", code.encode())[0]
24
+
25
+
26
+ _SCOPE_GLOBAL = _fourcc("glob")
27
+ _PROCESS_LIST = _fourcc("prs#")
28
+ _PID = _fourcc("ppid")
29
+ _IS_RUNNING_INPUT = _fourcc("piri")
30
+ _IS_RUNNING_OUTPUT = _fourcc("piro")
31
+
32
+
33
+ class _Address(ctypes.Structure):
34
+ _fields_ = [
35
+ ("mSelector", ctypes.c_uint32),
36
+ ("mScope", ctypes.c_uint32),
37
+ ("mElement", ctypes.c_uint32),
38
+ ]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AudioProcess:
43
+ pid: int
44
+ name: str
45
+ input_running: bool
46
+ output_running: bool
47
+
48
+
49
+ def _get_property(obj: int, selector: int, ctype) -> list | None:
50
+ address = _Address(selector, _SCOPE_GLOBAL, 0)
51
+ size = ctypes.c_uint32(0)
52
+ if (
53
+ _core_audio.AudioObjectGetPropertyDataSize(
54
+ ctypes.c_uint32(obj), ctypes.byref(address), 0, None, ctypes.byref(size)
55
+ )
56
+ != 0
57
+ ):
58
+ return None
59
+ count = size.value // ctypes.sizeof(ctype)
60
+ if count == 0:
61
+ return []
62
+ buffer = (ctype * count)()
63
+ if (
64
+ _core_audio.AudioObjectGetPropertyData(
65
+ ctypes.c_uint32(obj),
66
+ ctypes.byref(address),
67
+ 0,
68
+ None,
69
+ ctypes.byref(size),
70
+ ctypes.byref(buffer),
71
+ )
72
+ != 0
73
+ ):
74
+ return None
75
+ return list(buffer)
76
+
77
+
78
+ def _process_name(pid: int, cache: dict[int, str]) -> str:
79
+ cached = cache.get(pid)
80
+ if cached is not None:
81
+ return cached
82
+ buffer = ctypes.create_string_buffer(_PROC_PIDPATHINFO_MAXSIZE)
83
+ length = _libproc.proc_pidpath(pid, buffer, _PROC_PIDPATHINFO_MAXSIZE)
84
+ name = Path(buffer.value.decode(errors="replace")).name if length > 0 else ""
85
+ cache[pid] = name
86
+ return name
87
+
88
+
89
+ def sample_processes() -> tuple[AudioProcess, ...]:
90
+ # 呼び出しごとに作り直すローカルキャッシュ。プロセスは終了後に pid が
91
+ # 再利用されるため、呼び出しをまたいで保持すると古い名前を返しかねない。
92
+ name_cache: dict[int, str] = {}
93
+ objects = _get_property(_SYSTEM_OBJECT, _PROCESS_LIST, ctypes.c_uint32) or []
94
+ processes = []
95
+ for obj in objects:
96
+ pid_values = _get_property(obj, _PID, ctypes.c_int32)
97
+ if not pid_values:
98
+ continue
99
+ pid = pid_values[0]
100
+ input_values = _get_property(obj, _IS_RUNNING_INPUT, ctypes.c_uint32) or [0]
101
+ output_values = _get_property(obj, _IS_RUNNING_OUTPUT, ctypes.c_uint32) or [0]
102
+ output_running = bool(output_values[0])
103
+ processes.append(
104
+ AudioProcess(
105
+ pid=pid,
106
+ # 名前は出力の除外にしか使わないので、出力中のプロセスだけ引く。
107
+ # 読み上げの間は 0.25 秒ごとに呼ばれる
108
+ name=_process_name(pid, name_cache) if output_running else "",
109
+ input_running=bool(input_values[0]),
110
+ output_running=output_running,
111
+ )
112
+ )
113
+ return tuple(processes)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class AudioActivity:
118
+ microphone: bool
119
+ output: bool
120
+
121
+
122
+ class AudioProbe:
123
+ """呼ばれた時点の入出力の有無を1回だけ取る(約13ms)。
124
+
125
+ 再生の直前に確かめたいので、裏で監視して結果を溜めることはしない。
126
+ 自分(常駐サーバー)の再生はほかのアプリの音に数えない。
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ ignore_processes: tuple[str, ...] = (),
132
+ sampler: Callable[[], tuple[AudioProcess, ...]] = sample_processes,
133
+ own_pid: int | None = None,
134
+ ) -> None:
135
+ self._ignored = {name.lower() for name in ignore_processes}
136
+ self._sampler = sampler
137
+ self._own_pid = os.getpid() if own_pid is None else own_pid
138
+
139
+ def sample(self) -> AudioActivity:
140
+ processes = self._sampler()
141
+ return AudioActivity(
142
+ microphone=any(p.input_running for p in processes),
143
+ output=any(
144
+ p.output_running and p.pid != self._own_pid and p.name.lower() not in self._ignored
145
+ for p in processes
146
+ ),
147
+ )
pairvoice/bundle.py ADDED
@@ -0,0 +1,18 @@
1
+ """パッケージに同梱した studio / 雛形の置き場所。DATA_ROOT とは別物。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def bundle_root(package_dir: Path) -> Path:
9
+ """studio/ と examples/ を持つディレクトリ。
10
+
11
+ wheel から入れるとパッケージの中に同梱されている(pyproject の force-include)。
12
+ リポジトリから editable で入れると __file__ は src/pairvoice/ を指したままなので、
13
+ 2つ遡ったリポジトリ直下にある。
14
+ """
15
+ return package_dir if (package_dir / "studio").is_dir() else package_dir.parents[1]
16
+
17
+
18
+ BUNDLE_ROOT = bundle_root(Path(__file__).resolve().parent)
pairvoice/checker.py ADDED
@@ -0,0 +1,199 @@
1
+ """読み上げ用の要約を、要約プロンプトが要求する厳守ルールに照らして機械判定する。
2
+
3
+ ヒューリスティックなので、絶対評価ではなく候補どうしの相対比較に使う。
4
+
5
+ ルール:
6
+ 1. punct_count : 句点・感嘆符・疑問符(。!?)が出力全体で1個または2個(二文構成まで許可)。
7
+ 3個以上は違反。最後の句読点より後に文字が続く場合も違反。
8
+ 2. code_ident : ファイルパス・関数名・変数名・コマンド名などのコード識別子を含まない。
9
+ ただし CI・PR のような短い大文字略語は許可する。
10
+ 3. imperative : 命令・急かし口調(「〜しな」「〜してきな」「さっさと〜」文末「〜な」)を含まない。
11
+ 3・4・7 の「文末」は、二文構成ならそれぞれの文の末尾を指す。
12
+ 4. noun_ne : 名詞・形容詞+「ね」止め(「大丈夫ね」「順調ね」)を含まない。
13
+ 5. length_emoji : 約30文字程度(許容 10〜45 文字、二文構成なら上限は緩める)、絵文字を含まない。
14
+ 6. first_person : 一人称「俺」「僕」「オレ」「ボク」を含まない。
15
+ 7. desu_masu : です・ます調の文末(「〜ます。」「〜でした。」「〜ません。」「〜ください。」等)を含まない。
16
+ 話者の好みに依存するので、config.toml の [eval] style = "casual" のときだけ
17
+ 判定する。既定("any")は文体を問わず、常に PASS にする。
18
+
19
+ 判定は単一のテキストに対して行い、各ルールについて pass/fail の bool を返す。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+ SENTENCE_END_CHARS = "。!?"
27
+ SENTENCE_PATTERN = re.compile(f"[^{SENTENCE_END_CHARS}]+[{SENTENCE_END_CHARS}]*")
28
+
29
+ # 短い技術略語のホワイトリスト(これらは code_ident 違反として数えない)
30
+ ALLOWED_ACRONYMS = {
31
+ "CI",
32
+ "PR",
33
+ "QA",
34
+ "UI",
35
+ "UX",
36
+ "API",
37
+ "URL",
38
+ "ID",
39
+ "AI",
40
+ "TTS",
41
+ "OK",
42
+ "NG",
43
+ "TODO",
44
+ "FYI",
45
+ "ASMR",
46
+ }
47
+
48
+ # コード識別子らしさの検出パターン群
49
+ CODE_PATTERNS = [
50
+ r"[a-zA-Z0-9_]+\.(sh|py|js|ts|tsx|jsx|json|md|yml|yaml|txt|log|rb|go|rs|c|cpp|h|java)\b", # ファイル名
51
+ r"/[a-zA-Z0-9_\-./]+/[a-zA-Z0-9_\-./]+", # パスらしき文字列
52
+ r"[a-zA-Z_][a-zA-Z0-9_]*\(\)", # 関数呼び出し func()
53
+ r"\b[a-z]+[A-Z][a-zA-Z0-9]*\b", # camelCase
54
+ r"\b[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\b", # snake_case
55
+ r"`[^`]+`", # バッククォート
56
+ r"\$[A-Z_][A-Z0-9_]*", # シェル変数
57
+ r"--[a-zA-Z][a-zA-Z0-9\-]*", # CLIオプション
58
+ ]
59
+
60
+ EMOJI_PATTERN = re.compile(
61
+ # 2300 台と 2B00 台は記号のブロックで、⌘ ⏎ や矢印のような技術文書の記号が多い。
62
+ # 絵文字として出る字(⌚⌛ ⌨ ⏏ ⏩-⏳ ⏸-⏺ ⬛⬜ ⭐ ⭕)だけを拾う。
63
+ # 〰 〽 ㊗ ㊙ は CJK の記号の中にある絵文字で、FE0F は絵文字として表示させる異体字セレクタ
64
+ "[\U0001f300-\U0001faff\U00002600-\U000027bf\U0001f1e6-\U0001f1ff"
65
+ "\u231a\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u2b1b\u2b1c\u2b50\u2b55"
66
+ "\u3030\u303d\u3297\u3299\ufe0f]+",
67
+ flags=re.UNICODE,
68
+ )
69
+
70
+ # 命令・急かし口調
71
+ # 文末の裸の「〜な」は動詞連用形(い段中心)に付く命令形。
72
+ # 「かな」(〜かな、疑問の“かな”)「だな」(断定の柔らかい念押し)「たな」(〜ついたな)
73
+ # 「よな」(〜だったよな)は命令ではないため除外。
74
+ IMPERATIVE_PATTERNS = [
75
+ r"しな(?:よ|さい)?[。!?]?$", # 「〜しな」文末
76
+ r"してきな",
77
+ r"さっさと",
78
+ r"しろ[。!?]?$",
79
+ r"(?<![かだではたよ])な[。!?]?$", # 文末の裸の「〜な」(か/だ/で/は/た/よの後は除外)
80
+ ]
81
+
82
+ # 名詞・形容詞+「ね」止め(「〜だね」「〜だよ」は許可、直接「ね」で終わるものを検出)
83
+ # 「大丈夫ね」「順調ね」のように、形容動詞語幹/名詞に直接「ね」が付くケースを検出する。
84
+ # 動詞の活用語尾(う段: う く ぐ す つ ぬ ぶ む ゆ る/た・だ・て・で)や
85
+ # い形容詞語尾(い)、終助詞よ・ん の後に続く「ね」は自然な活用なので除外する。
86
+ NOUN_NE_PATTERN = re.compile(r"(?<![うくぐすつぬぶむゆるたてだでいんよら])ね[。!?]?$")
87
+
88
+
89
+ def split_sentences(text: str) -> list[str]:
90
+ """句点・感嘆符・疑問符の直後で切る。文末に $ でアンカーする判定は文ごとに当てる。
91
+
92
+ 全体の末尾にだけ当てると、二文構成(ルール1で許可)の一文目の違反を見逃す。
93
+ """
94
+ return [s for s in SENTENCE_PATTERN.findall(text) if s.strip()]
95
+
96
+
97
+ def check_punct_count(text: str) -> tuple[bool, str]:
98
+ positions = [i for i, ch in enumerate(text) if ch in SENTENCE_END_CHARS]
99
+ if len(positions) == 0 or len(positions) > 2:
100
+ return False, f"句読点数={len(positions)} (期待=1〜2)"
101
+ last = positions[-1]
102
+ trailing = text[last + 1 :].strip()
103
+ if trailing:
104
+ return False, f"句点後に文字列が続く: '{trailing}'"
105
+ return True, ""
106
+
107
+
108
+ def check_code_ident(text: str) -> tuple[bool, str]:
109
+ hits = []
110
+ for pat in CODE_PATTERNS:
111
+ for m in re.finditer(pat, text):
112
+ token = m.group(0)
113
+ if token.strip("`$-") in ALLOWED_ACRONYMS:
114
+ continue
115
+ hits.append(token)
116
+ # 英単語連続(3文字以上のアルファベット列)もチェックするが、許可略語は除外
117
+ for m in re.finditer(r"[A-Za-z][A-Za-z0-9]{2,}", text):
118
+ token = m.group(0)
119
+ if token.upper() in ALLOWED_ACRONYMS or token in ALLOWED_ACRONYMS:
120
+ continue
121
+ # 既に上のパターンで拾われていなければ追加候補として拾う
122
+ hits.append(token)
123
+ hits = list(dict.fromkeys(hits))
124
+ if hits:
125
+ return False, f"コード識別子疑い: {hits}"
126
+ return True, ""
127
+
128
+
129
+ def check_imperative(text: str) -> tuple[bool, str]:
130
+ sentences = split_sentences(text)
131
+ for pat in IMPERATIVE_PATTERNS:
132
+ if any(re.search(pat, s) for s in sentences):
133
+ return False, f"命令/急かし口調: pattern={pat}"
134
+ return True, ""
135
+
136
+
137
+ def check_noun_ne(text: str) -> tuple[bool, str]:
138
+ if any(NOUN_NE_PATTERN.search(s) for s in split_sentences(text)):
139
+ return False, "名詞/形容詞+「ね」止めの疑い"
140
+ return True, ""
141
+
142
+
143
+ def check_length_emoji(text: str) -> tuple[bool, str]:
144
+ if EMOJI_PATTERN.search(text):
145
+ return False, "絵文字を含む"
146
+ n = len(text)
147
+ positions = [i for i, ch in enumerate(text) if ch in SENTENCE_END_CHARS]
148
+ upper = 60 if len(positions) >= 2 else 45
149
+ if n < 10 or n > upper:
150
+ return False, f"文字数={n} (期待 10〜{upper} 程度)"
151
+ return True, ""
152
+
153
+
154
+ FIRST_PERSON_WORDS = ["俺", "僕", "オレ", "ボク"]
155
+
156
+
157
+ def check_first_person(text: str) -> tuple[bool, str]:
158
+ hits = [w for w in FIRST_PERSON_WORDS if w in text]
159
+ if hits:
160
+ return False, f"一人称を含む: {hits}"
161
+ return True, ""
162
+
163
+
164
+ # です・ます調の文末(style = "casual" のときだけ違反)
165
+ DESU_MASU_PATTERN = re.compile(
166
+ r"(です|ます|ました|でした|ません|ください|ましょう|でしょう|ですよ|ますよ|ましたよ)[。!?]?$"
167
+ )
168
+
169
+
170
+ def check_desu_masu(text: str) -> tuple[bool, str]:
171
+ if any(DESU_MASU_PATTERN.search(s) for s in split_sentences(text)):
172
+ return False, "です・ます調の文末"
173
+ return True, ""
174
+
175
+
176
+ RULES = [
177
+ ("punct_count", check_punct_count),
178
+ ("code_ident", check_code_ident),
179
+ ("imperative", check_imperative),
180
+ ("noun_ne", check_noun_ne),
181
+ ("length_emoji", check_length_emoji),
182
+ ("first_person", check_first_person),
183
+ ("desu_masu", check_desu_masu),
184
+ ]
185
+
186
+
187
+ def evaluate(text: str, style: str = "any") -> dict:
188
+ """rules はルール名 → 合否、reasons は違反したルールだけの理由。
189
+
190
+ desu_masu は style が "casual" のときだけ判定し、それ以外(知らない値も)は PASS にする。
191
+ """
192
+ text = text.strip()
193
+ rules, reasons = {}, {}
194
+ for name, fn in RULES:
195
+ ok, reason = (True, "") if name == "desu_masu" and style != "casual" else fn(text)
196
+ rules[name] = ok
197
+ if not ok:
198
+ reasons[name] = reason
199
+ return {"text": text, "rules": rules, "reasons": reasons, "all_pass": all(rules.values())}