ai-notify 0.7.1 → 0.9.0
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 +14 -0
- package/README.md +14 -0
- package/menubar/AiNotifyMenuBar.swift +234 -0
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +154 -0
- package/src/notify.mjs +35 -16
- package/src/state.mjs +27 -0
- package/src/war.mjs +118 -0
package/README.ja.md
CHANGED
|
@@ -121,6 +121,7 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
121
121
|
|
|
122
122
|
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
|
|
123
123
|
- **右クリック** → 即ミュート切替。
|
|
124
|
+
- **⚙ 設定…** → **整列したスライダー+編集可能な数値フィールド**(音量・ツンデレ・戦争・速さ/高さ/抑揚)と**プリセット保存**(`ai-notify preset save <名前>` / `load` / `delete`)の設定ウィンドウ。毎回調整し直さなくて済みます。
|
|
124
125
|
|
|
125
126
|
<p>
|
|
126
127
|
<img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
@@ -205,6 +206,19 @@ ai-notify tsundere test # T3/T2/T1/T0 のサンプルを試聴
|
|
|
205
206
|
|
|
206
207
|
**無API・決定論・オフライン**(テンプレートで生成。課金ゼロ)。緊急度はエージェントの文面からのキーワード推定(厳密な重大度ではなくベストエフォート)で、デスクトップ通知は素の文面のまま。**VOICEVOX**利用時は、強さに応じて同じキャラの**ツンツン/あまあま**スタイルを選ぶので、声色そのものがツン・デレに変わります。`lang` は `ja` / `en` 対応。
|
|
207
208
|
|
|
209
|
+
## ⚔️ 戦争モード(任意・遊び心)
|
|
210
|
+
|
|
211
|
+
ツンデレとは別の読み上げスキン。**作戦司令室**を演じます。レベルで状況が変わり、**ツンデレレベル(=オペレーターの好感度)との組合せ**で台詞が変化します:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
ai-notify war on
|
|
215
|
+
ai-notify war level 0.5 # 0 = 平時 ・ 0.5 = 戦闘中/第一種戦闘配置 ・ 1 = 危機(短く絶叫)
|
|
216
|
+
ai-notify war test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **平時** — 落ち着いた無線。**戦闘中** — 第一種戦闘配置、緊迫。**危機** — 短い絶叫、音量↑・速度↑。
|
|
220
|
+
- **ツンデレレベルが各段を味付け**(デレ=優しいオペレーター/ツン=厳しいオペレーター)。戦争×ツンデレで9通りの空気感。メニューバーにもトグル+スライダーあり。(ツンデレモード自体はOFFでも、そのスライダーは戦争モードの好感度入力として機能します。)
|
|
221
|
+
|
|
208
222
|
## ⏳ どの窓が・何を求めているか
|
|
209
223
|
|
|
210
224
|
各通知のタイトルに窓ラベルが付きます — 入力待ちは `⏳ <label>`、完了は `✓ <label>`。本文には**何を**(翻訳されたプロンプト、または作業内容の要約)が出ます。各ペインに短い `AI_NOTIFY_LABEL` を設定すれば、10個のターミナルもひと目で見分けられます。
|
package/README.md
CHANGED
|
@@ -122,6 +122,7 @@ A monochrome waveform icon shows status by color (Adobe-style): plain when idle,
|
|
|
122
122
|
|
|
123
123
|
- **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 **spoken name** (read out so you know *which* pane finished), **voice**, and **volume** — the row shows each pane's voice at a glance.
|
|
124
124
|
- **Right-click** → instant mute toggle.
|
|
125
|
+
- **⚙ 設定…** → a settings window with **aligned sliders + editable numeric fields** (volume, tsundere, war, speed/pitch/intonation) and **saveable presets** (`ai-notify preset save <name>` / `load` / `delete`), so you don't re-tune every time.
|
|
125
126
|
|
|
126
127
|
<p>
|
|
127
128
|
<img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
@@ -206,6 +207,19 @@ ai-notify tsundere test # hear T3/T2/T1/T0 samples
|
|
|
206
207
|
|
|
207
208
|
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`.
|
|
208
209
|
|
|
210
|
+
## ⚔️ War mode (optional, fun)
|
|
211
|
+
|
|
212
|
+
A separate read-out skin: a **military ops room**. The level sets the situation, and — combined with the tsundere level (the operator's 好感度) — picks the line:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
ai-notify war on
|
|
216
|
+
ai-notify war level 0.5 # 0 = 平時 (calm) · 0.5 = 戦闘中 / 第一種戦闘配置 · 1 = 危機 (short, shouted)
|
|
217
|
+
ai-notify war test
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **平時** — calm radio chatter. **戦闘中** — general quarters, urgent. **危機** — short shouts, louder and faster.
|
|
221
|
+
- The **tsundere level flavors every band** (a warm デレ operator vs a harsh ツン one), so war × tsundere gives 9 distinct moods. Toggle + slider are in the menu bar too. (Tsundere mode itself can be off; its slider still acts as the affection input for war.)
|
|
222
|
+
|
|
209
223
|
## ⏳ Which window, and what it's asking
|
|
210
224
|
|
|
211
225
|
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.
|
|
@@ -275,9 +275,200 @@ final class PopupCard: NSObject {
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// One settings row: a label (or checkbox) + a slider + an editable numeric field,
|
|
279
|
+
// kept on a single shared grid so every row lines up. The slider and the field
|
|
280
|
+
// stay in sync; both call `onChange`.
|
|
281
|
+
final class SettingsRow: NSObject {
|
|
282
|
+
let view = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 32))
|
|
283
|
+
private let slider: NSSlider
|
|
284
|
+
private let field = NSTextField()
|
|
285
|
+
private let lo: Double
|
|
286
|
+
private let hi: Double
|
|
287
|
+
private let onChange: (Double) -> Void
|
|
288
|
+
private let onToggle: (() -> Void)?
|
|
289
|
+
|
|
290
|
+
init(title: String, asCheckbox: Bool, on: Bool, lo: Double, hi: Double, value: Double,
|
|
291
|
+
fill: NSColor, onToggle: (() -> Void)? = nil, onChange: @escaping (Double) -> Void) {
|
|
292
|
+
self.lo = lo; self.hi = hi; self.onChange = onChange; self.onToggle = onToggle
|
|
293
|
+
slider = NSSlider(value: value, minValue: lo, maxValue: hi, target: nil, action: nil)
|
|
294
|
+
super.init()
|
|
295
|
+
|
|
296
|
+
if asCheckbox {
|
|
297
|
+
let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(toggled))
|
|
298
|
+
cb.frame = NSRect(x: 16, y: 6, width: 106, height: 20)
|
|
299
|
+
cb.state = on ? .on : .off
|
|
300
|
+
view.addSubview(cb)
|
|
301
|
+
} else {
|
|
302
|
+
let lbl = NSTextField(labelWithString: title)
|
|
303
|
+
lbl.frame = NSRect(x: 16, y: 7, width: 106, height: 18)
|
|
304
|
+
lbl.textColor = .labelColor
|
|
305
|
+
view.addSubview(lbl)
|
|
306
|
+
}
|
|
307
|
+
// Unified grid: slider always at the same x/width, field always after it.
|
|
308
|
+
slider.frame = NSRect(x: 128, y: 5, width: 250, height: 20)
|
|
309
|
+
slider.target = self; slider.action = #selector(sliderMoved)
|
|
310
|
+
slider.isContinuous = true
|
|
311
|
+
slider.trackFillColor = fill
|
|
312
|
+
view.addSubview(slider)
|
|
313
|
+
|
|
314
|
+
field.frame = NSRect(x: 392, y: 5, width: 56, height: 20)
|
|
315
|
+
field.alignment = .right
|
|
316
|
+
field.target = self; field.action = #selector(fieldEdited)
|
|
317
|
+
view.addSubview(field)
|
|
318
|
+
setField(value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private func setField(_ v: Double) { field.stringValue = String(format: "%.2f", v) }
|
|
322
|
+
@objc private func toggled() { onToggle?() }
|
|
323
|
+
@objc private func sliderMoved() { setField(slider.doubleValue); onChange(slider.doubleValue) }
|
|
324
|
+
@objc private func fieldEdited() {
|
|
325
|
+
var v = Double(field.stringValue) ?? slider.doubleValue
|
|
326
|
+
v = min(hi, max(lo, v))
|
|
327
|
+
slider.doubleValue = v; setField(v); onChange(v)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The settings window: aligned sliders + editable numeric fields for volume,
|
|
332
|
+
// tsundere, war, and the VOICEVOX prosody, plus a saveable preset bar.
|
|
333
|
+
final class SettingsWindowController: NSObject {
|
|
334
|
+
private var window: NSWindow?
|
|
335
|
+
private var presetPopup: NSPopUpButton?
|
|
336
|
+
var windowNumber: Int { window?.windowNumber ?? 0 }
|
|
337
|
+
|
|
338
|
+
func show() {
|
|
339
|
+
if window == nil { build() }
|
|
340
|
+
reloadValues()
|
|
341
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
342
|
+
window?.center()
|
|
343
|
+
window?.makeKeyAndOrderFront(nil)
|
|
344
|
+
window?.orderFrontRegardless()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func menuJSON() -> [String: Any] {
|
|
348
|
+
(State.cli(["menu-json"], capture: true)?.data(using: .utf8))
|
|
349
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private func build() {
|
|
353
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 470, height: 360),
|
|
354
|
+
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
|
355
|
+
w.title = "ai-notify 設定"
|
|
356
|
+
w.isReleasedWhenClosed = false
|
|
357
|
+
window = w
|
|
358
|
+
rebuildContent()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Re-create the rows from the current state (also used after loading a preset).
|
|
362
|
+
private func rebuildContent() {
|
|
363
|
+
guard let w = window else { return }
|
|
364
|
+
let j = menuJSON()
|
|
365
|
+
let content = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 360))
|
|
366
|
+
|
|
367
|
+
// Preset bar.
|
|
368
|
+
let presetLabel = NSTextField(labelWithString: "プリセット")
|
|
369
|
+
presetLabel.frame = NSRect(x: 16, y: 322, width: 70, height: 18)
|
|
370
|
+
content.addSubview(presetLabel)
|
|
371
|
+
let popup = NSPopUpButton(frame: NSRect(x: 92, y: 318, width: 180, height: 26))
|
|
372
|
+
for name in presetNames() { popup.addItem(withTitle: name) }
|
|
373
|
+
if popup.numberOfItems == 0 { popup.addItem(withTitle: "(なし)"); popup.isEnabled = false }
|
|
374
|
+
content.addSubview(popup)
|
|
375
|
+
presetPopup = popup
|
|
376
|
+
let apply = NSButton(title: "適用", target: self, action: #selector(applyPreset)); apply.frame = NSRect(x: 278, y: 318, width: 52, height: 26); apply.bezelStyle = .rounded; content.addSubview(apply)
|
|
377
|
+
let save = NSButton(title: "保存…", target: self, action: #selector(savePreset)); save.frame = NSRect(x: 332, y: 318, width: 60, height: 26); save.bezelStyle = .rounded; content.addSubview(save)
|
|
378
|
+
let del = NSButton(title: "削除", target: self, action: #selector(deletePreset)); del.frame = NSRect(x: 394, y: 318, width: 52, height: 26); del.bezelStyle = .rounded; content.addSubview(del)
|
|
379
|
+
|
|
380
|
+
let sep = NSBox(frame: NSRect(x: 12, y: 306, width: 446, height: 1)); sep.boxType = .separator; content.addSubview(sep)
|
|
381
|
+
|
|
382
|
+
// Parameter rows (top-down).
|
|
383
|
+
let blue = NSColor(srgbRed: 0, green: 122.0 / 255.0, blue: 1, alpha: 1)
|
|
384
|
+
let pink = NSColor.systemPink
|
|
385
|
+
let red = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1)
|
|
386
|
+
let tsun = j["tsundere"] as? [String: Any]
|
|
387
|
+
let warj = j["war"] as? [String: Any]
|
|
388
|
+
let pr = j["prosody"] as? [String: Any] ?? [:]
|
|
389
|
+
let range = j["prosodyRange"] as? [String: Any] ?? [:]
|
|
390
|
+
func bound(_ key: String, _ dlo: Double, _ dhi: Double) -> (Double, Double) {
|
|
391
|
+
let r = range[key] as? [Any]
|
|
392
|
+
return ((r?.first as? Double) ?? dlo, (r?.last as? Double) ?? dhi)
|
|
393
|
+
}
|
|
394
|
+
let (slo, shi) = bound("speed", 0.5, 1.5)
|
|
395
|
+
let (plo, phi) = bound("pitch", -0.15, 0.15)
|
|
396
|
+
let (ilo, ihi) = bound("intonation", 0.0, 1.5)
|
|
397
|
+
|
|
398
|
+
let rows: [SettingsRow] = [
|
|
399
|
+
SettingsRow(title: "音量", asCheckbox: false, on: false, lo: 0, hi: 2, value: (j["volume"] as? Double) ?? 1, fill: blue,
|
|
400
|
+
onChange: { State.cli(["volume", String(format: "%.2f", $0)]) }),
|
|
401
|
+
SettingsRow(title: "ツンデレ", asCheckbox: true, on: (tsun?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (tsun?["level"] as? Double) ?? 0.5, fill: pink,
|
|
402
|
+
onToggle: { State.cli(["tsundere", "toggle"]) },
|
|
403
|
+
onChange: { State.cli(["tsundere", "level", String(format: "%.2f", $0)]) }),
|
|
404
|
+
SettingsRow(title: "戦争", asCheckbox: true, on: (warj?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (warj?["level"] as? Double) ?? 0.5, fill: red,
|
|
405
|
+
onToggle: { State.cli(["war", "toggle"]) },
|
|
406
|
+
onChange: { State.cli(["war", "level", String(format: "%.2f", $0)]) }),
|
|
407
|
+
SettingsRow(title: "速さ", asCheckbox: false, on: false, lo: slo, hi: shi, value: (pr["speed"] as? Double) ?? 1, fill: blue,
|
|
408
|
+
onChange: { State.cli(["voice-prosody", "speed", String(format: "%.3f", $0)]) }),
|
|
409
|
+
SettingsRow(title: "高さ", asCheckbox: false, on: false, lo: plo, hi: phi, value: (pr["pitch"] as? Double) ?? 0, fill: blue,
|
|
410
|
+
onChange: { State.cli(["voice-prosody", "pitch", String(format: "%.3f", $0)]) }),
|
|
411
|
+
SettingsRow(title: "抑揚", asCheckbox: false, on: false, lo: ilo, hi: ihi, value: (pr["intonation"] as? Double) ?? 1, fill: blue,
|
|
412
|
+
onChange: { State.cli(["voice-prosody", "intonation", String(format: "%.3f", $0)]) }),
|
|
413
|
+
]
|
|
414
|
+
var y = 264
|
|
415
|
+
let header = NSTextField(labelWithString: "ツンデレ/戦争は 0=デレ・平時 〜 1=ツン・危機")
|
|
416
|
+
header.frame = NSRect(x: 16, y: 286, width: 440, height: 16)
|
|
417
|
+
header.font = .systemFont(ofSize: 11); header.textColor = .secondaryLabelColor
|
|
418
|
+
content.addSubview(header)
|
|
419
|
+
for r in rows {
|
|
420
|
+
r.view.frame.origin = NSPoint(x: 0, y: CGFloat(y))
|
|
421
|
+
content.addSubview(r.view)
|
|
422
|
+
self.rows.append(r) // retain
|
|
423
|
+
y -= 36
|
|
424
|
+
}
|
|
425
|
+
w.contentView = content
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private var rows: [SettingsRow] = []
|
|
429
|
+
|
|
430
|
+
private func presetNames() -> [String] {
|
|
431
|
+
let out = State.cli(["preset", "list"], capture: true) ?? ""
|
|
432
|
+
return out.split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) }
|
|
433
|
+
.filter { !$0.isEmpty && $0 != "(no presets)" }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func reloadValues() {
|
|
437
|
+
rows.removeAll()
|
|
438
|
+
rebuildContent()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
@objc private func applyPreset() {
|
|
442
|
+
guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
|
|
443
|
+
State.cli(["preset", "load", name])
|
|
444
|
+
reloadValues()
|
|
445
|
+
}
|
|
446
|
+
@objc private func deletePreset() {
|
|
447
|
+
guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
|
|
448
|
+
State.cli(["preset", "delete", name])
|
|
449
|
+
reloadValues()
|
|
450
|
+
}
|
|
451
|
+
@objc private func savePreset() {
|
|
452
|
+
let alert = NSAlert()
|
|
453
|
+
alert.messageText = "プリセットを保存"
|
|
454
|
+
alert.informativeText = "現在の音量・ツンデレ・戦争・読み上げ設定を名前を付けて保存します"
|
|
455
|
+
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
|
|
456
|
+
field.placeholderString = "例: 集中モード"
|
|
457
|
+
alert.accessoryView = field
|
|
458
|
+
alert.addButton(withTitle: "保存"); alert.addButton(withTitle: "キャンセル")
|
|
459
|
+
alert.window.initialFirstResponder = field
|
|
460
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
461
|
+
let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
462
|
+
guard !name.isEmpty else { return }
|
|
463
|
+
State.cli(["preset", "save", name])
|
|
464
|
+
reloadValues()
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
278
468
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
279
469
|
private var statusItem: NSStatusItem!
|
|
280
470
|
private var timer: Timer?
|
|
471
|
+
private let settings = SettingsWindowController()
|
|
281
472
|
|
|
282
473
|
// The "waiting for input" popup — one floating card per waiting pane.
|
|
283
474
|
private var waitingCards: [String: PopupCard] = [:] // keyed by tty
|
|
@@ -424,6 +615,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
424
615
|
|
|
425
616
|
private func toggle() { State.setMuted(!State.isMuted); render() }
|
|
426
617
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
618
|
+
@objc private func openSettings() { settings.show() }
|
|
427
619
|
|
|
428
620
|
@objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
|
|
429
621
|
// Slider is shown reversed (left = ツン, right = デレ) but the file keeps the
|
|
@@ -552,6 +744,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
552
744
|
return item
|
|
553
745
|
}
|
|
554
746
|
|
|
747
|
+
// War mode on/off checkbox + 平時⇄危機 slider (level 0–1). Separate axis from
|
|
748
|
+
// tsundere; the tsundere level flavors it.
|
|
749
|
+
private func warToggleRow(on: Bool) -> NSMenuItem {
|
|
750
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
|
|
751
|
+
let btn = NSButton(checkboxWithTitle: "戦争モード", target: self, action: #selector(warToggled(_:)))
|
|
752
|
+
btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
|
|
753
|
+
btn.state = on ? .on : .off
|
|
754
|
+
row.addSubview(btn)
|
|
755
|
+
let item = NSMenuItem(); item.view = row
|
|
756
|
+
return item
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private func warRow(value: Double) -> NSMenuItem {
|
|
760
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
|
|
761
|
+
let left = NSTextField(labelWithString: "平時")
|
|
762
|
+
left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
|
|
763
|
+
left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
|
|
764
|
+
let slider = NSSlider(value: value, minValue: 0, maxValue: 1, target: self, action: #selector(warLevelChanged(_:)))
|
|
765
|
+
slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
|
|
766
|
+
slider.isContinuous = false
|
|
767
|
+
slider.trackFillColor = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1) // war red
|
|
768
|
+
let right = NSTextField(labelWithString: "危機")
|
|
769
|
+
right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
|
|
770
|
+
right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
|
|
771
|
+
row.addSubview(left); row.addSubview(slider); row.addSubview(right)
|
|
772
|
+
let item = NSMenuItem(); item.view = row
|
|
773
|
+
return item
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
@objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
|
|
777
|
+
@objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
|
|
778
|
+
|
|
555
779
|
// representedObject is the full CLI arg array to run.
|
|
556
780
|
@objc private func runItem(_ item: NSMenuItem) {
|
|
557
781
|
if let cmd = item.representedObject as? [String] { State.cli(cmd) }
|
|
@@ -583,6 +807,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
583
807
|
let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
|
|
584
808
|
menu.addItem(tsundereToggleRow(on: tsunOn))
|
|
585
809
|
menu.addItem(tsundereRow(value: tsunLevel))
|
|
810
|
+
|
|
811
|
+
// War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
|
|
812
|
+
let warJson = json?["war"] as? [String: Any]
|
|
813
|
+
menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
|
|
814
|
+
menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
|
|
586
815
|
menu.addItem(.separator())
|
|
587
816
|
|
|
588
817
|
// VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
|
|
@@ -725,6 +954,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
725
954
|
notifyParent.submenu = notifySub
|
|
726
955
|
menu.addItem(notifyParent)
|
|
727
956
|
|
|
957
|
+
menu.addItem(.separator())
|
|
958
|
+
let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
|
|
959
|
+
settingsItem.target = self
|
|
960
|
+
menu.addItem(settingsItem)
|
|
961
|
+
|
|
728
962
|
menu.addItem(.separator())
|
|
729
963
|
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
730
964
|
quitItem.target = self
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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": {
|
package/src/cli.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { translate } from './translate.mjs';
|
|
|
13
13
|
import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
|
|
14
14
|
import * as voicevox from './voicevox.mjs';
|
|
15
15
|
import * as tsundere from './tsundere.mjs';
|
|
16
|
+
import * as war from './war.mjs';
|
|
16
17
|
import {
|
|
17
18
|
isMuted,
|
|
18
19
|
setMuted,
|
|
@@ -45,6 +46,10 @@ import {
|
|
|
45
46
|
getNotifyKinds,
|
|
46
47
|
setNotifyKind,
|
|
47
48
|
isNotifyKindEnabled,
|
|
49
|
+
isWarEnabled,
|
|
50
|
+
setWarEnabled,
|
|
51
|
+
readWarLevel,
|
|
52
|
+
setWarLevel,
|
|
48
53
|
} from './state.mjs';
|
|
49
54
|
import { resolve as resolvePath, join as pathJoin } from 'node:path';
|
|
50
55
|
|
|
@@ -446,6 +451,152 @@ const cmds = {
|
|
|
446
451
|
if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
|
|
447
452
|
},
|
|
448
453
|
|
|
454
|
+
// Named presets: snapshot the current volume / prosody / tsundere / war and
|
|
455
|
+
// restore them later, so you don't re-tune every time.
|
|
456
|
+
// preset [list | save <name> | load <name> | delete <name>]
|
|
457
|
+
preset() {
|
|
458
|
+
const sub = positionals[0] || 'list';
|
|
459
|
+
const file = pathJoin(paths.stateDir(), 'presets.json');
|
|
460
|
+
const read = () => {
|
|
461
|
+
try {
|
|
462
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
463
|
+
} catch {
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const write = (o) => {
|
|
468
|
+
mkdirSync(paths.stateDir(), { recursive: true });
|
|
469
|
+
writeFileSync(file, JSON.stringify(o, null, 2));
|
|
470
|
+
};
|
|
471
|
+
const name = positionals.slice(1).join(' ').trim();
|
|
472
|
+
|
|
473
|
+
if (sub === 'list') {
|
|
474
|
+
const names = Object.keys(read());
|
|
475
|
+
return log(names.length ? names.join('\n') : '(no presets)');
|
|
476
|
+
}
|
|
477
|
+
if (sub === 'save') {
|
|
478
|
+
if (!name) return void (console.error('usage: preset save <name>'), process.exit(1));
|
|
479
|
+
const config = readConfig();
|
|
480
|
+
const snap = {
|
|
481
|
+
volume: readVolume() != null ? readVolume() : config.volume ?? 1,
|
|
482
|
+
prosody: readVoiceProsody(),
|
|
483
|
+
tsundere: {
|
|
484
|
+
enabled: !!config.tsundere?.enabled,
|
|
485
|
+
level: readTsundereLevel() != null ? readTsundereLevel() : config.tsundere?.level ?? 0.5,
|
|
486
|
+
},
|
|
487
|
+
war: { enabled: isWarEnabled(), level: readWarLevel() },
|
|
488
|
+
};
|
|
489
|
+
const p = read();
|
|
490
|
+
p[name] = snap;
|
|
491
|
+
write(p);
|
|
492
|
+
return log(`💾 saved preset "${name}"`);
|
|
493
|
+
}
|
|
494
|
+
if (sub === 'delete' || sub === 'rm') {
|
|
495
|
+
const p = read();
|
|
496
|
+
if (!(name in p)) return void (console.error(`no preset: ${name}`), process.exit(1));
|
|
497
|
+
delete p[name];
|
|
498
|
+
write(p);
|
|
499
|
+
return log(`deleted "${name}"`);
|
|
500
|
+
}
|
|
501
|
+
if (sub === 'load' || sub === 'apply') {
|
|
502
|
+
const s = read()[name];
|
|
503
|
+
if (!s) return void (console.error(`no preset: ${name}`), process.exit(1));
|
|
504
|
+
if (typeof s.volume === 'number') setVolume(s.volume);
|
|
505
|
+
if (s.prosody) for (const k of ['speed', 'pitch', 'intonation']) if (typeof s.prosody[k] === 'number') setVoiceProsody(k, s.prosody[k]);
|
|
506
|
+
if (s.tsundere) {
|
|
507
|
+
if (typeof s.tsundere.level === 'number') setTsundereLevel(s.tsundere.level);
|
|
508
|
+
const config = readConfig();
|
|
509
|
+
config.tsundere = { ...(config.tsundere || {}), enabled: !!s.tsundere.enabled };
|
|
510
|
+
writeConfig(config);
|
|
511
|
+
}
|
|
512
|
+
if (s.war) {
|
|
513
|
+
setWarEnabled(!!s.war.enabled);
|
|
514
|
+
if (typeof s.war.level === 'number') setWarLevel(s.war.level);
|
|
515
|
+
}
|
|
516
|
+
return log(`▶ loaded preset "${name}"`);
|
|
517
|
+
}
|
|
518
|
+
console.error('usage: preset [list | save <name> | load <name> | delete <name>]');
|
|
519
|
+
process.exit(1);
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
// War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
|
|
523
|
+
// max 危機的. Combined with the tsundere level for the operator's 好感度.
|
|
524
|
+
// war [on|off|toggle|level <0-1>|test|status]
|
|
525
|
+
war() {
|
|
526
|
+
const sub = positionals[0] || 'status';
|
|
527
|
+
const config = readConfig();
|
|
528
|
+
const ts = config.tsundere || {};
|
|
529
|
+
const url = config.voicevox?.url || voicevox.DEFAULT_URL;
|
|
530
|
+
|
|
531
|
+
if (sub === 'on' || sub === 'off' || sub === 'toggle') {
|
|
532
|
+
const enabled = sub === 'toggle' ? !isWarEnabled() : sub === 'on';
|
|
533
|
+
setWarEnabled(enabled);
|
|
534
|
+
// Cache the VOICEVOX tsun/dere style map so fire-time skips the lookup.
|
|
535
|
+
if (enabled && config.tts === 'voicevox') {
|
|
536
|
+
const sm = voicevox.resolveStyles(config.voicevox?.speaker, url);
|
|
537
|
+
if (sm) {
|
|
538
|
+
config.tsundere = { ...ts, styleMap: sm };
|
|
539
|
+
writeConfig(config);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
log(enabled ? '⚔️ 戦争モード ON(平時⇄戦闘⇄危機・ツンデレ好感度で口調変化)' : '戦争モード OFF');
|
|
543
|
+
if (enabled) {
|
|
544
|
+
log(' レベル: ai-notify war level <0=平時 〜 0.5=戦闘 〜 1=危機>');
|
|
545
|
+
log(' 試聴: ai-notify war test');
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (sub === 'level') {
|
|
550
|
+
const arg = positionals[1];
|
|
551
|
+
if (arg === undefined) return log(`war level: ${readWarLevel()} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
552
|
+
return log(`⚔️ war level → ${setWarLevel(arg)} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
553
|
+
}
|
|
554
|
+
if (sub === 'test') {
|
|
555
|
+
const lang = ts.lang || 'ja';
|
|
556
|
+
const level = readWarLevel();
|
|
557
|
+
const aff = readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5;
|
|
558
|
+
const sm = config.tts === 'voicevox' ? ts.styleMap || voicevox.resolveStyles(config.voicevox?.speaker, url) : null;
|
|
559
|
+
log(`war test (level ${level} = ${war.band(level)}, 好感度 ${aff}, lang ${lang}):\n`);
|
|
560
|
+
const rows =
|
|
561
|
+
lang === 'ja'
|
|
562
|
+
? [
|
|
563
|
+
{ tier: 'T3', body: 'ビルドが失敗' },
|
|
564
|
+
{ tier: 'T2', body: '許可待ち' },
|
|
565
|
+
{ tier: 'T1', body: '3ファイルを更新' },
|
|
566
|
+
{ tier: 'T0', body: 'テスト全部パス' },
|
|
567
|
+
]
|
|
568
|
+
: [
|
|
569
|
+
{ tier: 'T3', body: 'the build failed' },
|
|
570
|
+
{ tier: 'T2', body: 'waiting for approval' },
|
|
571
|
+
{ tier: 'T1', body: 'updated 3 files' },
|
|
572
|
+
{ tier: 'T0', body: 'all tests passed' },
|
|
573
|
+
];
|
|
574
|
+
for (const s of rows) {
|
|
575
|
+
const eff = tsundere.effectiveLevel(aff, s.tier, ts.urgencyShift !== false);
|
|
576
|
+
const text = war.wrap(s.body, level, eff, lang, 0);
|
|
577
|
+
const mul = war.volumeMul(level, s.tier);
|
|
578
|
+
const tone = tsundere.axisFor(eff);
|
|
579
|
+
log(` [${s.tier} ×${mul.toFixed(2)} ${tone}] ${text}`);
|
|
580
|
+
if (sm) {
|
|
581
|
+
const speaker = sm[tone] ?? config.voicevox?.speaker;
|
|
582
|
+
voicevox.speak(text, speaker, url, mul, undefined, war.effectiveProsody(level, tsundere.effectiveProsody(tone, readVoiceProsody())));
|
|
583
|
+
} else {
|
|
584
|
+
try {
|
|
585
|
+
execFileSync('say', config.voice ? ['-v', config.voice, tsundere.decorateForSay(text, tone)] : [tsundere.decorateForSay(text, tone)], { stdio: 'ignore' });
|
|
586
|
+
} catch {
|
|
587
|
+
/* non-mac / no say */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// status
|
|
594
|
+
log(`war mode: ${isWarEnabled() ? '⚔️ ON' : 'OFF'}`);
|
|
595
|
+
log(` level: ${readWarLevel()} → ${war.band(readWarLevel())} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
596
|
+
log(` 好感度 (tsundere level): ${readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5}`);
|
|
597
|
+
if (!isWarEnabled()) log('\nEnable: ai-notify war on 試聴: ai-notify war test');
|
|
598
|
+
},
|
|
599
|
+
|
|
449
600
|
// Assign a voice to a specific pane (by tty), from the menu bar.
|
|
450
601
|
// voice-pane <tty> voicevox <id> | say <name> | clear
|
|
451
602
|
'voice-pane'() {
|
|
@@ -786,6 +937,7 @@ const cmds = {
|
|
|
786
937
|
voices,
|
|
787
938
|
panes,
|
|
788
939
|
tsundere: { enabled: !!config.tsundere?.enabled, level: tsLevel },
|
|
940
|
+
war: { enabled: isWarEnabled(), level: readWarLevel() },
|
|
789
941
|
tts: config.tts || 'say',
|
|
790
942
|
prosody: readVoiceProsody(),
|
|
791
943
|
prosodyRange: VOICE_PROSODY_RANGE,
|
|
@@ -948,6 +1100,8 @@ Usage:
|
|
|
948
1100
|
ai-notify voice [number|name|preview|default] pick the spoken voice
|
|
949
1101
|
ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
950
1102
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
1103
|
+
ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
|
|
1104
|
+
ai-notify preset [list|save <name>|load <name>|delete <name>] save/restore volume+tsundere+war+prosody
|
|
951
1105
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
952
1106
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
953
1107
|
ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
|
package/src/notify.mjs
CHANGED
|
@@ -18,12 +18,15 @@ import {
|
|
|
18
18
|
readTsundereLevel,
|
|
19
19
|
readVoiceProsody,
|
|
20
20
|
nextCounter,
|
|
21
|
+
isWarEnabled,
|
|
22
|
+
readWarLevel,
|
|
21
23
|
} from './state.mjs';
|
|
22
24
|
import { controllingTty } from './util.mjs';
|
|
23
25
|
import { translate } from './translate.mjs';
|
|
24
26
|
import { highlightWaiting, clearHighlight } from './highlight.mjs';
|
|
25
27
|
import * as voicevox from './voicevox.mjs';
|
|
26
28
|
import * as tsundere from './tsundere.mjs';
|
|
29
|
+
import * as war from './war.mjs';
|
|
27
30
|
|
|
28
31
|
const platform = process.platform; // 'darwin' | 'linux' | 'win32'
|
|
29
32
|
|
|
@@ -234,29 +237,44 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
234
237
|
let outText = speakText;
|
|
235
238
|
let outVol = vol;
|
|
236
239
|
let outSpeaker = speaker;
|
|
237
|
-
let speakTone = 'normal'; // delivery contour; tsundere
|
|
240
|
+
let speakTone = 'normal'; // delivery contour; tsundere/war set it to tsun/dere
|
|
241
|
+
let warActive = false;
|
|
242
|
+
let warLevel = 0.5;
|
|
238
243
|
const ts = config.tsundere;
|
|
239
|
-
|
|
240
|
-
|
|
244
|
+
const tier = tsundere.classifyUrgency(event, message, fullBody);
|
|
245
|
+
// The tsundere LEVEL doubles as the operator's 好感度 for war mode, so resolve
|
|
246
|
+
// it even when tsundere skinning is off.
|
|
247
|
+
const tsBaseLevel = (() => {
|
|
241
248
|
const envLevel = parseFloat(process.env.AI_NOTIFY_TSUNDERE_LEVEL);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
if (Number.isFinite(envLevel)) return Math.min(1, Math.max(0, envLevel));
|
|
250
|
+
if (typeof pane.tsundere === 'number') return pane.tsundere;
|
|
251
|
+
const f = readTsundereLevel();
|
|
252
|
+
if (f != null) return f;
|
|
253
|
+
return typeof ts?.level === 'number' ? ts.level : 0.5;
|
|
254
|
+
})();
|
|
255
|
+
|
|
256
|
+
if (isWarEnabled()) {
|
|
257
|
+
// War mode skins the read-out (ops room); the tsundere level flavors it.
|
|
258
|
+
warActive = true;
|
|
259
|
+
warLevel = readWarLevel();
|
|
260
|
+
const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts?.urgencyShift !== false);
|
|
261
|
+
speakTone = tsundere.axisFor(eff);
|
|
262
|
+
outVol = Math.min(2, Math.max(0, vol * war.volumeMul(warLevel, tier)));
|
|
263
|
+
outText = war.wrap(spokenBody, warLevel, eff, ts?.lang || 'ja', nextCounter('war'));
|
|
264
|
+
if (spokenName) outText = joinName(spokenName, outText);
|
|
265
|
+
if (tts === 'voicevox') {
|
|
266
|
+
const sm = ts?.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
|
|
267
|
+
if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
|
|
268
|
+
}
|
|
269
|
+
} else if (ts && ts.enabled) {
|
|
270
|
+
const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts.urgencyShift !== false);
|
|
252
271
|
speakTone = tsundere.axisFor(eff);
|
|
253
272
|
outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
|
|
254
273
|
outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
|
|
255
274
|
if (spokenName) outText = joinName(spokenName, outText);
|
|
256
275
|
if (tts === 'voicevox') {
|
|
257
276
|
const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
|
|
258
|
-
|
|
259
|
-
if (sm && sm[axis] != null) outSpeaker = sm[axis];
|
|
277
|
+
if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
|
|
260
278
|
}
|
|
261
279
|
}
|
|
262
280
|
|
|
@@ -265,7 +283,8 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
265
283
|
if (config.speak && outVol > 0) {
|
|
266
284
|
let spoken = false;
|
|
267
285
|
if (tts === 'voicevox') {
|
|
268
|
-
|
|
286
|
+
let prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
|
|
287
|
+
if (warActive) prosody = war.effectiveProsody(warLevel, prosody); // band scale on top
|
|
269
288
|
spoken = voicevox.speak(outText, outSpeaker, config.voicevox?.url, outVol, undefined, prosody);
|
|
270
289
|
}
|
|
271
290
|
if (!spoken) speak(outText, voice, outVol, speakTone); // OS `say` (also the VOICEVOX fallback)
|
package/src/state.mjs
CHANGED
|
@@ -90,6 +90,33 @@ export const setTsundereLevel = (v) => {
|
|
|
90
90
|
return n;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
// --- War mode --------------------------------------------------------------
|
|
94
|
+
// A separate read-out skin (military ops room). enabled flag + 0–1 level:
|
|
95
|
+
// min 平時 / mid 戦闘中 / max 危機的. Combined with the tsundere level for the
|
|
96
|
+
// operator's 好感度. Same small-file pattern as the mute flag / tsundere level.
|
|
97
|
+
const warFlagPath = () => join(stateDir(), 'war-enabled');
|
|
98
|
+
const warLevelPath = () => join(stateDir(), 'war-level');
|
|
99
|
+
export const isWarEnabled = () => existsSync(warFlagPath());
|
|
100
|
+
export const setWarEnabled = (on) => {
|
|
101
|
+
ensureDir(stateDir());
|
|
102
|
+
if (on) writeFileSync(warFlagPath(), '');
|
|
103
|
+
else rmSync(warFlagPath(), { force: true });
|
|
104
|
+
};
|
|
105
|
+
export const readWarLevel = () => {
|
|
106
|
+
try {
|
|
107
|
+
const v = parseFloat(readFileSync(warLevelPath(), 'utf8'));
|
|
108
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.5;
|
|
109
|
+
} catch {
|
|
110
|
+
return 0.5;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
export const setWarLevel = (v) => {
|
|
114
|
+
const n = Math.min(1, Math.max(0, Number(v)));
|
|
115
|
+
ensureDir(stateDir());
|
|
116
|
+
writeFileSync(warLevelPath(), String(n));
|
|
117
|
+
return n;
|
|
118
|
+
};
|
|
119
|
+
|
|
93
120
|
// --- VOICEVOX base prosody -------------------------------------------------
|
|
94
121
|
// User-tunable BASE scales for the VOICEVOX read-out — the values used at the
|
|
95
122
|
// NORMAL tone; tsundere tones nudge from here. Written by the menu bar sliders /
|
package/src/war.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// War mode: skin the spoken read-out as a military operations room. A separate
|
|
2
|
+
// axis from tsundere — the WAR LEVEL sets the situation, the tsundere level (if
|
|
3
|
+
// on) sets the operator's 好感度 (affection), and the combination picks the line:
|
|
4
|
+
//
|
|
5
|
+
// war level min → 平時 (peacetime, calm radio chatter)
|
|
6
|
+
// mid → 戦闘中 / 第一種戦闘配置 (general quarters, urgent)
|
|
7
|
+
// max → 危機的状況 (no slack — short, shouted)
|
|
8
|
+
// affection dere (warm) ⇄ normal ⇄ tsun (harsh) — flavors every band
|
|
9
|
+
//
|
|
10
|
+
// Deterministic, offline, SFW. Like tsundere.mjs, only the spoken text is
|
|
11
|
+
// wrapped; the desktop banner stays factual.
|
|
12
|
+
|
|
13
|
+
import { axisFor } from './tsundere.mjs';
|
|
14
|
+
|
|
15
|
+
// War situation band from the 0–1 level.
|
|
16
|
+
export const band = (level) => {
|
|
17
|
+
const v = Number.isFinite(level) ? level : 0.5;
|
|
18
|
+
if (v < 0.34) return 'peace';
|
|
19
|
+
if (v < 0.67) return 'combat';
|
|
20
|
+
return 'crisis';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Crisis shouts at full volume; combat is raised; peace is normal. Urgency (tier)
|
|
24
|
+
// nudges it a little more. Multiplies the user's volume.
|
|
25
|
+
const BAND_VOL = { peace: 1.0, combat: 1.18, crisis: 1.4 };
|
|
26
|
+
const TIER_VOL = { T3: 1.12, T2: 1.04, T1: 1, T0: 0.98 };
|
|
27
|
+
export const volumeMul = (level, tier) => (BAND_VOL[band(level)] || 1) * (TIER_VOL[tier] || 1);
|
|
28
|
+
|
|
29
|
+
// A VOICEVOX prosody nudge per band (combat/crisis = faster, sharper). Combined
|
|
30
|
+
// on top of the user's base scales by effectiveProsody below.
|
|
31
|
+
const BAND_PROSODY = {
|
|
32
|
+
peace: { speed: 0.98, pitch: 0.0, intonation: 1.0 },
|
|
33
|
+
combat: { speed: 1.1, pitch: 0.0, intonation: 1.2 },
|
|
34
|
+
crisis: { speed: 1.22, pitch: 0.02, intonation: 1.35 },
|
|
35
|
+
};
|
|
36
|
+
export const effectiveProsody = (level, base = {}) => {
|
|
37
|
+
const t = BAND_PROSODY[band(level)] || BAND_PROSODY.peace;
|
|
38
|
+
const b = { speed: 1, pitch: 0, intonation: 1, ...base };
|
|
39
|
+
return { speed: b.speed * t.speed, pitch: b.pitch + t.pitch, intonation: b.intonation * t.intonation };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// BANK[lang][band][tone] = [lines]. `{body}` keeps the task gist so it stays
|
|
43
|
+
// informative. Crisis lines are short and shouted; peace lines are calm.
|
|
44
|
+
const BANK = {
|
|
45
|
+
ja: {
|
|
46
|
+
peace: {
|
|
47
|
+
tsun: [
|
|
48
|
+
'司令部より各局。{body}。…別に労ってるわけじゃないけど、引き続き警戒を怠るな。',
|
|
49
|
+
'状況、異常なし。{body}だ。気を抜くんじゃないわよ、当然でしょ。',
|
|
50
|
+
'定時報告。{body}。…ふん、これくらい当たり前。次も抜かりなくね。',
|
|
51
|
+
],
|
|
52
|
+
normal: [
|
|
53
|
+
'司令部より入電。{body}。現状、戦線は静穏。警戒態勢を維持する。',
|
|
54
|
+
'通信。{body}。各員、配置のまま待機。以上。',
|
|
55
|
+
'定時連絡。{body}。状況に変化なし、平常運転だ。',
|
|
56
|
+
],
|
|
57
|
+
dere: [
|
|
58
|
+
'司令部より各局へ。{body}だよ。落ち着いてるね、いい調子。少し休んでも大丈夫。',
|
|
59
|
+
'報告ありがと。{body}。今は穏やかだから、ゆっくりいこう?',
|
|
60
|
+
'通信。{body}。順調だね。…無理しないで、そばで見てるから。',
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
combat: {
|
|
64
|
+
tsun: [
|
|
65
|
+
'第一種戦闘配置!{body}よ。ぼーっとしてないで持ち場につきなさい!',
|
|
66
|
+
'総員戦闘配置。{body}。…ヘマしたら承知しないからね、急いで!',
|
|
67
|
+
'戦闘開始。{body}だ。手が止まってるわよ、さっさと動く!',
|
|
68
|
+
],
|
|
69
|
+
normal: [
|
|
70
|
+
'第一種戦闘配置。{body}。総員、対応急げ。',
|
|
71
|
+
'戦闘配置につけ。{body}。各局、状況を共有し対処せよ。',
|
|
72
|
+
'交戦中。{body}。手順どおり、迅速に。',
|
|
73
|
+
],
|
|
74
|
+
dere: [
|
|
75
|
+
'第一種戦闘配置だよ!{body}。大丈夫、一緒に乗り切ろう、急いで!',
|
|
76
|
+
'戦闘配置。{body}。落ち着いて、でも急いで。…ちゃんと支えるから。',
|
|
77
|
+
'交戦中だよ。{body}。焦らないで、でも手は止めないで!',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
crisis: {
|
|
81
|
+
tsun: ['緊急!{body}!早く!', '被弾!{body}!何やってんの、急いで!', '危機的状況!{body}!もたもたしない!'],
|
|
82
|
+
normal: ['緊急事態!{body}!対応急げ!', '警報!{body}!即応せよ!', '危機!{body}!直ちに対処!'],
|
|
83
|
+
dere: ['緊急だよ!{body}!お願い、急いで!', '危ない、{body}!すぐ動こう、今すぐ!', '大変、{body}!一緒に、早く!'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
en: {
|
|
87
|
+
peace: {
|
|
88
|
+
tsun: ['Command, all stations. {body}. …Not that I care, but stay sharp.', 'Status nominal. {body}. Don’t slack off.'],
|
|
89
|
+
normal: ['Command. {body}. Lines quiet, holding posture.', 'Comms. {body}. All hands, maintain station.'],
|
|
90
|
+
dere: ['Command to all. {body}. Calm out there — nice. Take a breather.', 'Report received. {body}. Steady. Don’t overdo it.'],
|
|
91
|
+
},
|
|
92
|
+
combat: {
|
|
93
|
+
tsun: ['General quarters! {body}. Stop dawdling, to your posts!', 'Battle stations. {body}. Don’t mess this up — move!'],
|
|
94
|
+
normal: ['General quarters. {body}. All hands, respond.', 'Engaged. {body}. By the numbers, quickly.'],
|
|
95
|
+
dere: ['Battle stations! {body}. We’ve got this — hurry!', 'Engaged. {body}. Easy, but keep moving. I’ve got you.'],
|
|
96
|
+
},
|
|
97
|
+
crisis: {
|
|
98
|
+
tsun: ['Emergency! {body}! Now!', 'We’re hit! {body}! Move it!'],
|
|
99
|
+
normal: ['Emergency! {body}! Respond now!', 'Alert! {body}! Immediate action!'],
|
|
100
|
+
dere: ['Emergency! {body}! Please, hurry!', 'It’s bad — {body}! Move, now!'],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const isLangSupported = (lang) => !!BANK[lang];
|
|
106
|
+
|
|
107
|
+
// Wrap `body` as a war read-out. `affectionEff` is the tsundere effective level
|
|
108
|
+
// (0 デレ – 1 ツン); when tsundere is off, pass 0.5 for a neutral operator.
|
|
109
|
+
// `rot` rotates the phrase choice so repeats vary.
|
|
110
|
+
export const wrap = (body, level, affectionEff = 0.5, lang = 'ja', rot = 0) => {
|
|
111
|
+
const bank = BANK[lang];
|
|
112
|
+
if (!bank || !body) return body;
|
|
113
|
+
const tone = axisFor(affectionEff); // tsun | normal | dere
|
|
114
|
+
const cell = (bank[band(level)] || bank.peace);
|
|
115
|
+
const arr = cell[tone] || cell.normal || ['{body}'];
|
|
116
|
+
const phrase = arr[((rot % arr.length) + arr.length) % arr.length];
|
|
117
|
+
return phrase.replace('{body}', body);
|
|
118
|
+
};
|