ai-notify 0.7.0 → 0.8.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 +13 -0
- package/README.md +13 -0
- package/menubar/AiNotifyMenuBar.swift +47 -2
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +85 -0
- package/src/notify.mjs +35 -16
- package/src/state.mjs +27 -0
- package/src/war.mjs +118 -0
package/README.ja.md
CHANGED
|
@@ -205,6 +205,19 @@ ai-notify tsundere test # T3/T2/T1/T0 のサンプルを試聴
|
|
|
205
205
|
|
|
206
206
|
**無API・決定論・オフライン**(テンプレートで生成。課金ゼロ)。緊急度はエージェントの文面からのキーワード推定(厳密な重大度ではなくベストエフォート)で、デスクトップ通知は素の文面のまま。**VOICEVOX**利用時は、強さに応じて同じキャラの**ツンツン/あまあま**スタイルを選ぶので、声色そのものがツン・デレに変わります。`lang` は `ja` / `en` 対応。
|
|
207
207
|
|
|
208
|
+
## ⚔️ 戦争モード(任意・遊び心)
|
|
209
|
+
|
|
210
|
+
ツンデレとは別の読み上げスキン。**作戦司令室**を演じます。レベルで状況が変わり、**ツンデレレベル(=オペレーターの好感度)との組合せ**で台詞が変化します:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
ai-notify war on
|
|
214
|
+
ai-notify war level 0.5 # 0 = 平時 ・ 0.5 = 戦闘中/第一種戦闘配置 ・ 1 = 危機(短く絶叫)
|
|
215
|
+
ai-notify war test
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- **平時** — 落ち着いた無線。**戦闘中** — 第一種戦闘配置、緊迫。**危機** — 短い絶叫、音量↑・速度↑。
|
|
219
|
+
- **ツンデレレベルが各段を味付け**(デレ=優しいオペレーター/ツン=厳しいオペレーター)。戦争×ツンデレで9通りの空気感。メニューバーにもトグル+スライダーあり。(ツンデレモード自体はOFFでも、そのスライダーは戦争モードの好感度入力として機能します。)
|
|
220
|
+
|
|
208
221
|
## ⏳ どの窓が・何を求めているか
|
|
209
222
|
|
|
210
223
|
各通知のタイトルに窓ラベルが付きます — 入力待ちは `⏳ <label>`、完了は `✓ <label>`。本文には**何を**(翻訳されたプロンプト、または作業内容の要約)が出ます。各ペインに短い `AI_NOTIFY_LABEL` を設定すれば、10個のターミナルもひと目で見分けられます。
|
package/README.md
CHANGED
|
@@ -206,6 +206,19 @@ ai-notify tsundere test # hear T3/T2/T1/T0 samples
|
|
|
206
206
|
|
|
207
207
|
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
208
|
|
|
209
|
+
## ⚔️ War mode (optional, fun)
|
|
210
|
+
|
|
211
|
+
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:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
ai-notify war on
|
|
215
|
+
ai-notify war level 0.5 # 0 = 平時 (calm) · 0.5 = 戦闘中 / 第一種戦闘配置 · 1 = 危機 (short, shouted)
|
|
216
|
+
ai-notify war test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **平時** — calm radio chatter. **戦闘中** — general quarters, urgent. **危機** — short shouts, louder and faster.
|
|
220
|
+
- 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.)
|
|
221
|
+
|
|
209
222
|
## ⏳ Which window, and what it's asking
|
|
210
223
|
|
|
211
224
|
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.
|
|
@@ -193,8 +193,16 @@ final class PopupCard: NSObject {
|
|
|
193
193
|
var onClick: () -> Void = {}
|
|
194
194
|
|
|
195
195
|
override init() {
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
// A non-activating floating panel: it receives clicks (the close button
|
|
197
|
+
// and the card's mouseDown both fire) without the background menu-bar app
|
|
198
|
+
// ever becoming active — the reliable way to make a HUD-style window
|
|
199
|
+
// clickable. A plain NSWindow drops the first click while inactive.
|
|
200
|
+
let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
|
|
201
|
+
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
202
|
+
panel.isFloatingPanel = true
|
|
203
|
+
panel.becomesKeyOnlyIfNeeded = true
|
|
204
|
+
panel.worksWhenModal = true
|
|
205
|
+
window = panel
|
|
198
206
|
super.init()
|
|
199
207
|
window.isOpaque = false
|
|
200
208
|
window.backgroundColor = .clear
|
|
@@ -544,6 +552,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
544
552
|
return item
|
|
545
553
|
}
|
|
546
554
|
|
|
555
|
+
// War mode on/off checkbox + 平時⇄危機 slider (level 0–1). Separate axis from
|
|
556
|
+
// tsundere; the tsundere level flavors it.
|
|
557
|
+
private func warToggleRow(on: Bool) -> NSMenuItem {
|
|
558
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
|
|
559
|
+
let btn = NSButton(checkboxWithTitle: "戦争モード", target: self, action: #selector(warToggled(_:)))
|
|
560
|
+
btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
|
|
561
|
+
btn.state = on ? .on : .off
|
|
562
|
+
row.addSubview(btn)
|
|
563
|
+
let item = NSMenuItem(); item.view = row
|
|
564
|
+
return item
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private func warRow(value: Double) -> NSMenuItem {
|
|
568
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
|
|
569
|
+
let left = NSTextField(labelWithString: "平時")
|
|
570
|
+
left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
|
|
571
|
+
left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
|
|
572
|
+
let slider = NSSlider(value: value, minValue: 0, maxValue: 1, target: self, action: #selector(warLevelChanged(_:)))
|
|
573
|
+
slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
|
|
574
|
+
slider.isContinuous = false
|
|
575
|
+
slider.trackFillColor = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1) // war red
|
|
576
|
+
let right = NSTextField(labelWithString: "危機")
|
|
577
|
+
right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
|
|
578
|
+
right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
|
|
579
|
+
row.addSubview(left); row.addSubview(slider); row.addSubview(right)
|
|
580
|
+
let item = NSMenuItem(); item.view = row
|
|
581
|
+
return item
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
|
|
585
|
+
@objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
|
|
586
|
+
|
|
547
587
|
// representedObject is the full CLI arg array to run.
|
|
548
588
|
@objc private func runItem(_ item: NSMenuItem) {
|
|
549
589
|
if let cmd = item.representedObject as? [String] { State.cli(cmd) }
|
|
@@ -575,6 +615,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
575
615
|
let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
|
|
576
616
|
menu.addItem(tsundereToggleRow(on: tsunOn))
|
|
577
617
|
menu.addItem(tsundereRow(value: tsunLevel))
|
|
618
|
+
|
|
619
|
+
// War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
|
|
620
|
+
let warJson = json?["war"] as? [String: Any]
|
|
621
|
+
menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
|
|
622
|
+
menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
|
|
578
623
|
menu.addItem(.separator())
|
|
579
624
|
|
|
580
625
|
// VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,84 @@ const cmds = {
|
|
|
446
451
|
if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
|
|
447
452
|
},
|
|
448
453
|
|
|
454
|
+
// War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
|
|
455
|
+
// max 危機的. Combined with the tsundere level for the operator's 好感度.
|
|
456
|
+
// war [on|off|toggle|level <0-1>|test|status]
|
|
457
|
+
war() {
|
|
458
|
+
const sub = positionals[0] || 'status';
|
|
459
|
+
const config = readConfig();
|
|
460
|
+
const ts = config.tsundere || {};
|
|
461
|
+
const url = config.voicevox?.url || voicevox.DEFAULT_URL;
|
|
462
|
+
|
|
463
|
+
if (sub === 'on' || sub === 'off' || sub === 'toggle') {
|
|
464
|
+
const enabled = sub === 'toggle' ? !isWarEnabled() : sub === 'on';
|
|
465
|
+
setWarEnabled(enabled);
|
|
466
|
+
// Cache the VOICEVOX tsun/dere style map so fire-time skips the lookup.
|
|
467
|
+
if (enabled && config.tts === 'voicevox') {
|
|
468
|
+
const sm = voicevox.resolveStyles(config.voicevox?.speaker, url);
|
|
469
|
+
if (sm) {
|
|
470
|
+
config.tsundere = { ...ts, styleMap: sm };
|
|
471
|
+
writeConfig(config);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
log(enabled ? '⚔️ 戦争モード ON(平時⇄戦闘⇄危機・ツンデレ好感度で口調変化)' : '戦争モード OFF');
|
|
475
|
+
if (enabled) {
|
|
476
|
+
log(' レベル: ai-notify war level <0=平時 〜 0.5=戦闘 〜 1=危機>');
|
|
477
|
+
log(' 試聴: ai-notify war test');
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (sub === 'level') {
|
|
482
|
+
const arg = positionals[1];
|
|
483
|
+
if (arg === undefined) return log(`war level: ${readWarLevel()} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
484
|
+
return log(`⚔️ war level → ${setWarLevel(arg)} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
485
|
+
}
|
|
486
|
+
if (sub === 'test') {
|
|
487
|
+
const lang = ts.lang || 'ja';
|
|
488
|
+
const level = readWarLevel();
|
|
489
|
+
const aff = readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5;
|
|
490
|
+
const sm = config.tts === 'voicevox' ? ts.styleMap || voicevox.resolveStyles(config.voicevox?.speaker, url) : null;
|
|
491
|
+
log(`war test (level ${level} = ${war.band(level)}, 好感度 ${aff}, lang ${lang}):\n`);
|
|
492
|
+
const rows =
|
|
493
|
+
lang === 'ja'
|
|
494
|
+
? [
|
|
495
|
+
{ tier: 'T3', body: 'ビルドが失敗' },
|
|
496
|
+
{ tier: 'T2', body: '許可待ち' },
|
|
497
|
+
{ tier: 'T1', body: '3ファイルを更新' },
|
|
498
|
+
{ tier: 'T0', body: 'テスト全部パス' },
|
|
499
|
+
]
|
|
500
|
+
: [
|
|
501
|
+
{ tier: 'T3', body: 'the build failed' },
|
|
502
|
+
{ tier: 'T2', body: 'waiting for approval' },
|
|
503
|
+
{ tier: 'T1', body: 'updated 3 files' },
|
|
504
|
+
{ tier: 'T0', body: 'all tests passed' },
|
|
505
|
+
];
|
|
506
|
+
for (const s of rows) {
|
|
507
|
+
const eff = tsundere.effectiveLevel(aff, s.tier, ts.urgencyShift !== false);
|
|
508
|
+
const text = war.wrap(s.body, level, eff, lang, 0);
|
|
509
|
+
const mul = war.volumeMul(level, s.tier);
|
|
510
|
+
const tone = tsundere.axisFor(eff);
|
|
511
|
+
log(` [${s.tier} ×${mul.toFixed(2)} ${tone}] ${text}`);
|
|
512
|
+
if (sm) {
|
|
513
|
+
const speaker = sm[tone] ?? config.voicevox?.speaker;
|
|
514
|
+
voicevox.speak(text, speaker, url, mul, undefined, war.effectiveProsody(level, tsundere.effectiveProsody(tone, readVoiceProsody())));
|
|
515
|
+
} else {
|
|
516
|
+
try {
|
|
517
|
+
execFileSync('say', config.voice ? ['-v', config.voice, tsundere.decorateForSay(text, tone)] : [tsundere.decorateForSay(text, tone)], { stdio: 'ignore' });
|
|
518
|
+
} catch {
|
|
519
|
+
/* non-mac / no say */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// status
|
|
526
|
+
log(`war mode: ${isWarEnabled() ? '⚔️ ON' : 'OFF'}`);
|
|
527
|
+
log(` level: ${readWarLevel()} → ${war.band(readWarLevel())} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
|
|
528
|
+
log(` 好感度 (tsundere level): ${readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5}`);
|
|
529
|
+
if (!isWarEnabled()) log('\nEnable: ai-notify war on 試聴: ai-notify war test');
|
|
530
|
+
},
|
|
531
|
+
|
|
449
532
|
// Assign a voice to a specific pane (by tty), from the menu bar.
|
|
450
533
|
// voice-pane <tty> voicevox <id> | say <name> | clear
|
|
451
534
|
'voice-pane'() {
|
|
@@ -786,6 +869,7 @@ const cmds = {
|
|
|
786
869
|
voices,
|
|
787
870
|
panes,
|
|
788
871
|
tsundere: { enabled: !!config.tsundere?.enabled, level: tsLevel },
|
|
872
|
+
war: { enabled: isWarEnabled(), level: readWarLevel() },
|
|
789
873
|
tts: config.tts || 'say',
|
|
790
874
|
prosody: readVoiceProsody(),
|
|
791
875
|
prosodyRange: VOICE_PROSODY_RANGE,
|
|
@@ -948,6 +1032,7 @@ Usage:
|
|
|
948
1032
|
ai-notify voice [number|name|preview|default] pick the spoken voice
|
|
949
1033
|
ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
950
1034
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
1035
|
+
ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
|
|
951
1036
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
952
1037
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
953
1038
|
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
|
+
};
|