ai-notify 0.2.2 → 0.4.1
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.
- package/README.ja.md +21 -2
- package/README.md +21 -2
- package/menubar/AiNotifyMenuBar.swift +117 -4
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +2 -1
- package/src/cli.mjs +138 -2
- package/src/notify.mjs +63 -10
- package/src/state.mjs +115 -2
- package/src/tsundere.mjs +278 -0
- package/src/voicevox.mjs +67 -8
package/README.ja.md
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
10
|
+
brew install unoryota/tap/ai-notify # macOS(Homebrew)
|
|
11
|
+
# または: npm i -g ai-notify
|
|
12
|
+
|
|
11
13
|
ai-notify init # インストール済みのエージェントを自動検出して配線
|
|
12
14
|
```
|
|
13
15
|
|
|
@@ -39,6 +41,7 @@ ai-notify toggle | on | off | status # ミュートスイッチ
|
|
|
39
41
|
ai-notify volume [0.0-2.0] # 音量の取得/設定
|
|
40
42
|
ai-notify voice [number|name|preview|default] # 読み上げ音声を選ぶ
|
|
41
43
|
ai-notify voicevox [on <id>|off|speakers|test] # VOICEVOXの声で読み上げ
|
|
44
|
+
ai-notify tsundere [on|off|level <0-1>|test] # ツンデレ口調(緊急度でツン⇄デレ)
|
|
42
45
|
ai-notify translate [on <lang>|off|test] # エージェントの文章を自分の言語で
|
|
43
46
|
ai-notify menubar [install|uninstall|status] # ネイティブのメニューバー(macOS)
|
|
44
47
|
ai-notify doctor # 依存・配線の確認
|
|
@@ -52,6 +55,7 @@ AI_NOTIFY_LABEL=api # この窓の読み上げ/通知での名
|
|
|
52
55
|
AI_NOTIFY_VOICE=Eddy # この窓の `say` 音声
|
|
53
56
|
AI_NOTIFY_VOICEVOX_SPEAKER=3 # この窓の VOICEVOX 話者ID
|
|
54
57
|
AI_NOTIFY_VOLUME=0.5 # この窓の音量(0.0〜2.0)
|
|
58
|
+
AI_NOTIFY_TSUNDERE_LEVEL=0.8 # この窓のツンデレ既定値(0=デレ〜1=ツン)
|
|
55
59
|
```
|
|
56
60
|
|
|
57
61
|
## 🎛️ ネイティブのメニューバー — ミュート・音量・声
|
|
@@ -64,7 +68,7 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
64
68
|
|
|
65
69
|
モノクロの波形アイコンが**状態を色で**表します(Adobe風):通常はシルエットのみ、入力待ちがあると**黄ドット**、ミュート中は**赤+斜線**。
|
|
66
70
|
|
|
67
|
-
- **左クリック** →
|
|
71
|
+
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定(開いている各ターミナルに個別の声と音量)。
|
|
68
72
|
- **右クリック** → 即ミュート切替。
|
|
69
73
|
|
|
70
74
|
第三者アプリ不要。別の方法が好みなら、**Hammerspoon**・**SwiftBar/xbar**・**Raycast**・標準の**ショートカット**用レシピが [`recipes/`](recipes/) にあります。`ai-notify status --icon` は `🔔`/`🔕` だけを出力するので、tmux・プロンプト・Claude Code のステータスラインに埋め込めます。
|
|
@@ -97,6 +101,21 @@ ai-notify translate test "I fixed the auth bug and added 3 tests."
|
|
|
97
101
|
|
|
98
102
|
キーレス・無料(HTTP 1リクエスト。オフライン時はローカルの定型文にフォールバック)。デスクトップ通知には原文も表示されます。
|
|
99
103
|
|
|
104
|
+
## 💢 ツンデレモード(任意・遊び心)
|
|
105
|
+
|
|
106
|
+
読み上げに「ツンデレ」人格を載せ、**事象の緊急度で口調が変わります**:
|
|
107
|
+
|
|
108
|
+
- **失敗・危険な許可待ち** → 声大きめの鋭い**ツン**で「ちょっと!ビルドが失敗じゃない。早く直しなさいよね!」
|
|
109
|
+
- **問題なしのパス** → やさしい**デレ**で「…ふふ、よくやったじゃない。べ、別に褒めてないんだからね…えらいえらい。」
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
ai-notify tsundere on # 既定はOFF
|
|
113
|
+
ai-notify tsundere level 0.6 # 既定の強さ 0(デレ)〜1(ツン)。メニューバーにもスライダー
|
|
114
|
+
ai-notify tsundere test # T3/T2/T1/T0 のサンプルを試聴
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**無API・決定論・オフライン**(テンプレートで生成。課金ゼロ)。緊急度はエージェントの文面からのキーワード推定(厳密な重大度ではなくベストエフォート)で、デスクトップ通知は素の文面のまま。**VOICEVOX**利用時は、強さに応じて同じキャラの**ツンツン/あまあま**スタイルを選ぶので、声色そのものがツン・デレに変わります。`lang` は `ja` / `en` 対応。
|
|
118
|
+
|
|
100
119
|
## ⏳ どの窓が・何を求めているか
|
|
101
120
|
|
|
102
121
|
各通知のタイトルに窓ラベルが付きます — 入力待ちは `⏳ <label>`、完了は `✓ <label>`。本文には**何を**(翻訳されたプロンプト、または作業内容の要約)が出ます。各ペインに短い `AI_NOTIFY_LABEL` を設定すれば、10個のターミナルもひと目で見分けられます。
|
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
10
|
+
brew install unoryota/tap/ai-notify # macOS (Homebrew)
|
|
11
|
+
# or: npm i -g ai-notify
|
|
12
|
+
|
|
11
13
|
ai-notify init # auto-detects your agents and wires them
|
|
12
14
|
```
|
|
13
15
|
|
|
@@ -39,6 +41,7 @@ ai-notify toggle | on | off | status # the mute switch
|
|
|
39
41
|
ai-notify volume [0.0-2.0] # get/set output volume
|
|
40
42
|
ai-notify voice [number|name|preview|default] # pick the spoken voice
|
|
41
43
|
ai-notify voicevox [on <id>|off|speakers|test] # speak in VOICEVOX voices
|
|
44
|
+
ai-notify tsundere [on|off|level <0-1>|test] # tsundere persona (ツン⇄デレ by urgency)
|
|
42
45
|
ai-notify translate [on <lang>|off|test] # speak agent text in your language
|
|
43
46
|
ai-notify menubar [install|uninstall|status] # native menu bar app (macOS)
|
|
44
47
|
ai-notify doctor # check deps & wiring
|
|
@@ -51,6 +54,7 @@ Per-window overrides — `export` these in a terminal *before* launching the age
|
|
|
51
54
|
AI_NOTIFY_LABEL=api # name this window in the read-out / notification
|
|
52
55
|
AI_NOTIFY_VOICE=Eddy # this window's `say` voice
|
|
53
56
|
AI_NOTIFY_VOICEVOX_SPEAKER=3 # this window's VOICEVOX speaker id
|
|
57
|
+
AI_NOTIFY_TSUNDERE_LEVEL=0.8 # this window's tsundere baseline (0=デレ … 1=ツン)
|
|
54
58
|
AI_NOTIFY_VOLUME=0.5 # this window's volume (0.0–2.0)
|
|
55
59
|
```
|
|
56
60
|
|
|
@@ -64,7 +68,7 @@ ai-notify menubar install # native menu bar app, starts at login
|
|
|
64
68
|
|
|
65
69
|
A monochrome waveform icon shows status by color (Adobe-style): plain when idle, a **yellow** dot when an agent is waiting for you, **red + slash** when muted.
|
|
66
70
|
|
|
67
|
-
- **Left-click** → menu: a **volume slider**, the **voice list** (system + VOICEVOX), and **per-pane** controls — each open terminal gets its own voice *and* volume.
|
|
71
|
+
- **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls — each open terminal gets its own voice *and* volume.
|
|
68
72
|
- **Right-click** → instant mute toggle.
|
|
69
73
|
|
|
70
74
|
No third-party app needed. Prefer something else? There are drop-in recipes for **Hammerspoon**, **SwiftBar/xbar**, **Raycast**, and the built-in **macOS Shortcuts** in [`recipes/`](recipes/). `ai-notify status --icon` prints just `🔔`/`🔕` to embed in tmux / your prompt / Claude Code's status line.
|
|
@@ -97,6 +101,21 @@ ai-notify translate test "I fixed the auth bug and added 3 tests."
|
|
|
97
101
|
|
|
98
102
|
Key-less and no cost (one HTTP request; falls back to a localized template offline). The desktop banner still shows the original text.
|
|
99
103
|
|
|
104
|
+
## 💢 Tsundere mode (optional, fun)
|
|
105
|
+
|
|
106
|
+
Give the spoken read-out a tsundere persona whose tone tracks **how urgent the event is**:
|
|
107
|
+
|
|
108
|
+
- a **failure / dangerous approval** → a louder, sharp **ツン** scolding ("Hey! The build failed — don't just sit there, fix it!")
|
|
109
|
+
- a **clean pass / no issues** → a warm **デレ** "good job" ("...heh, not bad. N-not that I'm impressed or anything.")
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
ai-notify tsundere on # off by default
|
|
113
|
+
ai-notify tsundere level 0.6 # baseline 0 (デレ) … 1 (ツン); the menu bar has a slider
|
|
114
|
+
ai-notify tsundere test # hear T3/T2/T1/T0 samples
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It's **deterministic and offline** — phrase banks, no API, no cost. The urgency is a keyword heuristic over the agent's text (so it's best-effort, not a real severity signal), and the desktop banner stays factual. With **VOICEVOX** the level also picks the character's own **ツンツン / あまあま** style, so the same character actually *sounds* harsher or sweeter. `lang` supports `ja` and `en`.
|
|
118
|
+
|
|
100
119
|
## ⏳ Which window, and what it's asking
|
|
101
120
|
|
|
102
121
|
Each notification is titled with the window label — `⏳ <label>` when an agent is waiting, `✓ <label>` when it's done — and the body says **what** (the translated prompt, or a summary of what was done). Set a short `AI_NOTIFY_LABEL` per pane and you can tell ten terminals apart at a glance.
|
|
@@ -46,6 +46,12 @@ enum State {
|
|
|
46
46
|
try? String(format: "%.2f", v).write(toFile: file("volume"), atomically: true, encoding: .utf8)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Tsundere baseline level 0.0 (デレ) – 1.0 (ツン). Same file the CLI reads.
|
|
50
|
+
static func setTsundereLevel(_ v: Double) {
|
|
51
|
+
try? FileManager.default.createDirectory(atPath: dir(), withIntermediateDirectories: true)
|
|
52
|
+
try? String(format: "%.2f", v).write(toFile: file("tsundere-level"), atomically: true, encoding: .utf8)
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
@discardableResult
|
|
50
56
|
static func cli(_ args: [String], capture: Bool = false) -> String? {
|
|
51
57
|
let launcher = file("cli")
|
|
@@ -130,9 +136,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
130
136
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
131
137
|
|
|
132
138
|
@objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
|
|
139
|
+
// Slider is shown reversed (left = ツン, right = デレ) but the file keeps the
|
|
140
|
+
// canonical scale (0 = デレ, 1 = ツン), so write back 1 - position.
|
|
141
|
+
@objc private func tsundereLevelChanged(_ s: NSSlider) { State.setTsundereLevel(1 - s.doubleValue) }
|
|
142
|
+
@objc private func tsundereToggled(_ b: NSButton) { State.cli(["tsundere", "toggle"]) }
|
|
143
|
+
@objc private func paneTsundereChanged(_ s: NSSlider) {
|
|
144
|
+
if let tty = s.identifier?.rawValue { State.cli(["tsundere-pane", tty, String(format: "%.2f", 1 - s.doubleValue)]) }
|
|
145
|
+
}
|
|
133
146
|
@objc private func paneVolumeChanged(_ s: NSSlider) {
|
|
134
147
|
if let tty = s.identifier?.rawValue { State.cli(["volume-pane", tty, String(format: "%.2f", s.doubleValue)]) }
|
|
135
148
|
}
|
|
149
|
+
// identifier carries the prosody key (speed | pitch | intonation).
|
|
150
|
+
@objc private func prosodyChanged(_ s: NSSlider) {
|
|
151
|
+
if let key = s.identifier?.rawValue { State.cli(["voice-prosody", key, String(format: "%.3f", s.doubleValue)]) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A labeled VOICEVOX base-prosody slider (speed / pitch / intonation). Applied
|
|
155
|
+
// on release (one subprocess per drag avoided). The key rides in the identifier.
|
|
156
|
+
private func prosodyRow(label: String, value: Double, lo: Double, hi: Double, key: String) -> NSMenuItem {
|
|
157
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
|
|
158
|
+
let cap = NSTextField(labelWithString: label)
|
|
159
|
+
cap.frame = NSRect(x: 12, y: 4, width: 48, height: 16)
|
|
160
|
+
cap.font = .systemFont(ofSize: 11); cap.textColor = .secondaryLabelColor
|
|
161
|
+
let slider = NSSlider(value: value, minValue: lo, maxValue: hi, target: self, action: #selector(prosodyChanged(_:)))
|
|
162
|
+
slider.frame = NSRect(x: 62, y: 3, width: 162, height: 20)
|
|
163
|
+
slider.isContinuous = false
|
|
164
|
+
slider.identifier = NSUserInterfaceItemIdentifier(key)
|
|
165
|
+
row.addSubview(cap); row.addSubview(slider)
|
|
166
|
+
let item = NSMenuItem(); item.view = row
|
|
167
|
+
return item
|
|
168
|
+
}
|
|
136
169
|
|
|
137
170
|
// A 🔊 + slider row. identifier == nil => global (live); otherwise a pane tty
|
|
138
171
|
// (applied on release to avoid a subprocess per drag tick).
|
|
@@ -149,6 +182,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
149
182
|
return item
|
|
150
183
|
}
|
|
151
184
|
|
|
185
|
+
// A ツン ⇄ デレ slider for the tsundere baseline level. Shown reversed (left =
|
|
186
|
+
// ツン, right = デレ) for intuition, while the file keeps 0 = デレ, 1 = ツン — so
|
|
187
|
+
// the knob sits at 1 - value and writes back 1 - position. Continuous.
|
|
188
|
+
private func tsundereRow(value: Double, identifier: String? = nil) -> NSMenuItem {
|
|
189
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
|
|
190
|
+
let left = NSTextField(labelWithString: "ツン")
|
|
191
|
+
left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
|
|
192
|
+
left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
|
|
193
|
+
// identifier == nil => global (live, writes the level file); a pane tty =>
|
|
194
|
+
// per-pane override applied on release (one subprocess per drag avoided).
|
|
195
|
+
let action: Selector = identifier == nil ? #selector(tsundereLevelChanged(_:)) : #selector(paneTsundereChanged(_:))
|
|
196
|
+
let slider = NSSlider(value: 1 - value, minValue: 0, maxValue: 1, target: self, action: action)
|
|
197
|
+
slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
|
|
198
|
+
slider.isContinuous = (identifier == nil)
|
|
199
|
+
slider.trackFillColor = .systemPink
|
|
200
|
+
if let id = identifier { slider.identifier = NSUserInterfaceItemIdentifier(id) }
|
|
201
|
+
let right = NSTextField(labelWithString: "デレ")
|
|
202
|
+
right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
|
|
203
|
+
right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
|
|
204
|
+
row.addSubview(left); row.addSubview(slider); row.addSubview(right)
|
|
205
|
+
let item = NSMenuItem(); item.view = row
|
|
206
|
+
return item
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ツンデレモード on/off as a checkbox living inside a view row, so a click
|
|
210
|
+
// toggles in place instead of dismissing the menu (a normal menu item closes
|
|
211
|
+
// on click). The level slider below stays mounted regardless of this state, so
|
|
212
|
+
// the menu height never jumps.
|
|
213
|
+
private func tsundereToggleRow(on: Bool) -> NSMenuItem {
|
|
214
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
|
|
215
|
+
let btn = NSButton(checkboxWithTitle: "ツンデレモード", target: self, action: #selector(tsundereToggled(_:)))
|
|
216
|
+
btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
|
|
217
|
+
btn.state = on ? .on : .off
|
|
218
|
+
row.addSubview(btn)
|
|
219
|
+
let item = NSMenuItem(); item.view = row
|
|
220
|
+
return item
|
|
221
|
+
}
|
|
222
|
+
|
|
152
223
|
// representedObject is the full CLI arg array to run.
|
|
153
224
|
@objc private func runItem(_ item: NSMenuItem) {
|
|
154
225
|
if let cmd = item.representedObject as? [String] { State.cli(cmd) }
|
|
@@ -163,16 +234,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
163
234
|
private func showMenu() {
|
|
164
235
|
let menu = NSMenu()
|
|
165
236
|
|
|
166
|
-
// Global volume slider.
|
|
167
|
-
menu.addItem(sliderRow(value: State.volume, action: #selector(volumeChanged(_:)), identifier: nil))
|
|
168
|
-
menu.addItem(.separator())
|
|
169
|
-
|
|
170
237
|
// Parse menu-json once.
|
|
171
238
|
let json = (State.cli(["menu-json"], capture: true)?.data(using: .utf8))
|
|
172
239
|
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
|
|
173
240
|
let voices = (json?["voices"] as? [[String: Any]]) ?? []
|
|
174
241
|
let panes = (json?["panes"] as? [[String: Any]]) ?? []
|
|
175
242
|
|
|
243
|
+
// Global volume slider.
|
|
244
|
+
menu.addItem(sliderRow(value: State.volume, action: #selector(volumeChanged(_:)), identifier: nil))
|
|
245
|
+
|
|
246
|
+
// Tsundere mode: checkbox toggle + ツン⇄デレ baseline slider. Both live in
|
|
247
|
+
// view rows and are always mounted, so toggling never closes the menu nor
|
|
248
|
+
// shifts its height.
|
|
249
|
+
let tsun = json?["tsundere"] as? [String: Any]
|
|
250
|
+
let tsunOn = (tsun?["enabled"] as? Bool) ?? false
|
|
251
|
+
let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
|
|
252
|
+
menu.addItem(tsundereToggleRow(on: tsunOn))
|
|
253
|
+
menu.addItem(tsundereRow(value: tsunLevel))
|
|
254
|
+
menu.addItem(.separator())
|
|
255
|
+
|
|
256
|
+
// VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
|
|
257
|
+
// is the active TTS, since these are VOICEVOX audio_query scales.
|
|
258
|
+
if (json?["tts"] as? String) == "voicevox" {
|
|
259
|
+
let pr = json?["prosody"] as? [String: Any] ?? [:]
|
|
260
|
+
let range = json?["prosodyRange"] as? [String: Any] ?? [:]
|
|
261
|
+
let bounds: (String, Double, Double) -> (Double, Double) = { key, dlo, dhi in
|
|
262
|
+
let r = range[key] as? [Any]
|
|
263
|
+
let lo = (r?.first as? Double) ?? dlo
|
|
264
|
+
let hi = (r?.last as? Double) ?? dhi
|
|
265
|
+
return (lo, hi)
|
|
266
|
+
}
|
|
267
|
+
menu.addItem(disabledHeader("読み上げ(VOICEVOX)"))
|
|
268
|
+
for (key, label, dlo, dhi, dflt) in [
|
|
269
|
+
("speed", "速さ", 0.5, 1.5, 1.0),
|
|
270
|
+
("pitch", "高さ", -0.15, 0.15, 0.0),
|
|
271
|
+
("intonation", "抑揚", 0.0, 1.5, 1.0),
|
|
272
|
+
] {
|
|
273
|
+
let (lo, hi) = bounds(key, dlo, dhi)
|
|
274
|
+
let v = (pr[key] as? Double) ?? dflt
|
|
275
|
+
menu.addItem(prosodyRow(label: label, value: v, lo: lo, hi: hi, key: key))
|
|
276
|
+
}
|
|
277
|
+
menu.addItem(.separator())
|
|
278
|
+
}
|
|
279
|
+
|
|
176
280
|
if voices.isEmpty {
|
|
177
281
|
menu.addItem(disabledHeader("(声の一覧を取得できません)"))
|
|
178
282
|
} else {
|
|
@@ -200,6 +304,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
200
304
|
volDef.state = (p["volumeSet"] as? Bool ?? false) ? .off : .on
|
|
201
305
|
sub.addItem(volDef)
|
|
202
306
|
sub.addItem(.separator())
|
|
307
|
+
// Per-pane tsundere baseline (same ツン⇄デレ slider as global).
|
|
308
|
+
sub.addItem(disabledHeader("ツンデレ"))
|
|
309
|
+
let pts = (p["tsundere"] as? Double) ?? tsunLevel
|
|
310
|
+
sub.addItem(tsundereRow(value: pts, identifier: tty))
|
|
311
|
+
let tsDef = NSMenuItem(title: "強さを全体に従う", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
312
|
+
tsDef.target = self; tsDef.representedObject = ["tsundere-pane", tty, "clear"]
|
|
313
|
+
tsDef.state = (p["tsundereSet"] as? Bool ?? false) ? .off : .on
|
|
314
|
+
sub.addItem(tsDef)
|
|
315
|
+
sub.addItem(.separator())
|
|
203
316
|
// Per-pane voice.
|
|
204
317
|
sub.addItem(disabledHeader("声"))
|
|
205
318
|
let def = NSMenuItem(title: "デフォルト(全体に従う)", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"test": "node --test",
|
|
23
23
|
"scrub": "node scripts/scrub.mjs",
|
|
24
24
|
"build:menubar": "bash menubar/build.sh",
|
|
25
|
+
"release": "node scripts/release.mjs",
|
|
25
26
|
"prepack": "bash menubar/build.sh 2>/dev/null || true"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [
|
package/src/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// One mute switch for all of them, across every terminal. No daemon.
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
6
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
7
7
|
import { providers, byId } from './providers/index.mjs';
|
|
8
8
|
import { emit } from './notify.mjs';
|
|
9
9
|
import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
|
|
@@ -12,6 +12,7 @@ import * as menubar from './menubar.mjs';
|
|
|
12
12
|
import { translate } from './translate.mjs';
|
|
13
13
|
import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
|
|
14
14
|
import * as voicevox from './voicevox.mjs';
|
|
15
|
+
import * as tsundere from './tsundere.mjs';
|
|
15
16
|
import {
|
|
16
17
|
isMuted,
|
|
17
18
|
setMuted,
|
|
@@ -22,12 +23,20 @@ import {
|
|
|
22
23
|
DEFAULT_CONFIG,
|
|
23
24
|
readVolume,
|
|
24
25
|
setVolume,
|
|
26
|
+
readTsundereLevel,
|
|
27
|
+
setTsundereLevel,
|
|
28
|
+
readVoiceProsody,
|
|
29
|
+
setVoiceProsody,
|
|
30
|
+
resetVoiceProsody,
|
|
31
|
+
VOICE_PROSODY_RANGE,
|
|
25
32
|
readPanes,
|
|
26
33
|
readPaneSetting,
|
|
27
34
|
updatePaneSetting,
|
|
28
35
|
} from './state.mjs';
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
// Single source of truth: read the version from package.json so `--version`
|
|
38
|
+
// (and the Homebrew formula test that checks it) always matches the release.
|
|
39
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
31
40
|
|
|
32
41
|
const args = process.argv.slice(2);
|
|
33
42
|
const cmd = args[0];
|
|
@@ -329,6 +338,92 @@ const cmds = {
|
|
|
329
338
|
log(`🔊 volume → ${n}`);
|
|
330
339
|
},
|
|
331
340
|
|
|
341
|
+
// Tsundere mode: skin the spoken read-out with a tsundere persona that turns
|
|
342
|
+
// ツン (harsh + louder) on failures and デレ (warm) on clean passes. Offline,
|
|
343
|
+
// deterministic, no cost.
|
|
344
|
+
// tsundere on|off|toggle | level <0-1> | test [t3|t2|t1|t0] | status
|
|
345
|
+
tsundere() {
|
|
346
|
+
const sub = positionals[0] || 'status';
|
|
347
|
+
const config = readConfig();
|
|
348
|
+
const ts = config.tsundere;
|
|
349
|
+
const url = config.voicevox?.url || voicevox.DEFAULT_URL;
|
|
350
|
+
|
|
351
|
+
const sayText = (text, voice, tone = 'normal') => {
|
|
352
|
+
try {
|
|
353
|
+
const t = tsundere.decorateForSay(text, tone); // human contour, not 棒読み
|
|
354
|
+
execFileSync('say', voice ? ['-v', voice, t] : [t], { stdio: 'ignore' });
|
|
355
|
+
} catch {
|
|
356
|
+
/* non-mac / no say — ignore */
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (sub === 'on' || sub === 'off' || sub === 'toggle') {
|
|
361
|
+
const enabled = sub === 'toggle' ? !ts.enabled : sub === 'on';
|
|
362
|
+
config.tsundere = { ...ts, enabled };
|
|
363
|
+
// With VOICEVOX, resolve & cache the character's ツンツン/あまあま style ids
|
|
364
|
+
// now, so fire-time skips the lookup.
|
|
365
|
+
if (enabled && config.tts === 'voicevox') {
|
|
366
|
+
const sm = voicevox.resolveStyles(config.voicevox?.speaker, url);
|
|
367
|
+
if (sm) config.tsundere.styleMap = sm;
|
|
368
|
+
}
|
|
369
|
+
writeConfig(config);
|
|
370
|
+
log(enabled ? '💢 ツンデレ ON(デレ⇄ツン・緊急度で口調が変化)' : 'ツンデレ OFF');
|
|
371
|
+
if (enabled) {
|
|
372
|
+
log(' 既定の強さ: ai-notify tsundere level <0=デレ 〜 1=ツン>');
|
|
373
|
+
log(' 試聴: ai-notify tsundere test');
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (sub === 'level') {
|
|
378
|
+
const arg = positionals[1];
|
|
379
|
+
if (arg === undefined) {
|
|
380
|
+
const v = readTsundereLevel();
|
|
381
|
+
return log(`tsundere level: ${v != null ? v : ts.level} (0=デレ 〜 1=ツン)`);
|
|
382
|
+
}
|
|
383
|
+
const n = setTsundereLevel(arg);
|
|
384
|
+
return log(`💢 tsundere level → ${n} (0=デレ 〜 1=ツン)`);
|
|
385
|
+
}
|
|
386
|
+
if (sub === 'test') {
|
|
387
|
+
const which = (positionals[1] || '').toLowerCase();
|
|
388
|
+
const lang = ts.lang || 'ja';
|
|
389
|
+
const level = readTsundereLevel() != null ? readTsundereLevel() : ts.level;
|
|
390
|
+
const ja = lang === 'ja';
|
|
391
|
+
const samples = {
|
|
392
|
+
t3: { event: 'done', raw: 'Build failed: TypeError in auth.ts', body: ja ? 'ビルドが失敗' : 'the build failed' },
|
|
393
|
+
t2: { event: 'waiting', raw: 'Claude needs your permission to run a command', body: ja ? '許可待ち' : 'waiting for your input' },
|
|
394
|
+
t1: { event: 'done', raw: 'Updated three files', body: ja ? '3ファイルを更新' : 'updated three files' },
|
|
395
|
+
t0: { event: 'done', raw: 'All tests passed, no issues', body: ja ? 'テスト全部パス' : 'all tests passed' },
|
|
396
|
+
};
|
|
397
|
+
const keys = samples[which] ? [which] : ['t3', 't2', 't1', 't0'];
|
|
398
|
+
const sm = config.tts === 'voicevox' ? ts.styleMap || voicevox.resolveStyles(config.voicevox?.speaker, url) : null;
|
|
399
|
+
log(`tsundere test (level ${level}, lang ${lang}):\n`);
|
|
400
|
+
for (const k of keys) {
|
|
401
|
+
const s = samples[k];
|
|
402
|
+
const tier = tsundere.classifyUrgency(s.event, s.raw, s.body);
|
|
403
|
+
const eff = tsundere.effectiveLevel(level, tier, ts.urgencyShift !== false);
|
|
404
|
+
const text = tsundere.wrap(s.body, eff, tier, lang, 0);
|
|
405
|
+
const mul = tsundere.volumeMul(tier, ts.volumeBoost !== false);
|
|
406
|
+
const tone = tsundere.axisFor(eff);
|
|
407
|
+
log(` [${tier} ×${mul} ${tone}] ${text}`);
|
|
408
|
+
if (sm) {
|
|
409
|
+
const speaker = sm[tone] ?? config.voicevox?.speaker;
|
|
410
|
+
voicevox.speak(text, speaker, url, mul, undefined, tsundere.effectiveProsody(tone, readVoiceProsody()));
|
|
411
|
+
} else {
|
|
412
|
+
sayText(text, config.voice || '', tone);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// status
|
|
418
|
+
const lvl = readTsundereLevel() != null ? readTsundereLevel() : ts.level;
|
|
419
|
+
log(`tsundere: ${ts.enabled ? '💢 ON' : 'OFF'}`);
|
|
420
|
+
log(` level: ${lvl} (0=デレ 〜 1=ツン)`);
|
|
421
|
+
log(` urgencyShift: ${ts.urgencyShift !== false ? 'on' : 'off'} (緊急度で口調を増減)`);
|
|
422
|
+
log(` volumeBoost: ${ts.volumeBoost !== false ? 'on' : 'off'} (重大時は音量↑)`);
|
|
423
|
+
log(` lang: ${ts.lang || 'ja'}`);
|
|
424
|
+
if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
|
|
425
|
+
},
|
|
426
|
+
|
|
332
427
|
// Assign a voice to a specific pane (by tty), from the menu bar.
|
|
333
428
|
// voice-pane <tty> voicevox <id> | say <name> | clear
|
|
334
429
|
'voice-pane'() {
|
|
@@ -367,6 +462,38 @@ const cmds = {
|
|
|
367
462
|
log(`pane ${tty}: volume ${v}`);
|
|
368
463
|
},
|
|
369
464
|
|
|
465
|
+
// Set a specific pane's tsundere baseline level (0=デレ – 1=ツン), or `clear` to
|
|
466
|
+
// follow the global level. tsundere-pane <tty> <0-1|clear>
|
|
467
|
+
'tsundere-pane'() {
|
|
468
|
+
const [tty, arg] = positionals;
|
|
469
|
+
if (!tty || arg === undefined) {
|
|
470
|
+
console.error('usage: tsundere-pane <tty> <0-1|clear>');
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
if (arg === 'clear') {
|
|
474
|
+
updatePaneSetting(tty, { tsundere: null });
|
|
475
|
+
return log(`pane ${tty}: tsundere level reset to global`);
|
|
476
|
+
}
|
|
477
|
+
const v = Math.min(1, Math.max(0, Number(arg)));
|
|
478
|
+
updatePaneSetting(tty, { tsundere: v });
|
|
479
|
+
log(`pane ${tty}: tsundere level ${v}`);
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
// Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
|
|
483
|
+
// sliders drive). With no args, prints the current values as JSON.
|
|
484
|
+
// voice-prosody [speed|pitch|intonation <value> | reset]
|
|
485
|
+
'voice-prosody'() {
|
|
486
|
+
const [key, val] = positionals;
|
|
487
|
+
if (key === 'reset') return log(JSON.stringify(resetVoiceProsody()));
|
|
488
|
+
if (!key || val === undefined) return log(JSON.stringify(readVoiceProsody()));
|
|
489
|
+
const next = setVoiceProsody(key, val);
|
|
490
|
+
if (!next) {
|
|
491
|
+
console.error('usage: voice-prosody <speed|pitch|intonation> <value> | reset');
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
log(`voice prosody ${key} → ${next[key]}`);
|
|
495
|
+
},
|
|
496
|
+
|
|
370
497
|
// Machine-readable state for the menu bar agent: mute, volume, the selectable
|
|
371
498
|
// voices, and the recently-active panes (for per-pane assignment). Not human.
|
|
372
499
|
'menu-json'() {
|
|
@@ -398,6 +525,7 @@ const cmds = {
|
|
|
398
525
|
// Panes = live terminals currently running an agent (so they show up before
|
|
399
526
|
// they ever fire a notification) merged with previously-recorded ones.
|
|
400
527
|
const globalVol = readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1;
|
|
528
|
+
const tsLevel = readTsundereLevel() != null ? readTsundereLevel() : config.tsundere?.level ?? 0.5;
|
|
401
529
|
const recorded = new Map(readPanes().map((p) => [p.tty, p.label]));
|
|
402
530
|
const ttys = new Set([...livePanes(), ...recorded.keys()]);
|
|
403
531
|
const panes = [...ttys].map((tty) => {
|
|
@@ -408,6 +536,8 @@ const cmds = {
|
|
|
408
536
|
current: labelFor(s.tts ? s : null),
|
|
409
537
|
volume: typeof s.volume === 'number' ? s.volume : globalVol,
|
|
410
538
|
volumeSet: typeof s.volume === 'number',
|
|
539
|
+
tsundere: typeof s.tsundere === 'number' ? s.tsundere : tsLevel,
|
|
540
|
+
tsundereSet: typeof s.tsundere === 'number',
|
|
411
541
|
};
|
|
412
542
|
});
|
|
413
543
|
log(
|
|
@@ -416,6 +546,10 @@ const cmds = {
|
|
|
416
546
|
volume: readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1,
|
|
417
547
|
voices,
|
|
418
548
|
panes,
|
|
549
|
+
tsundere: { enabled: !!config.tsundere?.enabled, level: tsLevel },
|
|
550
|
+
tts: config.tts || 'say',
|
|
551
|
+
prosody: readVoiceProsody(),
|
|
552
|
+
prosodyRange: VOICE_PROSODY_RANGE,
|
|
419
553
|
})
|
|
420
554
|
);
|
|
421
555
|
},
|
|
@@ -552,6 +686,8 @@ Usage:
|
|
|
552
686
|
ai-notify volume [0.0-2.0] get/set output volume
|
|
553
687
|
ai-notify voice [number|name|preview|default] pick the spoken voice
|
|
554
688
|
ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
689
|
+
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
690
|
+
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
555
691
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
556
692
|
ai-notify translate [on <lang>|off|test] speak agent text in your language
|
|
557
693
|
ai-notify doctor check deps & wiring
|
package/src/notify.mjs
CHANGED
|
@@ -8,11 +8,22 @@ import { spawn, execFileSync } from 'node:child_process';
|
|
|
8
8
|
import { existsSync, rmSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isMuted,
|
|
13
|
+
readConfig,
|
|
14
|
+
readVolume,
|
|
15
|
+
recordPane,
|
|
16
|
+
readPaneSetting,
|
|
17
|
+
setPaneWaiting,
|
|
18
|
+
readTsundereLevel,
|
|
19
|
+
readVoiceProsody,
|
|
20
|
+
nextCounter,
|
|
21
|
+
} from './state.mjs';
|
|
12
22
|
import { controllingTty } from './util.mjs';
|
|
13
23
|
import { translate } from './translate.mjs';
|
|
14
24
|
import { highlightWaiting, clearHighlight } from './highlight.mjs';
|
|
15
25
|
import * as voicevox from './voicevox.mjs';
|
|
26
|
+
import * as tsundere from './tsundere.mjs';
|
|
16
27
|
|
|
17
28
|
const platform = process.platform; // 'darwin' | 'linux' | 'win32'
|
|
18
29
|
|
|
@@ -73,14 +84,23 @@ const sayWithVolume = (text, voice, vol) => {
|
|
|
73
84
|
}
|
|
74
85
|
};
|
|
75
86
|
|
|
76
|
-
const speak = (text, voice, vol = 1) => {
|
|
87
|
+
const speak = (text, voice, vol = 1, tone = 'normal') => {
|
|
77
88
|
if (!text) return;
|
|
78
89
|
if (platform === 'darwin') {
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
// Give the OS voice human contour (pace/pitch/intonation + real pauses)
|
|
91
|
+
// instead of a flat 棒読み monotone.
|
|
92
|
+
const t = tsundere.decorateForSay(text, tone);
|
|
93
|
+
if (vol !== 1) return sayWithVolume(t, voice, vol);
|
|
94
|
+
run('say', voice ? ['-v', voice, t] : [t]);
|
|
81
95
|
} else if (platform === 'linux') {
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
const e = tsundere.prosodyFor(tone).espeak;
|
|
97
|
+
if (which('spd-say')) {
|
|
98
|
+
const r = Math.max(-100, Math.min(100, Math.round((e.speed - 175) / 1.5)));
|
|
99
|
+
const pch = Math.max(-100, Math.min(100, Math.round((e.pitch - 50) * 2)));
|
|
100
|
+
run('spd-say', ['-r', String(r), '-p', String(pch), text]);
|
|
101
|
+
} else if (which('espeak')) {
|
|
102
|
+
run('espeak', ['-p', String(e.pitch), '-s', String(e.speed), text]);
|
|
103
|
+
}
|
|
84
104
|
} else if (platform === 'win32') {
|
|
85
105
|
run('powershell', [
|
|
86
106
|
'-NoProfile',
|
|
@@ -187,14 +207,47 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
187
207
|
? config.volume
|
|
188
208
|
: 1;
|
|
189
209
|
|
|
210
|
+
// Tsundere mode: skin the spoken text, scale volume, and (with VOICEVOX) pick
|
|
211
|
+
// the character's ツンツン/あまあま style — all driven by the event's urgency.
|
|
212
|
+
// The banner is left untouched (it stays factual). Off => identical to before.
|
|
213
|
+
let outText = speakText;
|
|
214
|
+
let outVol = vol;
|
|
215
|
+
let outSpeaker = speaker;
|
|
216
|
+
let speakTone = 'normal'; // delivery contour; tsundere sets it to tsun/dere
|
|
217
|
+
const ts = config.tsundere;
|
|
218
|
+
if (ts && ts.enabled) {
|
|
219
|
+
const tier = tsundere.classifyUrgency(event, message, fullBody);
|
|
220
|
+
const envLevel = parseFloat(process.env.AI_NOTIFY_TSUNDERE_LEVEL);
|
|
221
|
+
const baseLevel = Number.isFinite(envLevel)
|
|
222
|
+
? Math.min(1, Math.max(0, envLevel))
|
|
223
|
+
: typeof pane.tsundere === 'number'
|
|
224
|
+
? pane.tsundere
|
|
225
|
+
: readTsundereLevel() != null
|
|
226
|
+
? readTsundereLevel()
|
|
227
|
+
: typeof ts.level === 'number'
|
|
228
|
+
? ts.level
|
|
229
|
+
: 0.5;
|
|
230
|
+
const eff = tsundere.effectiveLevel(baseLevel, tier, ts.urgencyShift !== false);
|
|
231
|
+
speakTone = tsundere.axisFor(eff);
|
|
232
|
+
outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
|
|
233
|
+
outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
|
|
234
|
+
if (config.speakLabel === true && label) outText = `${label}、${outText}`;
|
|
235
|
+
if (tts === 'voicevox') {
|
|
236
|
+
const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
|
|
237
|
+
const axis = tsundere.axisFor(eff);
|
|
238
|
+
if (sm && sm[axis] != null) outSpeaker = sm[axis];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
190
242
|
if (!muted) {
|
|
191
|
-
playSound(soundName,
|
|
192
|
-
if (config.speak &&
|
|
243
|
+
playSound(soundName, outVol);
|
|
244
|
+
if (config.speak && outVol > 0) {
|
|
193
245
|
let spoken = false;
|
|
194
246
|
if (tts === 'voicevox') {
|
|
195
|
-
|
|
247
|
+
const prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
|
|
248
|
+
spoken = voicevox.speak(outText, outSpeaker, config.voicevox?.url, outVol, undefined, prosody);
|
|
196
249
|
}
|
|
197
|
-
if (!spoken) speak(
|
|
250
|
+
if (!spoken) speak(outText, voice, outVol, speakTone); // OS `say` (also the VOICEVOX fallback)
|
|
198
251
|
}
|
|
199
252
|
}
|
|
200
253
|
|
package/src/state.mjs
CHANGED
|
@@ -67,6 +67,98 @@ export const setVolume = (v) => {
|
|
|
67
67
|
return n;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// --- Tsundere level --------------------------------------------------------
|
|
71
|
+
// A single number 0.0 (full デレ) – 1.0 (full ツン) in a state file, written by
|
|
72
|
+
// the menu bar slider or `ai-notify tsundere level`, read at fire time. Overrides
|
|
73
|
+
// config.tsundere.level; $AI_NOTIFY_TSUNDERE_LEVEL overrides per window.
|
|
74
|
+
|
|
75
|
+
const tsundereLevelPath = () => join(stateDir(), 'tsundere-level');
|
|
76
|
+
|
|
77
|
+
export const readTsundereLevel = () => {
|
|
78
|
+
try {
|
|
79
|
+
const v = parseFloat(readFileSync(tsundereLevelPath(), 'utf8'));
|
|
80
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const setTsundereLevel = (v) => {
|
|
87
|
+
const n = Math.min(1, Math.max(0, Number(v)));
|
|
88
|
+
ensureDir(stateDir());
|
|
89
|
+
writeFileSync(tsundereLevelPath(), String(n));
|
|
90
|
+
return n;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// --- VOICEVOX base prosody -------------------------------------------------
|
|
94
|
+
// User-tunable BASE scales for the VOICEVOX read-out — the values used at the
|
|
95
|
+
// NORMAL tone; tsundere tones nudge from here. Written by the menu bar sliders /
|
|
96
|
+
// `ai-notify voice-prosody`, read at fire time. One small JSON file so all three
|
|
97
|
+
// stay in sync. Defaults = neutral (identical to no tuning).
|
|
98
|
+
export const VOICE_PROSODY_DEFAULTS = { speed: 1.0, pitch: 0.0, intonation: 1.0 };
|
|
99
|
+
export const VOICE_PROSODY_RANGE = { speed: [0.5, 1.5], pitch: [-0.15, 0.15], intonation: [0.0, 1.5] };
|
|
100
|
+
|
|
101
|
+
const voiceProsodyPath = () => join(stateDir(), 'voice-prosody.json');
|
|
102
|
+
|
|
103
|
+
const clampProsody = (key, v) => {
|
|
104
|
+
const [lo, hi] = VOICE_PROSODY_RANGE[key] || [0, 2];
|
|
105
|
+
return Math.min(hi, Math.max(lo, Number(v)));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const readVoiceProsody = () => {
|
|
109
|
+
let raw = {};
|
|
110
|
+
try {
|
|
111
|
+
raw = JSON.parse(readFileSync(voiceProsodyPath(), 'utf8')) || {};
|
|
112
|
+
} catch {
|
|
113
|
+
/* missing/corrupt -> defaults */
|
|
114
|
+
}
|
|
115
|
+
const out = {};
|
|
116
|
+
for (const k of Object.keys(VOICE_PROSODY_DEFAULTS)) {
|
|
117
|
+
out[k] = typeof raw[k] === 'number' ? clampProsody(k, raw[k]) : VOICE_PROSODY_DEFAULTS[k];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Set one key (speed | pitch | intonation); returns the full updated object, or
|
|
123
|
+
// null for an unknown key.
|
|
124
|
+
export const setVoiceProsody = (key, value) => {
|
|
125
|
+
if (!(key in VOICE_PROSODY_DEFAULTS)) return null;
|
|
126
|
+
const cur = readVoiceProsody();
|
|
127
|
+
cur[key] = clampProsody(key, value);
|
|
128
|
+
ensureDir(stateDir());
|
|
129
|
+
writeFileSync(voiceProsodyPath(), JSON.stringify(cur));
|
|
130
|
+
return cur;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const resetVoiceProsody = () => {
|
|
134
|
+
try {
|
|
135
|
+
rmSync(voiceProsodyPath(), { force: true });
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
return { ...VOICE_PROSODY_DEFAULTS };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// A small persisted counter (per name), so phrase rotation varies across fires
|
|
143
|
+
// even for identical input. Wraps to stay small; best-effort.
|
|
144
|
+
export const nextCounter = (name) => {
|
|
145
|
+
const p = join(stateDir(), `ctr-${name}`);
|
|
146
|
+
let n = 0;
|
|
147
|
+
try {
|
|
148
|
+
n = parseInt(readFileSync(p, 'utf8'), 10) || 0;
|
|
149
|
+
} catch {
|
|
150
|
+
/* first use */
|
|
151
|
+
}
|
|
152
|
+
n = (n + 1) % 1000000;
|
|
153
|
+
try {
|
|
154
|
+
ensureDir(stateDir());
|
|
155
|
+
writeFileSync(p, String(n));
|
|
156
|
+
} catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
return n;
|
|
160
|
+
};
|
|
161
|
+
|
|
70
162
|
// --- Per-pane state --------------------------------------------------------
|
|
71
163
|
// Recently-active terminal panes (so the menu bar can offer per-pane voices),
|
|
72
164
|
// and a per-tty voice override. Both are small JSON files in the state dir.
|
|
@@ -114,7 +206,8 @@ export const readPanes = () =>
|
|
|
114
206
|
.map(([tty, v]) => ({ tty, label: v.label || '', ts: v.ts || 0 }))
|
|
115
207
|
.sort((a, b) => b.ts - a.ts);
|
|
116
208
|
|
|
117
|
-
// Per-pane settings: { tts, speaker, voice, volume }. Any subset may
|
|
209
|
+
// Per-pane settings: { tts, speaker, voice, volume, tsundere }. Any subset may
|
|
210
|
+
// be set (tsundere = a 0–1 baseline level override; null/absent = follow global).
|
|
118
211
|
export const readPaneSetting = (tty) => (tty ? readJson(paneVoicesPath(), {})[tty] || {} : {});
|
|
119
212
|
|
|
120
213
|
// Merge `patch` into the pane's settings; keys set to null are removed; an empty
|
|
@@ -176,6 +269,21 @@ export const DEFAULT_CONFIG = {
|
|
|
176
269
|
// Per window: $AI_NOTIFY_VOICEVOX_SPEAKER overrides the speaker id.
|
|
177
270
|
tts: 'say',
|
|
178
271
|
voicevox: { url: 'http://127.0.0.1:50021', speaker: 3 },
|
|
272
|
+
// Tsundere mode: skin the SPOKEN read-out with a tsundere persona whose
|
|
273
|
+
// harshness (ツン) ⇄ sweetness (デレ) tracks the event's urgency — high-urgency
|
|
274
|
+
// failures get a louder ツン scolding, clean passes get a デレ "good job".
|
|
275
|
+
// Off by default. `level` is the baseline 0 (デレ) – 1 (ツン); the menu bar
|
|
276
|
+
// slider / `ai-notify tsundere level` write a state file that overrides it.
|
|
277
|
+
// With VOICEVOX, the level also picks the character's ツンツン/あまあま style
|
|
278
|
+
// (cached in `styleMap`). No API, no cost — deterministic phrase banks.
|
|
279
|
+
tsundere: {
|
|
280
|
+
enabled: false,
|
|
281
|
+
level: 0.5,
|
|
282
|
+
urgencyShift: true, // modulate the level by the event's urgency
|
|
283
|
+
volumeBoost: true, // louder on high-urgency events
|
|
284
|
+
lang: 'ja', // phrase bank language (ja | en)
|
|
285
|
+
styleMap: null, // { normal, tsun, dere } VOICEVOX style ids; auto-resolved
|
|
286
|
+
},
|
|
179
287
|
// Spoken read-out templates for agent events. The window label is added
|
|
180
288
|
// separately (speakLabel), so leave {label} out here to avoid doubling it.
|
|
181
289
|
// Override per language (e.g. Japanese) in config.json. An agent that supplies
|
|
@@ -193,7 +301,12 @@ export const DEFAULT_CONFIG = {
|
|
|
193
301
|
export const readConfig = () => {
|
|
194
302
|
try {
|
|
195
303
|
const raw = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
196
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
...DEFAULT_CONFIG,
|
|
306
|
+
...raw,
|
|
307
|
+
providers: { ...DEFAULT_CONFIG.providers, ...(raw.providers || {}) },
|
|
308
|
+
tsundere: { ...DEFAULT_CONFIG.tsundere, ...(raw.tsundere || {}) },
|
|
309
|
+
};
|
|
197
310
|
} catch {
|
|
198
311
|
return DEFAULT_CONFIG;
|
|
199
312
|
}
|
package/src/tsundere.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Tsundere mode: skin the spoken read-out with a tsundere persona whose harshness
|
|
2
|
+
// (ツン) ⇄ sweetness (デレ) tracks the event's urgency.
|
|
3
|
+
//
|
|
4
|
+
// high urgency (error / failure / dangerous approval) -> ツン + louder
|
|
5
|
+
// low urgency (tests passed / no issues / approved) -> デレ (warm)
|
|
6
|
+
//
|
|
7
|
+
// Everything here is deterministic and offline — phrase banks, no API, no cost.
|
|
8
|
+
// The banner (visual) is never skinned; only the spoken text is wrapped.
|
|
9
|
+
|
|
10
|
+
// --- Urgency classifier ----------------------------------------------------
|
|
11
|
+
// We only see the agent's notification text, so urgency is a heuristic. Order
|
|
12
|
+
// matters: a "no errors" / "tests passed" message must read as POSITIVE even
|
|
13
|
+
// though it contains the word "error".
|
|
14
|
+
|
|
15
|
+
const POSITIVE =
|
|
16
|
+
/\b(passed|all tests? pass(ed)?|no (issues?|errors?|problems?|failures?)|looks good|lgtm|approved?|success(ful|fully)?|succeeded|completed successfully)\b|✅|問題(は)?(あ?り?ま?せん|な(い|し))|エラー(は)?(あり|出て)?(ま|い)?せん|テスト.*(成功|通過|パス|通り)|レビュー.*(通|問題|OK)|承認|無事(完了|成功)/i;
|
|
17
|
+
|
|
18
|
+
// Critical = a failure, OR an approval for a DESTRUCTIVE action. A generic
|
|
19
|
+
// "permission to run a command" is just a wait (T2) — only destructive verbs /
|
|
20
|
+
// dangerous commands escalate to T3.
|
|
21
|
+
const CRITICAL =
|
|
22
|
+
/\b(failed|failing|failure|crash(ed|ing)?|exception|panic|fatal|unrecoverable|aborted|broke(n)?|blocked)\b|❌|🛑|\b(permission|approval)\b[^.!?\n]*\b(delete|remove|overwrite|reset|drop|truncate|force|rm)\b|rm\s+-rf|force[- ]?push|git\s+push\s+-f|drop\s+table|truncate\b|エラーが|失敗|クラッシュ|例外が|落ちて|中断され|危険なコマンド/i;
|
|
23
|
+
|
|
24
|
+
// Returns one of 'T3' (critical) | 'T2' (waiting) | 'T1' (neutral done) | 'T0' (positive).
|
|
25
|
+
// `raw` is the agent's original text (pre-translation); `core` is the formatted
|
|
26
|
+
// body. We test the raw text first for accuracy.
|
|
27
|
+
export const classifyUrgency = (event = 'done', raw = '', core = '') => {
|
|
28
|
+
const text = `${raw || ''} ${core || ''}`;
|
|
29
|
+
if (POSITIVE.test(text) && !CRITICAL.test(text)) return 'T0';
|
|
30
|
+
if (CRITICAL.test(text)) return 'T3';
|
|
31
|
+
if (event === 'waiting') return 'T2';
|
|
32
|
+
return 'T1';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Per-tier modulation: nudge the baseline tsun level toward ツン (positive bias)
|
|
36
|
+
// or デレ (negative bias), and scale the volume. Kept small (±0.25) so the
|
|
37
|
+
// SLIDER stays in charge — at either extreme the slider wins (even a success
|
|
38
|
+
// reads ツン at max, even a failure reads デレ at min); near the middle the
|
|
39
|
+
// urgency nudge is what tips the tone. T0 never lowers the volume.
|
|
40
|
+
const BIAS = { T3: 0.25, T2: 0.1, T1: 0, T0: -0.25 };
|
|
41
|
+
const VOLMUL = { T3: 1.3, T2: 1.1, T1: 1, T0: 1 };
|
|
42
|
+
|
|
43
|
+
export const effectiveLevel = (level, tier, urgencyShift = true) => {
|
|
44
|
+
const base = Number.isFinite(level) ? level : 0.5;
|
|
45
|
+
return Math.min(1, Math.max(0, base + (urgencyShift ? BIAS[tier] || 0 : 0)));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const volumeMul = (tier, volumeBoost = true) => (volumeBoost ? VOLMUL[tier] || 1 : 1);
|
|
49
|
+
|
|
50
|
+
// eff >= 0.6 => ツン, <= 0.4 => デレ, else ノーマル. A narrow ノーマル band (only
|
|
51
|
+
// the genuinely-neutral middle) so the contrast between ツン and デレ is obvious
|
|
52
|
+
// instead of everything collapsing into a bland middle. Used for both the phrase
|
|
53
|
+
// tone and the VOICEVOX style pick.
|
|
54
|
+
export const axisFor = (eff) => (eff >= 0.6 ? 'tsun' : eff <= 0.4 ? 'dere' : 'normal');
|
|
55
|
+
|
|
56
|
+
// --- Phrase banks ----------------------------------------------------------
|
|
57
|
+
// BANK[lang][tone] = { <tier>: [...], default: [...] }. `{body}` is the task
|
|
58
|
+
// gist (kept, so the read-out is still informative). Tasteful, short, SFW.
|
|
59
|
+
|
|
60
|
+
const BANK = {
|
|
61
|
+
ja: {
|
|
62
|
+
// ツン: 冷たい・とげとげ・素直じゃない。失敗には容赦なく、成功も渋々。
|
|
63
|
+
tsun: {
|
|
64
|
+
T3: [
|
|
65
|
+
'ちょっと!また{body}じゃない。…ぼーっとしてないで早く直しなさいよ!',
|
|
66
|
+
'はぁ?{body}って…どこ見てたのよ。さっさと直す!',
|
|
67
|
+
'べ、別に心配なんてしてないけど…{body}よ。早くなんとかしなさいよね!',
|
|
68
|
+
'{body}…って、あんたまたやらかしたわけ?ほら、手が止まってるわよ!',
|
|
69
|
+
'もう、{body}。…しょうがないわね、わたしが見ててあげるから早く直して。',
|
|
70
|
+
'{body}でしょ。わかってるなら、ぐずぐずしてないで直しなさいよ!',
|
|
71
|
+
'あーあ、{body}。…ま、あんたならこんなものよね。さっさと直す!',
|
|
72
|
+
],
|
|
73
|
+
T2: [
|
|
74
|
+
'…{body}。あんたの判断待ちなの。さっさと決めなさいよ。',
|
|
75
|
+
'ねえ、{body}でしょ。…わたしに聞いてないで自分で決めなさい。',
|
|
76
|
+
'{body}。…まだ決めないの? わたし、待つの嫌いなんだからね。',
|
|
77
|
+
'ふん、{body}。…どうするのよ。早く言いなさいよね。',
|
|
78
|
+
'{body}って言ってるでしょ。…ほら、あんたの番よ。',
|
|
79
|
+
],
|
|
80
|
+
T1: [
|
|
81
|
+
'ふん、{body}。…言われなくてもやっといたわよ。',
|
|
82
|
+
'{body}。…別にあんたのためじゃないんだからね。',
|
|
83
|
+
'{body}、終わったわよ。…感謝なんていらないけど。',
|
|
84
|
+
'はい、{body}。…これくらい当然でしょ。',
|
|
85
|
+
'{body}。…ま、わたしにかかればこんなものよ。',
|
|
86
|
+
'{body}よ。…ちゃんと見てた? もう一回言わないからね。',
|
|
87
|
+
],
|
|
88
|
+
T0: [
|
|
89
|
+
'{body}…ま、まあ及第点ね。べ、別に褒めてないんだからね!',
|
|
90
|
+
'ふん、{body}じゃない。…ちょっとは見直したけど、調子に乗らないでよね。',
|
|
91
|
+
'{body}…やるじゃない。…い、今のはたまたまよ、きっと。',
|
|
92
|
+
'{body}でしょ。…ま、悪くないんじゃない? さ、次いくわよ。',
|
|
93
|
+
'{body}…。べ、別に嬉しくなんかないけど…よくやったわね。',
|
|
94
|
+
'へえ、{body}なんだ。…ふん、まぐれでもできたなら上等よ。',
|
|
95
|
+
],
|
|
96
|
+
default: [
|
|
97
|
+
'{body}。…さっさと次いきなさいよ。',
|
|
98
|
+
'{body}。…ほら、ぼけっとしないの。',
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
// ノーマル: 中央のニュートラル帯だけ。素っ気なく事実だけ。
|
|
102
|
+
normal: {
|
|
103
|
+
default: ['{body}。', '{body}。…以上よ。', '{body}。…報告終わり。'],
|
|
104
|
+
},
|
|
105
|
+
// デレ: あまあま・素直・openly 心配&応援。失敗にも寄り添う。
|
|
106
|
+
dere: {
|
|
107
|
+
T3: [
|
|
108
|
+
'あっ、{body}…!大丈夫?あわてなくていいから、一緒に直そ?',
|
|
109
|
+
'{body}みたい…。落ち込まないで、ね?あなたならきっと直せるよ。',
|
|
110
|
+
'{body}だね…。大丈夫だよ、ひとつずつ見ていこ?わたしもついてるから。',
|
|
111
|
+
'うぅ、{body}…。でも平気平気、あなたなら立て直せるって。',
|
|
112
|
+
'{body}か…。ね、深呼吸して? あわてなくていいからね。',
|
|
113
|
+
],
|
|
114
|
+
T2: [
|
|
115
|
+
'ねぇ、{body}だって。…あなたの答え、ここで待ってるね。',
|
|
116
|
+
'{body}…どうするか、ゆっくり決めていいからね。',
|
|
117
|
+
'{body}みたいだよ。…焦らなくていいよ、わたし待ってるから。',
|
|
118
|
+
'{body}だね。…あなたが決めたなら、わたしはそれでいいよ。',
|
|
119
|
+
],
|
|
120
|
+
T1: [
|
|
121
|
+
'{body}、おしまい。…おつかれさま、えらいよ。',
|
|
122
|
+
'{body}。…ちゃんとできてる、すごいね。',
|
|
123
|
+
'{body}できたよ!…ふふ、いい調子だね。',
|
|
124
|
+
'{body}。…うん、ばっちり。その調子その調子。',
|
|
125
|
+
'{body}だよ。…ね、ちゃんと進んでる。えらいえらい。',
|
|
126
|
+
],
|
|
127
|
+
T0: [
|
|
128
|
+
'{body}…!やったね、すごいすごい!わたし、ほんとに嬉しい!',
|
|
129
|
+
'わぁ、{body}だって!さすがだなぁ、大好き…!',
|
|
130
|
+
'お疲れさま。{body}…できるって信じてたよ、ほんとえらい!',
|
|
131
|
+
'{body}…!えへへ、やっぱりあなたはすごいなぁ。',
|
|
132
|
+
'やった、{body}だ!いっしょに喜ばせて?…えらすぎるよ!',
|
|
133
|
+
'{body}!…ね、がんばったもんね。ぎゅーってしたいくらい嬉しい。',
|
|
134
|
+
],
|
|
135
|
+
default: [
|
|
136
|
+
'{body}。…よくがんばったね。',
|
|
137
|
+
'{body}。…うん、おつかれさま。',
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
en: {
|
|
142
|
+
tsun: {
|
|
143
|
+
T3: [
|
|
144
|
+
"Hey! {body} again?! ...Don't just sit there — fix it!",
|
|
145
|
+
'Seriously? {body}. Clean it up, now.',
|
|
146
|
+
"I-it's not like I was worried, but... {body}. Deal with it.",
|
|
147
|
+
'{body}. ...Ugh, what were you even looking at? Fix it.',
|
|
148
|
+
"Fine, {body}. ...I'll watch over your shoulder, so hurry up.",
|
|
149
|
+
'{body}, huh. ...Figures. Stop dawdling and fix it.',
|
|
150
|
+
],
|
|
151
|
+
T2: [
|
|
152
|
+
'...{body}. It needs your call. Hurry up and decide already.',
|
|
153
|
+
"Hey, {body}. ...Don't ask me — decide it yourself.",
|
|
154
|
+
"{body}. ...Still thinking? I hate waiting, you know.",
|
|
155
|
+
"{body}, okay? ...It's your move now. Get on with it.",
|
|
156
|
+
],
|
|
157
|
+
T1: [
|
|
158
|
+
'Hmph. {body}. ...I did it without being asked, obviously.',
|
|
159
|
+
"{body}. ...Not that I did it for you or anything.",
|
|
160
|
+
"{body}, done. ...You don't have to thank me.",
|
|
161
|
+
'There, {body}. ...Obviously. This much is nothing.',
|
|
162
|
+
"{body}. ...Were you watching? I won't repeat myself.",
|
|
163
|
+
],
|
|
164
|
+
T0: [
|
|
165
|
+
"{body}... fine, that's passable. N-not that I'm impressed!",
|
|
166
|
+
'Hmph, {body}. ...A little better, I guess. Don’t let it go to your head.',
|
|
167
|
+
"{body}... not bad. ...That was a fluke, probably.",
|
|
168
|
+
"{body}. ...Okay okay, you did well. D-don't make a big deal of it.",
|
|
169
|
+
"Oh? {body}. ...A fluke or not, that'll do.",
|
|
170
|
+
],
|
|
171
|
+
default: [
|
|
172
|
+
'{body}. ...Get on with the next one.',
|
|
173
|
+
'{body}. ...Quit spacing out.',
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
normal: { default: ['{body}.', "{body}. ...That's that.", '{body}. ...Report over.'] },
|
|
177
|
+
dere: {
|
|
178
|
+
T3: [
|
|
179
|
+
"Oh no, {body}...! Are you okay? Don't panic — let's fix it together, okay?",
|
|
180
|
+
"{body}, huh... Don't be down. You've got this, I know it.",
|
|
181
|
+
"{body}... it's okay, we'll take it one step at a time. I'm right here.",
|
|
182
|
+
"Aw, {body}... take a breath, okay? No need to rush.",
|
|
183
|
+
],
|
|
184
|
+
T2: [
|
|
185
|
+
"Hey, {body}. ...I'll be right here waiting for your call.",
|
|
186
|
+
'{body}... take your time deciding, okay?',
|
|
187
|
+
"{body}, looks like. ...No rush — I'll wait for you.",
|
|
188
|
+
"{body}. ...Whatever you choose, I'm with you.",
|
|
189
|
+
],
|
|
190
|
+
T1: [
|
|
191
|
+
'{body}, all done. ...Nice work, you did great.',
|
|
192
|
+
"{body}. ...You really pulled it off. I'm proud of you.",
|
|
193
|
+
"{body}, done! ...Hehe, you're on a roll.",
|
|
194
|
+
'{body}. ...Yep, spot on. Keep it up, okay?',
|
|
195
|
+
],
|
|
196
|
+
T0: [
|
|
197
|
+
"{body}...! You did it! Amazing, amazing! I'm so happy for you!",
|
|
198
|
+
"Wow, {body}! That's incredible — good job!",
|
|
199
|
+
'Nice work. {body}... I always knew you could do it.',
|
|
200
|
+
"{body}...! Hehe, you really are amazing, you know that?",
|
|
201
|
+
"Yay, {body}! Let me be happy with you — you did so well!",
|
|
202
|
+
],
|
|
203
|
+
default: [
|
|
204
|
+
'{body}. ...You did your best, well done.',
|
|
205
|
+
"{body}. ...Good job, okay?",
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const isLangSupported = (lang) => !!BANK[lang];
|
|
212
|
+
|
|
213
|
+
// Wrap `body` with a tsundere line. `rot` rotates phrase choice so repeats vary.
|
|
214
|
+
// Unsupported language => body is returned unchanged (volume/voice still apply).
|
|
215
|
+
export const wrap = (body, eff, tier, lang = 'ja', rot = 0) => {
|
|
216
|
+
const bank = BANK[lang];
|
|
217
|
+
if (!bank || !body) return body;
|
|
218
|
+
const tone = axisFor(eff);
|
|
219
|
+
const group = bank[tone] || bank.normal;
|
|
220
|
+
const arr = (group && (group[tier] || group.default)) || ['{body}'];
|
|
221
|
+
const phrase = arr[((rot % arr.length) + arr.length) % arr.length];
|
|
222
|
+
return phrase.replace('{body}', body);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// --- Delivery / prosody ----------------------------------------------------
|
|
226
|
+
// The persona's VOICE, not just its words: how it's spoken, so the read-out has
|
|
227
|
+
// human contour instead of a flat 棒読み monotone. Each tone gets its own pace,
|
|
228
|
+
// pitch, and intonation range.
|
|
229
|
+
// say.* : macOS `say` embedded-command deltas, RELATIVE to the voice's own
|
|
230
|
+
// natural setting (rate wpm, pbas pitch base, pmod pitch range) — so
|
|
231
|
+
// it works on any voice without knowing its defaults.
|
|
232
|
+
// vv.* : VOICEVOX audio_query scales (speed/pitch/intonation; 1.0 = default).
|
|
233
|
+
// espeak : { pitch 0-99, speed wpm } for the Linux fallback.
|
|
234
|
+
// tsun = quick, higher, sharp swings (agitated scolding).
|
|
235
|
+
// dere = slower, gentle, wide warm intonation + longer pauses (affectionate).
|
|
236
|
+
// normal= mild, just enough lilt to not sound robotic.
|
|
237
|
+
// Kept deliberately SUBTLE: VOICEVOX is already expressive, so over-driving the
|
|
238
|
+
// scales (intonation ≫1.2, any pitch shift) is what makes it sound warbly and
|
|
239
|
+
// unnatural. The real ツン/デレ contrast comes from the character STYLE
|
|
240
|
+
// (ツンツン/あまあま, see voicevox.resolveStyles) — these scales only add a light
|
|
241
|
+
// pace/lilt on top, staying inside natural ranges. Same idea for `say`: a small
|
|
242
|
+
// pmod, not a big one (heavy pitch-modulation = robotic warble).
|
|
243
|
+
const PROSODY = {
|
|
244
|
+
tsun: { say: { rate: 16, pbas: 3, pmod: 3 }, vv: { speed: 1.06, pitch: 0.0, intonation: 1.2 }, espeak: { pitch: 56, speed: 190 } },
|
|
245
|
+
normal: { say: { rate: 0, pbas: 0, pmod: 2 }, vv: { speed: 1.0, pitch: 0.0, intonation: 1.0 }, espeak: { pitch: 50, speed: 175 } },
|
|
246
|
+
dere: { say: { rate: -12, pbas: 1, pmod: 4 }, vv: { speed: 0.96, pitch: 0.0, intonation: 1.1 }, espeak: { pitch: 46, speed: 160 } },
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const prosodyFor = (tone) => PROSODY[tone] || PROSODY.normal;
|
|
250
|
+
|
|
251
|
+
// Combine the user's GUI-tunable BASE scales (the normal-tone values) with this
|
|
252
|
+
// tone's nudge, for the VOICEVOX read-out. speed & intonation are scales (they
|
|
253
|
+
// multiply), pitch is an offset (it adds). base = {} => pure tone prosody, which
|
|
254
|
+
// equals the old behaviour. Returns { speed, pitch, intonation }.
|
|
255
|
+
export const effectiveProsody = (tone, base = {}) => {
|
|
256
|
+
const t = prosodyFor(tone).vv;
|
|
257
|
+
const b = { speed: 1, pitch: 0, intonation: 1, ...base };
|
|
258
|
+
return {
|
|
259
|
+
speed: b.speed * t.speed,
|
|
260
|
+
pitch: b.pitch + t.pitch,
|
|
261
|
+
intonation: b.intonation * t.intonation,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const sgn = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
266
|
+
|
|
267
|
+
// Wrap spoken text with macOS `say` embedded commands for the given tone, and
|
|
268
|
+
// turn ellipses / commas into real beats so the line breathes. Stray `[[`/`]]`
|
|
269
|
+
// in the dynamic text is neutralized first so it can't inject its own commands.
|
|
270
|
+
export const decorateForSay = (text, tone = 'normal') => {
|
|
271
|
+
if (!text) return text;
|
|
272
|
+
const p = prosodyFor(tone).say;
|
|
273
|
+
const body = String(text)
|
|
274
|
+
.replace(/\[\[|\]\]/g, '') // can't let task text smuggle in commands
|
|
275
|
+
.replace(/[…⋯]+|・・・+|\.{3,}/g, ' [[slnc 220]] ') // a short beat where it trails off
|
|
276
|
+
.replace(/([、,])\s*/g, '$1 [[slnc 70]] '); // commas breathe a touch
|
|
277
|
+
return `[[rate ${sgn(p.rate)}]] [[pbas ${sgn(p.pbas)}]] [[pmod ${sgn(p.pmod)}]] ${body}`;
|
|
278
|
+
};
|
package/src/voicevox.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// return false and the caller falls back to the OS `say` voice.
|
|
10
10
|
|
|
11
11
|
import { execSync, execFileSync } from 'node:child_process';
|
|
12
|
-
import { existsSync, statSync, mkdtempSync, rmSync, appendFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, statSync, mkdtempSync, rmSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { tmpdir, homedir } from 'node:os';
|
|
15
15
|
import { stateDir } from './state.mjs';
|
|
@@ -115,6 +115,34 @@ export const listCharacters = (url = DEFAULT_URL) => {
|
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
// For tsundere mode: given a speaker id, find the character that owns it and map
|
|
119
|
+
// its styles to { normal, tsun, dere } speaker ids (so the SAME character can
|
|
120
|
+
// speak in a ツンツン or あまあま voice). Missing styles fall back to normal.
|
|
121
|
+
export const resolveStyles = (speakerId, url = DEFAULT_URL) => {
|
|
122
|
+
try {
|
|
123
|
+
const out = execFileSync('curl', ['-s', '-m', '4', `${url}/speakers`], { encoding: 'utf8', timeout: 5000 });
|
|
124
|
+
const data = JSON.parse(out);
|
|
125
|
+
const sid = Number(speakerId);
|
|
126
|
+
for (const sp of data) {
|
|
127
|
+
const styles = sp.styles || [];
|
|
128
|
+
if (!styles.some((s) => Number(s.id) === sid)) continue;
|
|
129
|
+
const find = (re) => {
|
|
130
|
+
const m = styles.find((s) => re.test(s.name || ''));
|
|
131
|
+
return m ? Number(m.id) : null;
|
|
132
|
+
};
|
|
133
|
+
const normal = find(/ノーマル|普通/) ?? sid;
|
|
134
|
+
return {
|
|
135
|
+
normal,
|
|
136
|
+
tsun: find(/ツンツン|ツン/) ?? normal,
|
|
137
|
+
dere: find(/あまあま|甘え|デレ|ささやき/) ?? normal,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
/* engine down / parse error — caller falls back to the base speaker */
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
|
|
118
146
|
const playWav = (wav, vol = 1) => {
|
|
119
147
|
if (platform === 'darwin') execFileSync('afplay', ['-v', String(vol), wav], { timeout: 30000 });
|
|
120
148
|
else if (platform === 'linux') {
|
|
@@ -126,8 +154,25 @@ const playWav = (wav, vol = 1) => {
|
|
|
126
154
|
}
|
|
127
155
|
};
|
|
128
156
|
|
|
157
|
+
// Apply a prosody profile to a VOICEVOX audio_query JSON in place, so the
|
|
158
|
+
// read-out has human contour (pace/pitch/intonation) instead of a flat 棒読み.
|
|
159
|
+
// Only the small query JSON passes through Node; the WAV never does.
|
|
160
|
+
const applyProsody = (queryPath, prosody) => {
|
|
161
|
+
if (!prosody) return;
|
|
162
|
+
try {
|
|
163
|
+
const q = JSON.parse(readFileSync(queryPath, 'utf8'));
|
|
164
|
+
if (typeof prosody.speed === 'number') q.speedScale = prosody.speed;
|
|
165
|
+
if (typeof prosody.pitch === 'number') q.pitchScale = prosody.pitch;
|
|
166
|
+
if (typeof prosody.intonation === 'number') q.intonationScale = prosody.intonation;
|
|
167
|
+
writeFileSync(queryPath, JSON.stringify(q));
|
|
168
|
+
} catch {
|
|
169
|
+
/* leave the query untouched on any parse/IO error */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
129
173
|
// Synthesize and play. Returns true if it spoke, false to fall back to `say`.
|
|
130
|
-
|
|
174
|
+
// `prosody` (optional) = { speed, pitch, intonation } audio_query scale overrides.
|
|
175
|
+
export const speak = (text, speaker = 3, url = DEFAULT_URL, vol = 1, timeoutMs = 15000, prosody = null) => {
|
|
131
176
|
if (!text) return false;
|
|
132
177
|
let dir;
|
|
133
178
|
try {
|
|
@@ -135,12 +180,26 @@ export const speak = (text, speaker = 3, url = DEFAULT_URL, vol = 1, timeoutMs =
|
|
|
135
180
|
const wav = join(dir, 'v.wav');
|
|
136
181
|
const sec = String(Math.max(2, Math.ceil(timeoutMs / 1000)));
|
|
137
182
|
const enc = encodeURIComponent(text); // URL-encoded -> no shell metacharacters
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`curl -s -m ${sec} -X POST
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
if (prosody) {
|
|
184
|
+
// Two steps so we can tune the query JSON between them (still no WAV in Node).
|
|
185
|
+
const q = join(dir, 'q.json');
|
|
186
|
+
execSync(`curl -s -m ${sec} -X POST "${url}/audio_query?speaker=${speaker}&text=${enc}" -o "${q}"`, {
|
|
187
|
+
timeout: timeoutMs + 1000,
|
|
188
|
+
stdio: 'ignore',
|
|
189
|
+
});
|
|
190
|
+
applyProsody(q, prosody);
|
|
191
|
+
execSync(
|
|
192
|
+
`curl -s -m ${sec} -X POST -H "Content-Type: application/json" -d @"${q}" "${url}/synthesis?speaker=${speaker}" -o "${wav}"`,
|
|
193
|
+
{ timeout: timeoutMs + 1000, stdio: 'ignore' }
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
// Pipe audio_query straight into synthesis. execSync uses /bin/sh for the pipe.
|
|
197
|
+
const cmd =
|
|
198
|
+
`curl -s -m ${sec} -X POST "${url}/audio_query?speaker=${speaker}&text=${enc}" | ` +
|
|
199
|
+
`curl -s -m ${sec} -X POST -H "Content-Type: application/json" -d @- ` +
|
|
200
|
+
`"${url}/synthesis?speaker=${speaker}" -o "${wav}"`;
|
|
201
|
+
execSync(cmd, { timeout: timeoutMs + 1000, stdio: 'ignore' });
|
|
202
|
+
}
|
|
144
203
|
if (!existsSync(wav) || statSync(wav).size < 1000) {
|
|
145
204
|
logFail(`empty/short wav (speaker ${speaker}, ${text.length} chars)`);
|
|
146
205
|
return false;
|