ai-notify 0.8.0 → 0.9.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 +1 -0
- package/README.md +1 -0
- package/menubar/AiNotifyMenuBar.swift +220 -21
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +69 -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">
|
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">
|
|
@@ -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
|
|
@@ -544,7 +736,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
544
736
|
// the menu height never jumps.
|
|
545
737
|
private func tsundereToggleRow(on: Bool) -> NSMenuItem {
|
|
546
738
|
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
|
|
547
|
-
let btn = NSButton(checkboxWithTitle: "
|
|
739
|
+
let btn = NSButton(checkboxWithTitle: "ツンデレ", target: self, action: #selector(tsundereToggled(_:)))
|
|
548
740
|
btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
|
|
549
741
|
btn.state = on ? .on : .off
|
|
550
742
|
row.addSubview(btn)
|
|
@@ -556,7 +748,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
556
748
|
// tsundere; the tsundere level flavors it.
|
|
557
749
|
private func warToggleRow(on: Bool) -> NSMenuItem {
|
|
558
750
|
let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
|
|
559
|
-
let btn = NSButton(checkboxWithTitle: "
|
|
751
|
+
let btn = NSButton(checkboxWithTitle: "アドレナリン", target: self, action: #selector(warToggled(_:)))
|
|
560
752
|
btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
|
|
561
753
|
btn.state = on ? .on : .off
|
|
562
754
|
row.addSubview(btn)
|
|
@@ -564,25 +756,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
564
756
|
return item
|
|
565
757
|
}
|
|
566
758
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
759
|
+
// A labeled blue level slider (0–1), laid out like the 速さ/高さ/抑揚 rows so
|
|
760
|
+
// ツンデレ / アドレナリン sit with them, aligned and in the same blue.
|
|
761
|
+
private func levelRow(label: String, value: Double, action: Selector) -> NSMenuItem {
|
|
762
|
+
let row = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
|
|
763
|
+
let cap = NSTextField(labelWithString: label)
|
|
764
|
+
cap.frame = NSRect(x: 12, y: 4, width: 64, height: 16)
|
|
765
|
+
cap.font = .systemFont(ofSize: 11); cap.textColor = .secondaryLabelColor
|
|
766
|
+
let slider = NSSlider(frame: NSRect(x: 78, y: 3, width: 146, height: 20))
|
|
767
|
+
slider.cell = FilledSliderCell() // guaranteed blue fill
|
|
768
|
+
slider.minValue = 0; slider.maxValue = 1; slider.doubleValue = value
|
|
769
|
+
slider.target = self; slider.action = action
|
|
574
770
|
slider.isContinuous = false
|
|
575
|
-
|
|
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)
|
|
771
|
+
row.addSubview(cap); row.addSubview(slider)
|
|
580
772
|
let item = NSMenuItem(); item.view = row
|
|
581
773
|
return item
|
|
582
774
|
}
|
|
583
775
|
|
|
584
776
|
@objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
|
|
585
777
|
@objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
|
|
778
|
+
@objc private func tsundereLevelDirect(_ s: NSSlider) { State.cli(["tsundere", "level", String(format: "%.2f", s.doubleValue)]) }
|
|
586
779
|
|
|
587
780
|
// representedObject is the full CLI arg array to run.
|
|
588
781
|
@objc private func runItem(_ item: NSMenuItem) {
|
|
@@ -610,16 +803,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
610
803
|
// Tsundere mode: checkbox toggle + ツン⇄デレ baseline slider. Both live in
|
|
611
804
|
// view rows and are always mounted, so toggling never closes the menu nor
|
|
612
805
|
// shifts its height.
|
|
806
|
+
// Mode toggles (checkboxes only). Their level sliders live below, with the
|
|
807
|
+
// 速さ/高さ/抑揚 sliders, so all the blue adjustment sliders are grouped.
|
|
613
808
|
let tsun = json?["tsundere"] as? [String: Any]
|
|
614
|
-
let tsunOn = (tsun?["enabled"] as? Bool) ?? false
|
|
615
809
|
let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
|
|
616
|
-
menu.addItem(tsundereToggleRow(on: tsunOn))
|
|
617
|
-
menu.addItem(tsundereRow(value: tsunLevel))
|
|
618
|
-
|
|
619
|
-
// War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
|
|
620
810
|
let warJson = json?["war"] as? [String: Any]
|
|
811
|
+
let warLevel = (warJson?["level"] as? Double) ?? 0.5
|
|
812
|
+
menu.addItem(tsundereToggleRow(on: (tsun?["enabled"] as? Bool) ?? false))
|
|
621
813
|
menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
|
|
622
|
-
menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
|
|
623
814
|
menu.addItem(.separator())
|
|
624
815
|
|
|
625
816
|
// VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
|
|
@@ -643,8 +834,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
643
834
|
let v = (pr[key] as? Double) ?? dflt
|
|
644
835
|
menu.addItem(prosodyRow(label: label, value: v, lo: lo, hi: hi, key: key))
|
|
645
836
|
}
|
|
646
|
-
menu.addItem(.separator())
|
|
647
837
|
}
|
|
838
|
+
// ツンデレ (好感度) and アドレナリン (強度) levels, below 速さ/高さ/抑揚, in blue.
|
|
839
|
+
menu.addItem(levelRow(label: "ツンデレ", value: tsunLevel, action: #selector(tsundereLevelDirect(_:))))
|
|
840
|
+
menu.addItem(levelRow(label: "アドレナリン", value: warLevel, action: #selector(warLevelChanged(_:))))
|
|
841
|
+
menu.addItem(.separator())
|
|
648
842
|
|
|
649
843
|
if voices.isEmpty {
|
|
650
844
|
menu.addItem(disabledHeader("(声の一覧を取得できません)"))
|
|
@@ -762,6 +956,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
762
956
|
notifyParent.submenu = notifySub
|
|
763
957
|
menu.addItem(notifyParent)
|
|
764
958
|
|
|
959
|
+
menu.addItem(.separator())
|
|
960
|
+
let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
|
|
961
|
+
settingsItem.target = self
|
|
962
|
+
menu.addItem(settingsItem)
|
|
963
|
+
|
|
765
964
|
menu.addItem(.separator())
|
|
766
965
|
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
767
966
|
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.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": {
|
package/src/cli.mjs
CHANGED
|
@@ -451,6 +451,74 @@ const cmds = {
|
|
|
451
451
|
if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
|
|
452
452
|
},
|
|
453
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
|
+
|
|
454
522
|
// War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
|
|
455
523
|
// max 危機的. Combined with the tsundere level for the operator's 好感度.
|
|
456
524
|
// war [on|off|toggle|level <0-1>|test|status]
|
|
@@ -1033,6 +1101,7 @@ Usage:
|
|
|
1033
1101
|
ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
1034
1102
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
1035
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
|
|
1036
1105
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
1037
1106
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
1038
1107
|
ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
|