SLOPSHOPPER

voice-notify

Speaks Claude Code turn results aloud with VOICEVOX on Windows, macOS and Linux (CLI); long turns are summarized by Haiku inside the session (no extra API key…

newcommandmodelprocessnetwork
★ 2v0.3.1MITupdated 2026-10-08nextscape/ns-mods/mods/voice-notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · voice-notify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /voice-notify ⎿ voice-notify: config.json を読めません。/voice-notify doctor で確かめてください。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

voice-notify — Claude Code の音声通知

English summary: voice-notify speaks Claude Code events aloud using VOICEVOX (turn finished, waiting for permission, subagent reports, errors). Long turns are summarized into one spoken sentence by Claude Haiku inside the mod ($.model.complete): no separate API key, nothing added to your conversation, but the call does use your plan or API key, and the full reply text is sent to Haiku. Runs on Windows; macOS and Linux are experimental (covered by automated tests only). VOICEVOX and the Claude Code CLI are required. Install: /plugin install voice-notify --marketplace nextscape/ns-mods, open a new session, then run /voice-notify setup. Voices: VOICEVOX:四国めたん, VOICEVOX:ずんだもん.

Claude Code のターン完了・許可待ち・サブエージェントの報告・エラーなどを、VOICEVOX の声で知らせます。 作業時間の長いターンは、応答を Claude Haiku で1文に要約して読み上げます。

  • 要約は Claude Code の中で動く mod が行います。別の API キーは要りません。要約のやり取りは会話に入りません。
  • 要約は利用者のプランまたは API キーを使い、応答の本文を Haiku に送ります。詳しくはデータと費用を見てください。
  • 鳴らす・黙るの判断(短いターンは完了だけ告げる、マイク使用中は黙る、など)は、すべてローカルで行います。

音声:VOICEVOX:四国めたん、VOICEVOX:ずんだもん(クレジット表記)

前提

項目内容
OSWindows 10 / 11、macOS、Linux。macOS・Linux は試験的な対応です(自動テストのみで、実機では確かめていません)
音声合成VOICEVOX。同梱の ENGINE を使います。GUI は起動しません
Claude CodeCLI(ターミナル)だけ。2.1.292 で動作を確認。デスクトップアプリの Code タブや VS Code 拡張では鳴りません(mod から外部コマンドを起動できるのが CLI だけのため)
再生Windows は標準の機能(SoundPlayer)。macOS は afplay。Linux は pw-play・paplay・aplay のどれか
curl合成に使います。Windows 10 以降と macOS には標準で入っています
権限管理者権限は不要です。すべてユーザー領域に収まります

導入

  1. プラグインを入れて、新しいセッションを開く
   /plugin install voice-notify --marketplace nextscape/ns-mods
  1. VOICEVOX を入れる
OS方法
Windowswinget install --id HiroshibaKazuyuki.VOICEVOX.CPU -e
macOS公式サイトの dmg を入れ、アプリケーションフォルダに置く
Linux公式サイトの tar.gz を展開する。3 の /voice-notify setup を一度実行すると ~/.claude/voice-notify/config.json ができるので、その enginePath に vv-engine/run の絶対パスを書き、もう一度 /voice-notify setup を実行する

ENGINE がすでに 127.0.0.1:50021 で応答していれば、それを使います(Docker の voicevox/voicevox_engine など)。

  1. 初期設定を行う
   /voice-notify setup
#内容
1ホーム(~/.claude/voice-notify/)の用意。config.json が無ければ既定をコピー。0.2.0 の残りを消す
2VOICEVOX の確認。見つからなければ、OS ごとの入れ方を案内して止まります
3VOICEVOX ENGINE の起動
4定型フレーズの生成(裏で実行。初回は数分。進み具合は /voice-notify doctor で確認)
5ログオン時に ENGINE を起動する登録(Windows はタスク、macOS は launchd、Linux は systemd のユーザーサービス。ENGINE の場所が分からない(Docker など)ときは登録しない)。ミュート切替のホットキーの登録(Windows だけ。既定 Ctrl+Alt+M)

音声通知は、setup を実行したセッションからすぐ有効です。

0.2.0 から移るとき

  1. /plugin update voice-notify で 0.3.0 にし、新しいセッションを開く。
  2. /voice-notify setup を実行する(0.2.0 の状態ファイルを消し、Windows のタスクとホットキーを新しいスクリプトに向け直す)。
  3. /voice-notify doctor で確かめる。定型フレーズの作り直しを案内されたら /voice-notify setup force(0.2.0 のフレーズには、鳴らし始めが欠けないための無音が入っていないため)。

コマンドの名前が変わりました(mod のコマンド名に : を使えないため)。

0.2.00.3.0
/voice-notify:voice・on・off・status/voice-notify・/voice-notify on・off・status
/voice-notify:setup/voice-notify setup
/voice-notify:setup force/voice-notify setup force
/voice-notify:setup doctor/voice-notify doctor
/voice-notify:setup remove/voice-notify remove

使い方

コマンド動作
/voice-notifyミュートの切替
/voice-notify on / off / status再開 / 停止 / 状態表示
Ctrl+Alt+M(Windows だけ。どのウィンドウからでも)ミュートの切替
/voice-notify doctor診断(鳴らないときの切り分け)。何も変えません
/voice-notify setup force定型フレーズを作り直す(話者・話速・文言・読み替え・先頭の無音を変えたとき)
/voice-notify removeログオン時の起動とホットキーの撤去(ホームは残す)

/voice-notify に知らない引数を付けたときは、何もせずに使い方を表示します。

マイクを使っているアプリがあると、自動で黙ります(Windows だけ。mute.whenMicInUse)。

何を読むか

イベント読み上げ
ターン完了定型フレーズ(完了・確認待ち・失敗を判定して選ぶ)+応答の要約。作業が30秒以下のターンは定型フレーズだけ
サブエージェント完了「〈説明〉が完了しました。〈報告の要約〉」を別の声で
許可待ち「〈ツール名〉の許可待ちです。」(ツール名は notification.toolLabels で言い換え。Bash は「コマンド実行」)
入力待ち・API エラー・タスク完了・利用枠の自動再開など定型フレーズ
  • サブエージェントが動いている間の途中経過は、最終報告と声を分け、短く読みます。8秒以内に続いた途中経過は読みません(subagent.debounceSeconds)。
  • 要約が使えないとき(無効、失敗、時間切れ)は、応答の本文から1文を選んで読みます。コードブロック・表・URL は読みません。
  • ENGINE が応答しないときは、黙らずに短い定型フレーズだけを鳴らします。
  • 入力待ちは、サブエージェントが動いている間と、メインが作業中の間は読みません。
  • /compact の最中のターンと、利用者が中断したターンは読みません。

設定

設定は ~/.claude/voice-notify/config.json を直接編集します。環境変数 VOICE_NOTIFY_HOME に絶対パスを指定すると、置き場所を変えられます。 プラグインを更新・アンインストールしても、このフォルダは消えません。 JSON を書き間違えると通知が止まります。/voice-notify doctor が「config.json を読めない」と知らせます。 config.json に書いていないキーは、同梱の既定値(config.default.json)で動きます。オブジェクトはキーごとに重なり、配列と値は config.json のものが使われます。そのため、phrases の区分(stop.brief など)を消しても既定の文言に戻ります。黙らせたい区分は、空の配列([])にしてください。

変えたいものキー
話者・話速・抑揚speaker / speakerInterim / speedScale / pitchScale / intonationScale(変えたら /voice-notify setup force でフレーズを作り直す)
使える話者speakers(名前ごとに VOICEVOX の話者 id、表示名 label、クレジット credit)
定型フレーズの文言phrases(変えたら /voice-notify setup force)
要約をやめるspeech.summarize を false
要約するイベントspeech.summarizeEvents(既定は stop と agentstop)
要約の文体speech.summaryPrompt / speech.interimPrompt(行の配列。良い例・悪い例を並べると効く。{maxChars} は上限の文字数に置き換わる)
要約の長さ・待ち時間speech.summaryMaxChars / speech.interimMaxChars / speech.summaryTimeoutSec
要約しない短い応答speech.summaryMinChars(既定80文字。これより短い応答はそのまま読む)
本文を読まない短いターンspeech.briefMaxSeconds(既定30秒)
英単語の読みspeech.readings(辞書)・speech.lowercaseMinLength・speech.keepUppercase(変えたら /voice-notify setup force)
マイク使用中に黙るmute.whenMicInUse(Windows だけ)
サブエージェントの途中経過の間隔subagent.debounceSeconds(既定8秒)
許可待ちで読むツール名notification.toolLabels
鳴らし始めの無音playback.leadSilenceMs(既定600。出力先がディスプレイの音声で頭が欠けるときに増やす。変えたら /voice-notify setup force)
ホットキーhotKey(Windows だけ。既定 CTRL+ALT+M。変えたら /voice-notify setup をもう一度実行する)
VOICEVOX の場所enginePath(自動で見つからないときに ENGINE の vv-engine/run(Windows は run.exe)の絶対パスを書く)
ENGINE のポートenginePort(既定 50021)
合成した音声のキャッシュ件数cacheMaxFiles(既定200)

データと費用

項目内容
要約を作るときメインのターンは、作業が30秒を超え、応答が80文字以上のとき。サブエージェントの報告は、30文字以上のとき。手動ミュート中と speech.summarize が false のときは作りません
Haiku に送るもの応答の本文の全文と、要約の指示文(speech.summaryPrompt)。会話履歴やファイルは送りません
費用mod のモデル呼び出しは、利用者のプランまたは API キーを使います(公式ドキュメント)。1回あたり出力は最大300トークンです
外に出ないもの音声合成は手元の VOICEVOX ENGINE(127.0.0.1:50021)で行い、ネットワークには出ません。マイク使用中の判定もローカルで行います
手元に残るものnotify.log に、読み上げた文と要約が残ります(約200KB を超えると古い行を捨てます。mod には追記の手段が無く読んで書き直すので、別のセッションとほぼ同時に書くと、まれに1行欠けます)。cache/ に合成した音声が最大200件、state/ にミュートや直前のフレーズなどの小さな記録が残ります
消し方~/.claude/voice-notify/ の notify.log、cache/、state/ は消してかまいません。次の通知で作り直します(state/ を消すと手動ミュートも解除されます)

仕組み

Claude Code(CLI)
 └─ mod(hooks/register.ts)
     ├─ turn.complete(メイン)        → 判定 → 定型フレーズを再生 → 要約(Haiku)か本文の1文 → VOICEVOX で合成 → 再生
     ├─ turn.complete(サブエージェント) → 「〈説明〉が完了しました。〈報告の要約〉」
     ├─ classic.PermissionRequest / Notification / TaskCompleted → 許可待ち・入力待ち・タスク完了など
     └─ /voice-notify                   → ミュート・導入・診断・撤去
  • 何を読むかの判定は純粋関数(hooks/decide.ts・text.ts)で、I/O は hooks/register.ts だけが行います。hook は読み上げを待たずに返すので、ターンは遅れません。
  • 要約は会話に入りません。$.model.complete は、履歴もツールも持たない単発の呼び出しです。定型フレーズを鳴らしている間に、要約と合成を進めます。
  • 合成は、audio_query を HTTP で受け取り、synthesis は curl で WAV をファイルに落とします(mod の HTTP は文字列しか受け取れないため)。合成した音声は cache/ に残し、同じ文はもう一度合成しません。
  • 再生は OS ごとの小さなスクリプト(scripts/windows/play.ps1・scripts/posix/play.sh)が行い、別のセッションの音と重ならないよう1つずつ鳴らします。
  • マイク使用中の判定は、Windows のプライバシー設定の記録(レジストリの CapabilityAccessManager\ConsentStore\microphone)で行います。会議アプリに限らず、マイクを使っているアプリがあれば黙ります。
  • ホットキーは、スタートメニューに置くショートカット(voice-notify ミュート切替.lnk)のショートカットキーです。常駐プロセスはありません。押すたびに PowerShell が起動するので、切り替わるまで1秒ほどかかります。ほかのアプリと同じキーだと、どちらかが効きません。
  • Windows のタスクとホットキーは、setup がホームの bin/ にコピーしたスクリプトを呼びます。プラグインを更新して場所が変わっても動きます。doctor は、bin/ のスクリプトが今の版と違えば、setup のやり直しを案内します。

ファイルの置き場所

場所中身更新・撤去で
プラグインhooks/、scripts/、config.default.json置き換わる
ホーム(~/.claude/voice-notify/)config.json、phrases/、cache/、state/、notify.log、bin/残る
Windows:スタートメニューvoice-notify ミュート切替.lnk(ホットキー)remove で消える
Windows:タスクスケジューラVOICEVOX ENGINE (voice-notify)remove で消える
macOS~/Library/LaunchAgents/jp.nextscape.voice-notify.engine.plistremove で消える
Linux~/.config/systemd/user/voice-notify-engine.serviceremove で消える

撤去

/voice-notify remove
/plugin uninstall voice-notify
  • remove は、ログオン時の起動とホットキーを外します。起動中の VOICEVOX ENGINE は止めません(次のログオンからは起動しません)。Windows ではタスクマネージャーで run.exe を終了してください。
  • 設定・フレーズ・ログも消すときは、~/.claude/voice-notify/ を削除してください。
  • VOICEVOX 本体は残ります。Windows で不要なら winget uninstall --id HiroshibaKazuyuki.VOICEVOX.CPU -e で外します。

鳴らないとき

  1. /voice-notify doctor を実行し、NG・注意の行に従う。
  2. ~/.claude/voice-notify/notify.log を見る。何が起きたか、なぜ黙ったか(「マイク使用中 (アプリ名)」など)、何を読んだかが残ります。
  3. デスクトップアプリや VS Code で使っている場合は鳴りません(CLI だけの対応です)。
  4. Windows で音が出ない場合は、既定の出力先(タスクバーのスピーカーアイコン)が聞いている機器になっているかを確かめる。
  5. Linux で「再生コマンドが見つからない」と出る場合は、pw-play(PipeWire)・paplay(PulseAudio)・aplay(ALSA)のどれかを入れる。

開発

claude plugin test     mods/voice-notify          # tests/*.test.ts
claude plugin validate mods/voice-notify          # ロード時の検査
npx -y -p typescript tsc -p mods/voice-notify --noEmit   # 型(tsconfig.json は mod を読み込むとエンジンが置く)
  • mod の $ は、hooks モジュール(hooks/register.ts)の中で宣言した関数にしか渡せません(ロード時の検査の規則)。ほかのファイルは純粋関数だけにして、直接テストします。$ を使う振る舞いは、tests/world.ts の偽環境でイベントやコマンドを起こしてテストします。
  • .ps1 は UTF-8(BOM 付き) で保存してください。BOM が無いと、Windows PowerShell 5.1 が誤読します。
  • 環境変数 VOICE_NOTIFY_SUPPRESS=1 で通知をすべて止められます。

変更履歴は CHANGELOG.md を見てください。

クレジット表記

本プラグインが生成する音声は VOICEVOX を使用しています。 生成した音声を含む成果物を公開・配布する場合は、使用した話者に応じて以下のクレジット表記が必要です。

話者クレジット表記
ずんだもんVOICEVOX:ずんだもん
四国めたんVOICEVOX:四国めたん

/voice-notify status と /voice-notify doctor も、使っている話者のクレジット(speakers.*.credit)を表示します。話者を足すときは credit も書いてください(無いと VOICEVOX とだけ表示します)。

生成した音声を動画などに使って公開する場合は、その説明欄などに上記のクレジットを書いてください。

ライセンス

MIT(リポジトリの LICENSE)

Source 11 files
hooks/register.ts 917 lines
1import type { EngineInterface, Register, TurnCompleteInput } from 'claude-code'
2
3import {
4  agentReportFallback,
5  agentText,
6  choosePhrase,
7  enginePort,
8  mergeConfig,
9  noticeKind,
10  parseConfig,
11  permissionText,
12  pickFor,
13  planStop,
14  planSummary,
15  voiceFor,
16  voiceHome,
17} from './decide'
18import type { Role, VoiceConfig } from './decide'
19import { Gate, capture } from './gate'
20import { MIC_QUERY, micInUseFrom, osFrom, removeArgv } from './os'
21import type { Os } from './os'
22import { jobsStamp, phraseBusy, phraseJobs } from './phrases'
23import type { PhraseState } from './phrases'
24import { LINUX_PLAYERS, playArgv, probeArgv } from './player'
25import { LAUNCHD_LABEL, SYSTEMD_UNIT, fillTemplate, fromScript, installScriptArgv, plistPath, systemdValue, unitPath, windowsInstallArgs, xml } from './autostart'
26import { HINT, INSTALL_HINT, LEGACY_BIN, LEGACY_STATE_DIRS, creditLine, doctorReport, ng, ok, parseCommand, step, unknownArg, voiceStatus, warn } from './setup'
27import type { SetupAction, VoiceAction } from './setup'
28import { appendLog, logLine, phraseMemo } from './store'
29import type { Level } from './store'
30import { cleanSummary, clearSpeech, convertReading } from './text'
31import {
32  audioQueryUrl,
33  cachePath,
34  cacheToDrop,
35  engineCandidates,
36  parseVersion,
37  startArgv,
38  synthArgv,
39  synthParams,
40  tuneQuery,
41  versionUrl,
42} from './voicevox'
43import type { FoundEngine, SynthParams } from './voicevox'
44
45// voice-notify の hooks モジュール。Claude Code の出来事を VOICEVOX の声で知らせる。
46//
47// - $ を使う処理(ファイル・プロセス・HTTP・モデル)はすべてこのファイルに置く。mod のロード時検査は、
48//   $ を import した先の関数に渡すことを許さない。判定と形の組み立ては純粋関数のファイル(decide / text / os / player / store / voicevox)
49// - 読み上げは待たずに裏で流し、どの hook も next(e) をすぐ返す(ターンを遅らせない)
50// - 何が起きたかは <ホーム>/notify.log に理由付きで残す(0.2.0 と同じ書式)
51
52type Ctx = { root: string; cfg: VoiceConfig; os: Os }
53type Synth = { path: string; cached: boolean } | { error: string }
54
55// 作業中のターン(idle の抑止に使う)と、本体の会話の /compact の最中かどうか
56const working = new Set<string>()
57let compacting = 0
58
59let knownOs: Os | null = null
60let linuxPlayer: string | null | undefined
61let warnedConfig = false
62let warnedNoPlayer = false
63let defaults: { text: string; cfg: VoiceConfig } | null = null
64// このセッションがフレーズを生成している最中か
65let generating = false
66
67// notify.log は読んで書き直すしかない($.fs に追記が無い)。同時に来た行はまとめて1回で書く
68const pendingLog: Array<{ root: string; line: string }> = []
69const logGate = new Gate()
70// 同じセッションの再生は1つずつ(セッションをまたぐ排他は再生スクリプトの中で取る)
71const playGate = new Gate()
72// サブエージェントの報告が同時に来たとき、連発抑制の「見て、記録する」をすり抜けないよう1つずつ通す
73const debounceGate = new Gate()
74
75// 出来事を止めうる hook(gating)は、例外を出しても出来事をそのまま通す(.catch)
76export const register: Register = on => {
77  on('session.start', async ($, e, next) => {
78    const r = await next(e)
79    await $.command.register({
80      name: 'voice-notify',
81      description: '音声通知のミュート切替(on / off / status)と、導入・診断・撤去(setup [force] / doctor / remove)',
82      argumentHint: '[on|off|status|setup|doctor|remove]',
83    })
84    // 前回の生成が途中で終わっていたら続きを作る
85    void background($, 'phrases', resumePhrases($))
86    return r
87  })
88
89  on('command.run', { command: 'voice-notify' }, async ($, e) => ({ text: await runCommand($, e.args) }))
90
91  on('session.compact', async ($, e, next) => {
92    // 先読み(precompute)は会話を変えないので数えない。サブエージェントの圧縮も本体のターンとは別
93    const counts = e.trigger !== 'precompute' && e.agentId === undefined
94    if (counts) compacting++
95    try {
96      return await next(e)
97    } finally {
98      if (counts) compacting--
99    }
100  }).catch(($, e, next) => next(e))
101
102  on('turn.start', async ($, e, next) => {
103    working.add(e.turnId)
104    return next(e)
105  })
106
107  on('turn.complete', async ($, e, next) => {
108    working.delete(e.turnId)
109    if (compacting === 0 && !e.isAborted) {
110      void background($, e.agentId ? 'agentstop' : 'stop', e.agentId ? onAgentTurn($, e) : onMainTurn($, e))
111    }
112    return next(e)
113  })
114
115  on('classic.PermissionRequest', async ($, e, next) => {
116    void background($, 'permission', onPermission($, e.tool_name))
117    return next(e)
118  }).catch(($, e, next) => next(e))
119
120  on('classic.Notification', async ($, e, next) => {
121    void background($, 'notification', onNotice($, e.notification_type, working.size > 0))
122    return next(e)
123  }).catch(($, e, next) => next(e))
124
125  on('classic.TaskCompleted', async ($, e, next) => {
126    void background($, 'task', onTask($))
127    return next(e)
128  }).catch(($, e, next) => next(e))
129}
130
131// 裏で流す処理(hook は待たない)。失敗はホームの notify.log に ERR で残す。
132// それもできない(モジュールが外れた後など)ときはデバッグログに、それもできなければあきらめる
133async function background($: EngineInterface, event: string, work: Promise<unknown>): Promise<void> {
134  try {
135    await work
136  } catch (err) {
137    await logFailure($, event, err)
138  }
139}
140
141async function logFailure($: EngineInterface, event: string, err: unknown): Promise<void> {
142  try {
143    const root = await homeRoot($)
144    if (root) {
145      await writeLog($, root, 'ERR', event, String(err))
146      return
147    }
148  } catch {
149    // ホームが分からない
150  }
151  try {
152    $.ui.log(`voice-notify: ${event}: ${String(err)}`, { to: 'debug' })
153  } catch {
154    // 知らせる先が無い
155  }
156}
157
158type RunInit = Parameters<EngineInterface['process']['run']>[1]
159type RunResult = { exitCode: number; stdout: string; stderr: string }
160
161// 外部コマンド。$.process.run はコマンドを起動できない(入っていない)と reject するので、
162// 終了コードの失敗(-1)と同じに扱う。doctor が「見つからない」と言えるように、例外で止めない
163async function run($: EngineInterface, argv: readonly string[], init?: RunInit): Promise<RunResult> {
164  try {
165    return await $.process.run(argv, init)
166  } catch (err) {
167    return { exitCode: -1, stdout: '', stderr: String(err) }
168  }
169}
170
171// ================================================================ 前提(ホーム・設定・OS)
172
173async function detectOs($: EngineInterface): Promise<Os> {
174  if (knownOs) return knownOs
175  const env = await $.env.get('OS')
176  let uname = ''
177  if (env !== 'Windows_NT') {
178    try {
179      uname = (await run($, ['uname', '-s'])).stdout
180    } catch {
181      uname = '' // uname が無い環境は Linux とみなす
182    }
183  }
184  knownOs = osFrom(env, uname)
185  return knownOs
186}
187
188async function homeRoot($: EngineInterface): Promise<string | null> {
189  return voiceHome(await $.env.get('VOICE_NOTIFY_HOME'), await $.env.get('USERPROFILE'), await $.env.get('HOME'))
190}
191
192// 同梱の既定の設定。モジュールの中で1回だけ読む(プラグインの更新で読み込み直されるので、古くならない)
193async function shippedDefaults($: EngineInterface): Promise<{ text: string; cfg: VoiceConfig }> {
194  if (!defaults) {
195    const text = await $.fs.read(`${$.plugin.root}/config.default.json`)
196    defaults = { text, cfg: parseConfig(text) }
197  }
198  return defaults
199}
200
201// まず読む。無いときだけ同梱の既定をコピーする(あるのに読めないときは上書きしない)。
202// 読めたら既定に重ねる(利用者の config.json に無いキーは既定値で動く)
203async function loadConfig($: EngineInterface, root: string): Promise<{ cfg: VoiceConfig } | { error: string }> {
204  const path = `${root}/config.json`
205  try {
206    const base = await shippedDefaults($)
207    let text: string
208    try {
209      text = await $.fs.read(path)
210    } catch (err) {
211      if (await $.fs.exists(path)) return { error: String(err) }
212      await $.fs.write(path, base.text)
213      text = base.text
214    }
215    return { cfg: mergeConfig(base.cfg, parseConfig(text)) as VoiceConfig }
216  } catch (err) {
217    return { error: String(err) }
218  }
219}
220
221// イベントごとの前提。設定が壊れていれば、一度だけログに残して null
222async function context($: EngineInterface): Promise<Ctx | null> {
223  const root = await homeRoot($)
224  if (!root) return null
225  const loaded = await loadConfig($, root)
226  if ('error' in loaded) {
227    if (!warnedConfig) {
228      warnedConfig = true
229      await writeLog($, root, 'ERR', 'config', `config.json を読めないので鳴らさない: ${loaded.error}`)
230    }
231    return null
232  }
233  warnedConfig = false
234  return { root, cfg: loaded.cfg, os: await detectOs($) }
235}
236
237// ================================================================ ホームのファイル
238
239// 消せたか(消すものが無ければ true)
240async function removeFiles($: EngineInterface, os: Os, paths: readonly string[]): Promise<boolean> {
241  const cmd = removeArgv(os, $.plugin.root, paths)
242  return !cmd || (await run($, cmd.argv, cmd.stdin ? { stdin: cmd.stdin } : undefined)).exitCode === 0
243}
244
245// 書けなくても例外は出さない(ログのために読み上げを止めない)
246async function writeLog($: EngineInterface, root: string, level: Level, event: string, msg: string): Promise<void> {
247  try {
248    pendingLog.push({ root, line: logLine(new Date(await $.clock.now()), level, event, msg) })
249  } catch {
250    return
251  }
252  const release = await logGate.enter()
253  try {
254    // 先に通った呼び出しが、この行もまとめて書いていれば何もしない
255    const batch = pendingLog.splice(0)
256    for (const r of new Set(batch.map(b => b.root))) {
257      const path = `${r}/notify.log`
258      let old = ''
259      try {
260        old = await $.fs.read(path)
261      } catch {
262        old = ''
263      }
264      await $.fs.write(path, appendLog(old, batch.filter(b => b.root === r).map(b => b.line)))
265    }
266  } catch (err) {
267    try {
268      $.ui.log(`voice-notify: notify.log に書けない (${String(err)})`, { to: 'debug' })
269    } catch {
270      // モジュールが外れた後は、知らせる先も無い
271    }
272  } finally {
273    release()
274  }
275}
276
277async function isMuted($: EngineInterface, root: string): Promise<boolean> {
278  return $.fs.exists(`${root}/state/mute`)
279}
280
281// name は phrases/<speaker>/ の下のフォルダ('stop/brief/done' など)。直前と同じものは避ける
282async function pickPhrase($: EngineInterface, root: string, speaker: string, name: string): Promise<string | null> {
283  const dir = `${root}/phrases/${speaker}/${name}`
284  let files: string[]
285  try {
286    files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.wav')).map(f => f.name)
287  } catch {
288    return null
289  }
290  const memo = phraseMemo(root, name)
291  let prev: string | null = null
292  try {
293    prev = (await $.fs.read(memo)).replace(/^/, '').trim()
294  } catch {
295    prev = null
296  }
297  const pick = choosePhrase(files, prev, Math.random())
298  if (!pick) return null
299  try {
300    await $.fs.write(memo, pick)
301  } catch {
302    // 覚えられなくても鳴らす
303  }
304  return `${dir}/${pick}`
305}
306
307// ================================================================ ENGINE
308
309async function engineVersion($: EngineInterface, port: number): Promise<string | null> {
310  try {
311    const r = await $.http.fetch(versionUrl(port))
312    return r.ok ? parseVersion(r.text) : null
313  } catch {
314    return null
315  }
316}
317
318// out を渡さなければキャッシュ。あればそれを返す
319async function synthesize($: EngineInterface, p: SynthParams, text: string, out?: string): Promise<Synth> {
320  const path = out ?? cachePath(p, text)
321  if (!out && (await $.fs.exists(path))) return { path, cached: true }
322  let query: Record<string, unknown>
323  try {
324    const r = await $.http.fetch(audioQueryUrl(p, text), { method: 'POST' })
325    if (!r.ok) return { error: `audio_query ${r.status}` }
326    query = JSON.parse(r.text) as Record<string, unknown>
327  } catch (err) {
328    return { error: `audio_query: ${String(err)}` }
329  }
330  // 書き込み先のフォルダを先に作る($.fs.write は途中のフォルダも作る)
331  const dir = path.slice(0, path.lastIndexOf('/'))
332  if (!(await $.fs.exists(dir))) await $.fs.write(`${dir}/.keep`, '')
333  const r = await run($, synthArgv(p, path), { stdin: JSON.stringify(tuneQuery(query, p)), timeoutMs: 130_000 })
334  if (r.exitCode !== 0) return { error: `synthesis: curl ${r.exitCode} ${r.stderr.trim()}` }
335  return { path, cached: false }
336}
337
338async function pruneCache($: EngineInterface, ctx: Ctx): Promise<void> {
339  try {
340    await removeFiles($, ctx.os, cacheToDrop(await $.fs.list(`${ctx.root}/cache`), ctx.root, ctx.cfg.cacheMaxFiles ?? 200))
341  } catch {
342    // キャッシュがまだ無い
343  }
344}
345
346// ================================================================ 再生
347
348// 見つかったものだけを覚える。「無い」は覚えない(あとから入れたら、セッションを開き直さずに鳴る。
349// 探し直しが走るのは鳴らせない環境だけなので、無駄は小さい)
350async function findLinuxPlayer($: EngineInterface): Promise<string | null> {
351  if (linuxPlayer) return linuxPlayer
352  for (const name of LINUX_PLAYERS) {
353    const r = await run($, probeArgv(name))
354    if (r.exitCode === 0 && r.stdout.trim()) return (linuxPlayer = name)
355  }
356  return null
357}
358
359const fileName = (p: string) => p.slice(p.replace(/\\/g, '/').lastIndexOf('/') + 1)
360
361async function play($: EngineInterface, ctx: Ctx, wav: string): Promise<boolean> {
362  const argv = playArgv(ctx.os, $.plugin.root, ctx.root, ctx.os === 'linux' ? await findLinuxPlayer($) : null, wav)
363  if (!argv) {
364    if (!warnedNoPlayer) {
365      warnedNoPlayer = true
366      await writeLog($, ctx.root, 'ERR', 'play', `再生コマンドが見つからない(${LINUX_PLAYERS.join(' / ')} のどれかを入れる)`)
367    }
368    return false
369  }
370  const release = await playGate.enter()
371  try {
372    const r = await run($, argv, { timeoutMs: 120_000 })
373    if (r.exitCode === 0) return true
374    await writeLog($, ctx.root, 'ERR', 'play', `再生失敗 ${fileName(wav)}: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
375    return false
376  } catch (err) {
377    await writeLog($, ctx.root, 'ERR', 'play', `再生失敗 ${fileName(wav)}: ${String(err)}`)
378    return false
379  } finally {
380    release()
381  }
382}
383
384// ================================================================ 読み上げの部品
385
386async function silence($: EngineInterface, ctx: Ctx): Promise<string | null> {
387  if ((await $.env.get('VOICE_NOTIFY_SUPPRESS')) === '1') return 'VOICE_NOTIFY_SUPPRESS'
388  if (await isMuted($, ctx.root)) return '手動ミュート'
389  if (ctx.os === 'windows' && ctx.cfg.mute?.whenMicInUse) {
390    const r = await run($, [...MIC_QUERY])
391    const app = r.exitCode === 0 ? micInUseFrom(r.stdout) : null
392    if (app) return `マイク使用中 (${app})`
393  }
394  return null
395}
396
397// 鳴らすかどうかの前置き。黙るなら理由をログに残して null
398async function begin($: EngineInterface, event: string): Promise<Ctx | null> {
399  const ctx = await context($)
400  if (!ctx) return null
401  await writeLog($, ctx.root, 'INFO', event, '発火')
402  const why = await silence($, ctx)
403  if (!why) return ctx
404  await writeLog($, ctx.root, 'INFO', event, `無音化: ${why}`)
405  return null
406}
407
408// 定型フレーズ。names を順に試し、最初に見つかったフォルダから選ぶ
409async function speakPhrase($: EngineInterface, ctx: Ctx, role: Role, names: readonly string[], event: string): Promise<boolean> {
410  const voice = voiceFor(ctx.cfg, role)
411  for (const name of names) {
412    const wav = await pickPhrase($, ctx.root, voice.name, name)
413    if (wav) return play($, ctx, wav)
414  }
415  await writeLog($, ctx.root, 'WARN', event, `定型フレーズが無い (${names.join(' / ')})。${HINT.setup} を実行のこと`)
416  return false
417}
418
419async function synthFor($: EngineInterface, ctx: Ctx, role: Role, text: string): Promise<Synth> {
420  return synthesize($, synthParams(ctx.cfg, voiceFor(ctx.cfg, role), ctx.root, ctx.os), convertReading(text, ctx.cfg.speech ?? {}))
421}
422
423// 合成の失敗は、ENGINE の不通かそれ以外かを分けて残す
424async function playSynth($: EngineInterface, ctx: Ctx, event: string, r: Synth): Promise<void> {
425  if ('error' in r) {
426    const port = enginePort(ctx.cfg)
427    const alive = await engineVersion($, port)
428    await writeLog($, ctx.root, 'ERR', event, alive ? `合成失敗: ${r.error}` : `VOICEVOX ENGINE に接続できない (port ${port})。定型フレーズのみ再生: ${r.error}`)
429    return
430  }
431  await play($, ctx, r.path)
432  // 件数が増えるのは新しく作ったときだけ
433  if (!r.cached) await pruneCache($, ctx)
434}
435
436// 要約(Haiku)。使えなければ null。待つのは summaryTimeoutSec まで
437async function summarize($: EngineInterface, ctx: Ctx, event: 'stop' | 'agentstop', role: Role, answer: string): Promise<string | null> {
438  const sp = ctx.cfg.speech ?? {}
439  const plan = planSummary(sp, event, role, answer)
440  if (plan.skip !== null) return null
441  const started = await $.clock.now()
442  const limit = (sp.summaryTimeoutSec ?? 15) * 1000
443  const r = await $.model.complete({
444    model: sp.summaryModel || 'haiku',
445    system: plan.system,
446    prompt: answer,
447    effort: 'low',
448    maxTokens: 300,
449    timeoutMs: limit,
450  })
451  const ms = (await $.clock.now()) - started
452  if (!r.isAnswered) {
453    const why = r.reason === 'aborted' ? `${limit / 1000}秒でタイムアウト` : r.reason
454    await writeLog($, ctx.root, 'WARN', event, `要約失敗、1文目を読む: ${why}`)
455    return null
456  }
457  const c = cleanSummary(r.text, plan.maxChars)
458  if ('error' in c) {
459    await writeLog($, ctx.root, 'WARN', event, `要約失敗、1文目を読む: ${c.error}`)
460    return null
461  }
462  const text = clearSpeech(c.text)
463  await writeLog($, ctx.root, 'INFO', event, `要約 ${(ms / 1000).toFixed(1)}秒: ${text}`)
464  return text
465}
466
467// 要約(無ければ抜き出し)→ 合成。定型フレーズを鳴らしている間に進める
468async function prepareBody($: EngineInterface, ctx: Ctx, role: Role, answer: string): Promise<{ text: string; r: Synth } | null> {
469  const text = (await summarize($, ctx, 'stop', role, answer)) ?? pickFor(ctx.cfg, answer)
470  if (!text) return null
471  return { text, r: await synthFor($, ctx, role, text) }
472}
473
474async function runningAgents($: EngineInterface): Promise<number> {
475  try {
476    return (await $.agent.list()).filter(a => a.status === 'running' || a.status === 'pending').length
477  } catch {
478    return 0
479  }
480}
481
482// ================================================================ イベントごとの流れ(失敗は background がログに残す)
483
484async function onMainTurn($: EngineInterface, e: TurnCompleteInput): Promise<void> {
485  // API エラーで終わったターンと、応答を拒否されたターンは、どちらも「うまくいかなかった」と知らせる
486  const failed = e.reason === 'error' || e.reason === 'refusal'
487  const event = failed ? 'failure' : 'stop'
488  const ctx = await begin($, event)
489  if (!ctx) return
490  if (failed) {
491    await speakPhrase($, ctx, 'final', ['failure'], event)
492    return
493  }
494  const running = await runningAgents($)
495  const alive = (await engineVersion($, enginePort(ctx.cfg))) !== null
496  const plan = planStop({ cfg: ctx.cfg, answer: e.answer, durationMs: e.durationMs, running, engineAlive: alive })
497  const kase = plan.pcase !== plan.case ? `${plan.case}→${plan.pcase}` : plan.case
498  const role = plan.role === 'interim' ? `中間・実行中${running}件` : '最終'
499  await writeLog($, ctx.root, 'INFO', event,
500    `本文 ${e.answer.length}字 / 作業 ${(e.durationMs / 1000).toFixed(1)}秒 / ケース ${kase}${plan.brief ? ' (brief)' : ''} / ${role} [${voiceFor(ctx.cfg, plan.role).name}]`)
501  if (!plan.read) {
502    await speakPhrase($, ctx, plan.role, plan.phrases, event)
503    return
504  }
505  // 要約と合成は、定型フレーズを鳴らしている間に進める。始めた時点で結果を受け止めておく
506  const body = capture(prepareBody($, ctx, plan.role, e.answer))
507  await speakPhrase($, ctx, plan.role, plan.phrases, event)
508  const b = await body
509  if (!b.ok) throw b.error
510  if (!b.value) return
511  await writeLog($, ctx.root, 'INFO', event, `読み上げ: ${b.value.text}`)
512  await playSynth($, ctx, event, b.value.r)
513}
514
515async function onAgentTurn($: EngineInterface, e: TurnCompleteInput): Promise<void> {
516  const event = 'agentstop'
517  if (e.reason !== 'answer') return
518  const ctx = await context($)
519  if (!ctx) return
520  const agent = (await $.agent.list()).find(a => a.id === e.agentId)
521  // /compact は開始の無い停止を出す。エンジンが知らない agent は本物のサブエージェントではない
522  if (!agent) {
523    await writeLog($, ctx.root, 'INFO', event, `開始を見ていない停止のため無視 (id=${e.agentId})`)
524    return
525  }
526  await writeLog($, ctx.root, 'INFO', event, '発火')
527  const why = await silence($, ctx)
528  if (why) {
529    await writeLog($, ctx.root, 'INFO', event, `無音化: ${why}`)
530    return
531  }
532  const deb = (ctx.cfg.subagent?.debounceSeconds ?? 8) * 1000
533  const since = await claimSpeech($, ctx.root, deb)
534  if (since !== null) {
535    await writeLog($, ctx.root, 'INFO', event, `連発抑制のため無音 (${(since / 1000).toFixed(1)}秒 < ${deb / 1000}秒)`)
536    return
537  }
538  // ENGINE が応答しなければ合成できない。要約(Haiku)は呼ばず、作ってある代わりのフレーズで知らせる
539  if (!(await engineVersion($, enginePort(ctx.cfg)))) {
540    await writeLog($, ctx.root, 'WARN', event, `VOICEVOX ENGINE に接続できない (port ${enginePort(ctx.cfg)})。定型フレーズのみ再生`)
541    await speakPhrase($, ctx, 'interim', ['agent/_default'], event)
542    return
543  }
544  const report = (await summarize($, ctx, 'agentstop', 'interim', e.answer)) ?? agentReportFallback(pickFor(ctx.cfg, e.answer), ctx.cfg.speech ?? {})
545  const text = agentText(agent.description || null, report)
546  if (!text) {
547    await writeLog($, ctx.root, 'WARN', event, '説明も報告も取れないので定型フレーズにフォールバック')
548    await speakPhrase($, ctx, 'interim', ['agent/_default'], event)
549    return
550  }
551  await writeLog($, ctx.root, 'INFO', event, `読み上げ [${voiceFor(ctx.cfg, 'interim').name}]: ${text}`)
552  await playSynth($, ctx, event, await synthFor($, ctx, 'interim', text))
553}
554
555// 連発抑制。直前の読み上げから deb ミリ秒たっていれば今の時刻を記録して null、たっていなければ経過ミリ秒。
556// last_spoken はセッションをまたいで共有する(別のセッションの報告とも重ねない)
557async function claimSpeech($: EngineInterface, root: string, deb: number): Promise<number | null> {
558  const path = `${root}/state/last_spoken`
559  const release = await debounceGate.enter()
560  try {
561    const now = await $.clock.now()
562    let since: number | null = null
563    try {
564      since = Math.max(0, now - (await $.fs.stat(path)).mtimeMs)
565    } catch {
566      since = null
567    }
568    if (since !== null && since < deb) return since
569    await $.fs.write(path, new Date(now).toISOString())
570    return null
571  } finally {
572    release()
573  }
574}
575
576async function onPermission($: EngineInterface, toolName: string): Promise<void> {
577  const event = 'permission'
578  const ctx = await begin($, event)
579  if (!ctx) return
580  const text = permissionText(ctx.cfg, toolName)
581  // 合成は定型フレーズと並行して進める。始めた時点で結果を受け止めておく
582  const r = capture(synthFor($, ctx, 'final', text))
583  await speakPhrase($, ctx, 'final', ['permission'], event)
584  const s = await r
585  if (!s.ok) throw s.error
586  await writeLog($, ctx.root, 'INFO', event, `読み上げ: ${text}`)
587  await playSynth($, ctx, event, s.value)
588}
589
590async function onNotice($: EngineInterface, notificationType: string, mainBusy: boolean): Promise<void> {
591  const kind = noticeKind(notificationType)
592  if (!kind) return
593  const ctx = await begin($, kind)
594  if (!ctx) return
595  if (kind === 'idle') {
596    // idle_prompt はサブエージェントを見ていない。待っているのは利用者の入力ではない
597    const running = await runningAgents($)
598    if (running > 0) {
599      await writeLog($, ctx.root, 'INFO', kind, `サブエージェント実行中 (${running}件) のため無音`)
600      return
601    }
602    if (mainBusy) {
603      await writeLog($, ctx.root, 'INFO', kind, 'メインが作業中のため無音')
604      return
605    }
606  }
607  await speakPhrase($, ctx, 'final', [kind], kind)
608}
609
610async function onTask($: EngineInterface): Promise<void> {
611  const ctx = await begin($, 'task')
612  if (ctx) await speakPhrase($, ctx, 'final', ['task'], 'task')
613}
614
615// ================================================================ ENGINE の場所と起動(setup)
616
617// enginePath → 決まった場所 → 応答だけある(Docker や手で起動した ENGINE)の順
618async function findEngine($: EngineInterface, cfg: VoiceConfig, os: Os): Promise<FoundEngine | null> {
619  const running = (await engineVersion($, enginePort(cfg))) !== null
620  if (cfg.enginePath) {
621    const path = cfg.enginePath.replace(/\\/g, '/')
622    if (await $.fs.exists(path)) return { path, running }
623  }
624  const local = await $.env.get('LOCALAPPDATA')
625  let winget: string[] = []
626  if (os === 'windows' && local) {
627    try {
628      winget = (await $.fs.list(`${local.replace(/\\/g, '/')}/Microsoft/WinGet/Packages`))
629        .filter(d => d.kind === 'dir' && d.name.startsWith('HiroshibaKazuyuki.VOICEVOX'))
630        .map(d => d.name)
631    } catch {
632      winget = []
633    }
634  }
635  const env = { LOCALAPPDATA: local, ProgramFiles: await $.env.get('ProgramFiles'), HOME: await $.env.get('HOME') }
636  for (const c of engineCandidates(os, env, winget)) if (await $.fs.exists(c)) return { path: c, running }
637  return running ? { path: null, running } : null
638}
639
640// Windows の start-engine.ps1 は起動を待って終了コードで答える。それ以外は切り離して起動し、ここで応答を待つ
641async function startEngine($: EngineInterface, cfg: VoiceConfig, os: Os, exe: string): Promise<boolean> {
642  const r = await run($, startArgv(os, $.plugin.root, exe), { timeoutMs: 90_000 })
643  if (os === 'windows') return r.exitCode === 0
644  // 初回はモデル読み込みで十数秒かかる
645  for (let i = 0; i < 60; i++) {
646    if (await engineVersion($, enginePort(cfg))) return true
647    await $.clock.sleep(1000)
648  }
649  return false
650}
651
652// ================================================================ 定型フレーズの生成
653
654async function readProgress($: EngineInterface, root: string): Promise<{ done: number; total: number; at: number } | null> {
655  const path = `${root}/state/phrases-progress`
656  try {
657    const p = JSON.parse(await $.fs.read(path)) as { done: number; total: number }
658    return { done: p.done, total: p.total, at: (await $.fs.stat(path)).mtimeMs }
659  } catch {
660    return null
661  }
662}
663
664async function phraseState($: EngineInterface, ctx: Ctx): Promise<PhraseState> {
665  const jobs = phraseJobs(ctx.cfg, ctx.root)
666  let missing = 0
667  for (const j of jobs) if (!(await $.fs.exists(j.path))) missing++
668  let current = false
669  try {
670    current = (await $.fs.read(`${ctx.root}/state/phrases-stamp`)).trim() === jobsStamp(jobs, ctx.cfg)
671  } catch {
672    current = false
673  }
674  const p = await readProgress($, ctx.root)
675  const busy = generating || phraseBusy(p, await $.clock.now())
676  return { total: jobs.length, missing, current, busy, progress: p && p.done < p.total ? { done: p.done, total: p.total } : null }
677}
678
679// force の前に消すもの:job のフォルダにある wav すべて。文言を減らしたあとの古い NN.wav が残ると、
680// 再生はフォルダの wav をすべて候補にするので、消したはずの文言が鳴り続ける
681async function phraseFolderWavs($: EngineInterface, jobs: readonly { path: string }[]): Promise<string[]> {
682  const out: string[] = []
683  for (const dir of new Set(jobs.map(j => j.path.slice(0, j.path.lastIndexOf('/'))))) {
684    try {
685      for (const f of await $.fs.list(dir)) if (f.kind === 'file' && f.name.endsWith('.wav')) out.push(`${dir}/${f.name}`)
686    } catch {
687      // まだ無い
688    }
689  }
690  return out
691}
692
693// 足りないもの(force ならすべて)を作る。作り終えたら、どの設定で作ったか(stamp)を残す
694async function generatePhrases($: EngineInterface, ctx: Ctx, force: boolean): Promise<void> {
695  if (generating) return
696  generating = true
697  try {
698    const jobs = phraseJobs(ctx.cfg, ctx.root)
699    const stamp = `${ctx.root}/state/phrases-stamp`
700    if (force) await removeFiles($, ctx.os, [...(await phraseFolderWavs($, jobs)), stamp])
701    // 流用するフレーズが今の設定で作ったものと分からなければ stamp を書かない(0.2.0 のものは先頭の無音が無い)。
702    // 書くと doctor が作り直しを案内せず、古いフレーズが鳴り続ける
703    const keptCurrent = force || (await $.fs.read(stamp).catch(() => '')).trim() === jobsStamp(jobs, ctx.cfg)
704    const speakers = ctx.cfg.speakers ?? {}
705    let made = 0
706    let kept = 0
707    let failed = 0
708    for (const [i, job] of jobs.entries()) {
709      if (!force && (await $.fs.exists(job.path))) kept++
710      else {
711        const voice = { name: job.speaker, id: speakers[job.speaker]?.id ?? voiceFor(ctx.cfg, 'final').id }
712        const r = await synthesize($, synthParams(ctx.cfg, voice, ctx.root, ctx.os), convertReading(job.text, ctx.cfg.speech ?? {}), job.path)
713        if ('error' in r) failed++
714        else made++
715      }
716      await $.fs.write(`${ctx.root}/state/phrases-progress`, JSON.stringify({ done: i + 1, total: jobs.length }))
717    }
718    if (failed === 0 && (kept === 0 || keptCurrent)) await $.fs.write(stamp, jobsStamp(jobs, ctx.cfg))
719    await writeLog($, ctx.root, failed ? 'WARN' : 'INFO', 'phrases', `生成 ${made} 件 / 既存流用 ${kept} 件 / 失敗 ${failed} 件`)
720  } finally {
721    generating = false
722  }
723}
724
725// session.start から。足りず、ほかのセッションが作っておらず、ENGINE が応答するときだけ続きを作る。
726// 中身が古いだけ(設定を変えた)のときは作り直さない(doctor が force を案内する)
727async function resumePhrases($: EngineInterface): Promise<void> {
728  const ctx = await context($)
729  if (!ctx) return
730  const st = await phraseState($, ctx)
731  if (st.missing === 0 || st.busy) return
732  if (!(await engineVersion($, enginePort(ctx.cfg)))) return
733  await generatePhrases($, ctx, false)
734}
735
736// ================================================================ コマンド
737
738async function legacyFiles($: EngineInterface, root: string): Promise<string[]> {
739  const out: string[] = []
740  for (const d of LEGACY_STATE_DIRS) {
741    try {
742      for (const f of await $.fs.list(`${root}/state/${d}`)) if (f.kind === 'file') out.push(`${root}/state/${d}/${f.name}`)
743    } catch {
744      // 無い
745    }
746  }
747  for (const f of LEGACY_BIN) if (await $.fs.exists(`${root}/bin/${f}`)) out.push(`${root}/bin/${f}`)
748  return out
749}
750
751async function runCommand($: EngineInterface, args: string): Promise<string> {
752  const cmd = parseCommand(args)
753  if (!cmd) return unknownArg(args)
754  return cmd.kind === 'voice' ? runVoice($, cmd.action) : runSetup($, cmd.action)
755}
756
757async function runVoice($: EngineInterface, action: VoiceAction): Promise<string> {
758  const ctx = await context($)
759  if (!ctx) return `config.json を読めません。${HINT.doctor} で確かめてください。`
760  const muted = await isMuted($, ctx.root)
761  if (action === 'status') return voiceStatus(ctx.cfg, ctx.os, muted)
762  const mute = action === 'toggle' ? !muted : action === 'mute'
763  if (mute) {
764    await speakPhrase($, ctx, 'final', ['mute'], 'voice') // 止める前に知らせる
765    await $.fs.write(`${ctx.root}/state/mute`, new Date(await $.clock.now()).toISOString())
766  } else {
767    await removeFiles($, ctx.os, [`${ctx.root}/state/mute`])
768    await speakPhrase($, ctx, 'final', ['unmute'], 'voice')
769  }
770  await writeLog($, ctx.root, 'INFO', 'voice', '手動ミュートを切り替え')
771  return mute ? '音声通知: 停止しました' : '音声通知: 再開しました'
772}
773
774async function runSetup($: EngineInterface, action: SetupAction): Promise<string> {
775  const root = await homeRoot($)
776  if (!root) return ng('ホームが決まりません(USERPROFILE も HOME もありません)')
777  // 撤去は config.json が壊れていてもできるようにする
778  if (action === 'remove') return (await removeAutostartAndHotkey($, root, await detectOs($))).join('\n')
779  const ctx = await context($)
780  if (!ctx) return [step('設定(config.json)'), ng(`config.json を読めません(JSON の書き間違い)。直してから、もう一度実行してください: ${root}/config.json`)].join('\n')
781  return (action === 'doctor' ? await doctor($, ctx) : await install($, ctx, action === 'force')).join('\n')
782}
783
784async function install($: EngineInterface, ctx: Ctx, force: boolean): Promise<string[]> {
785  const port = enginePort(ctx.cfg)
786  const out = ['voice-notify を導入します', `ホーム: ${ctx.root}`, step('1. ホーム'), ok(`config.json: ${ctx.root}/config.json`)]
787  const legacy = await legacyFiles($, ctx.root)
788  if (legacy.length) {
789    out.push((await removeFiles($, ctx.os, legacy)) ? ok(`0.2.0 の残りを消しました(${legacy.length} 件)`) : warn(`0.2.0 の残りを消せないものがありました(${legacy.length} 件のうち)。${ctx.root}/state と bin を確かめてください`))
790  }
791  out.push(step('2. VOICEVOX'))
792  const engine = await findEngine($, ctx.cfg, ctx.os)
793  if (!engine) {
794    out.push(ng(`VOICEVOX が見つかりません。次の方法で導入してから、もう一度 ${HINT.setup} を実行してください。`), `   ${INSTALL_HINT[ctx.os]}`)
795    if (ctx.os !== 'linux') out.push('   別の場所に入れた場合は、config.json の enginePath に ENGINE(vv-engine の run)の絶対パスを書いてください。')
796    return out
797  }
798  out.push(ok(engine.path ?? `場所は不明(port ${port} で応答あり)`))
799  out.push(step('3. ENGINE の起動'))
800  if (engine.running) out.push(ok(`すでに起動済み (port ${port})`))
801  else if (engine.path && (await startEngine($, ctx.cfg, ctx.os, engine.path))) out.push(ok('起動しました'))
802  else {
803    out.push(ng('ENGINE が起動しませんでした(60秒待っても応答なし)'))
804    return out
805  }
806  out.push(step('4. 定型フレーズ'))
807  const st = await phraseState($, ctx)
808  if (!force && st.current && st.missing === 0) out.push(ok(`生成済み(作り直すときは ${HINT.force})`))
809  else if (st.busy) out.push(ok(`別のセッションが生成中です(${st.progress?.done ?? 0}/${st.total})`))
810  else {
811    void background($, 'phrases', generatePhrases($, ctx, force))
812    out.push(ok(`裏で生成を始めました(初回は数分)。進み具合は ${HINT.doctor} の「定型フレーズ」で確認できます`))
813  }
814  out.push(...(await installAutostartAndHotkey($, ctx, engine)))
815  out.push('', '導入しました。音声通知は、このセッションからすぐ有効です。', `  診断: ${HINT.doctor}`, `  撤去: ${HINT.remove}`)
816  return out
817}
818
819// 報告だけ。何も変えない
820async function doctor($: EngineInterface, ctx: Ctx): Promise<string[]> {
821  const port = enginePort(ctx.cfg)
822  const curl = await run($, [ctx.os === 'windows' ? 'curl.exe' : 'curl', '--version'])
823  let errors: string[] = []
824  try {
825    errors = (await $.fs.read(`${ctx.root}/notify.log`)).trimEnd().split('\n').slice(-200).filter(l => / ERR {2}/.test(l))
826  } catch {
827    errors = []
828  }
829  return doctorReport({
830    pluginRoot: $.plugin.root,
831    root: ctx.root,
832    os: ctx.os,
833    player: ctx.os === 'linux' ? await findLinuxPlayer($) : null,
834    curl: curl.exitCode === 0 ? (curl.stdout.split('\n')[0] ?? '').trim() : null,
835    engine: await findEngine($, ctx.cfg, ctx.os),
836    port,
837    version: await engineVersion($, port),
838    phrases: await phraseState($, ctx),
839    autostart: await autostartStatus($, ctx),
840    muted: await isMuted($, ctx.root),
841    legacy: (await legacyFiles($, ctx.root)).length,
842    errors,
843    credit: creditLine(ctx.cfg),
844  })
845}
846
847// ================================================================ ログオン時の ENGINE 起動とホットキー
848
849// Windows のタスクとホットキーは install.ps1 が扱う(ScheduledTask・WScript.Shell は PowerShell からしか触れない)
850async function installScript($: EngineInterface, args: readonly string[]): Promise<string[]> {
851  const r = await run($, installScriptArgv($.plugin.root, args), { timeoutMs: 60_000 })
852  return r.exitCode === 0 ? fromScript(r.stdout) : [ng(`install.ps1 が失敗: ${r.stderr.trim() || r.exitCode}`)]
853}
854
855async function homeDir($: EngineInterface): Promise<string> {
856  return ((await $.env.get('HOME')) ?? '').replace(/\/+$/, '')
857}
858
859async function guiDomain($: EngineInterface): Promise<string> {
860  return `gui/${(await run($, ['id', '-u'])).stdout.trim()}`
861}
862
863async function installAutostartAndHotkey($: EngineInterface, ctx: Ctx, engine: FoundEngine): Promise<string[]> {
864  const out = [step('5. ログオン時の ENGINE 起動とホットキー')]
865  if (ctx.os === 'windows') return [...out, ...(await installScript($, windowsInstallArgs(ctx.root, ctx.cfg.hotKey ?? 'CTRL+ALT+M', engine.path)))]
866  if (!engine.path) return [...out, ok('外部の ENGINE(Docker など)を使うので、ログオン時の起動は登録しません')]
867  const home = await homeDir($)
868  const values = { LABEL: LAUNCHD_LABEL, RUN: engine.path, DIR: engine.path.slice(0, engine.path.lastIndexOf('/')) }
869  if (ctx.os === 'macos') {
870    const plist = plistPath(home)
871    await $.fs.write(plist, fillTemplate(await $.fs.read(`${$.plugin.root}/scripts/macos/voice-notify-engine.plist`), values, xml))
872    const domain = await guiDomain($)
873    await run($, ['launchctl', 'bootout', `${domain}/${LAUNCHD_LABEL}`]) // 前の登録が無ければ失敗するが、構わない
874    const r = await run($, ['launchctl', 'bootstrap', domain, plist])
875    out.push(r.exitCode === 0 ? ok(`launchd: ${plist}`) : ng(`launchctl bootstrap が失敗: ${r.stderr.trim()}`))
876  } else {
877    const unit = unitPath(home)
878    await $.fs.write(unit, fillTemplate(await $.fs.read(`${$.plugin.root}/scripts/linux/voice-notify-engine.service`), values, systemdValue))
879    await run($, ['systemctl', '--user', 'daemon-reload'])
880    const r = await run($, ['systemctl', '--user', 'enable', '--now', SYSTEMD_UNIT])
881    out.push(r.exitCode === 0 ? ok(`systemd: ${unit}`) : ng(`systemctl --user enable が失敗: ${r.stderr.trim()}`))
882  }
883  out.push(ok(`ホットキーは Windows だけです。ミュートは ${HINT.voice} を使ってください`))
884  return out
885}
886
887async function removeAutostartAndHotkey($: EngineInterface, root: string, os: Os): Promise<string[]> {
888  const out = ['voice-notify を撤去します(ホームは残します)']
889  if (os === 'windows') return [...out, ...(await installScript($, ['-Action', 'remove', '-VHome', root.replace(/\//g, '\\')]))]
890  const home = await homeDir($)
891  if (os === 'macos') {
892    await run($, ['launchctl', 'bootout', `${await guiDomain($)}/${LAUNCHD_LABEL}`])
893    await removeFiles($, os, [plistPath(home)])
894    out.push(ok('launchd の登録を外しました'))
895  } else {
896    await run($, ['systemctl', '--user', 'disable', '--now', SYSTEMD_UNIT])
897    await removeFiles($, os, [unitPath(home)])
898    await run($, ['systemctl', '--user', 'daemon-reload'])
899    out.push(ok('systemd の登録を外しました'))
900  }
901  return out
902}
903
904async function autostartStatus($: EngineInterface, ctx: Ctx): Promise<string[]> {
905  const out = [step('常駐まわり')]
906  if (ctx.os === 'windows') return [...out, ...(await installScript($, ['-Action', 'status', '-VHome', ctx.root.replace(/\//g, '\\')]))]
907  const home = await homeDir($)
908  if (ctx.os === 'macos') {
909    const plist = plistPath(home)
910    out.push((await $.fs.exists(plist)) ? ok(`ログオン時起動(launchd): ${plist}`) : warn('ログオン時起動(launchd)なし'))
911  } else {
912    const r = await run($, ['systemctl', '--user', 'is-enabled', SYSTEMD_UNIT])
913    out.push(r.exitCode === 0 ? ok(`ログオン時起動(systemd): ${r.stdout.trim()}`) : warn('ログオン時起動(systemd)なし'))
914  }
915  return out
916}
917
hooks/decide.ts 182 lines
1// 何を読むかの判定。$ に触れない純粋関数だけを置く(単体テストの対象)。
2// 0.2.0 の notify.ps1 の判断(ケース・brief・中間報告・フレーズ・サブエージェントの文)と、要約 mod の判定を1つにした。
3
4import { pickSentence, stopCase } from './text'
5import type { StopCase } from './text'
6
7export type SpeechConfig = {
8  summarize?: boolean
9  summarizeEvents?: string[]
10  summaryModel?: string
11  summaryMinChars?: number
12  summaryMaxChars?: number
13  interimMaxChars?: number
14  interimPrompt?: string[]
15  summaryPrompt?: string[]
16  summaryTimeoutSec?: number
17  briefMaxSeconds?: number
18  shortSentenceChars?: number
19  maxTwoSentenceChars?: number
20  readings?: Record<string, string>
21  lowercaseMinLength?: number
22  keepUppercase?: string[]
23}
24
25export type VoiceConfig = {
26  speaker?: string
27  speakerInterim?: string
28  speedScale?: number
29  pitchScale?: number
30  intonationScale?: number
31  enginePort?: number
32  enginePath?: string
33  hotKey?: string
34  speakers?: Record<string, { id: number; label?: string; credit?: string }>
35  phrases?: Record<string, unknown>
36  subagent?: { debounceSeconds?: number; defaultLabel?: string }
37  speech?: SpeechConfig
38  mute?: { whenMicInUse?: boolean }
39  cacheMaxFiles?: number
40  playback?: { leadSilenceMs?: number }
41  notification?: { toolLabels?: Record<string, string> }
42}
43
44export type Role = 'final' | 'interim'
45export type Voice = { name: string; id: number }
46
47export const DEFAULT_PROMPT =
48  '次に示すのは Claude Code の応答本文です。音声読み上げ用の日本語1文に要約してください。要約文だけを出力してください。'
49
50// 中間報告(サブエージェントの報告と、サブエージェントを待ったまま終えた応答)は声を変える
51export function voiceFor(cfg: VoiceConfig, role: Role): Voice {
52  const speakers = cfg.speakers ?? {}
53  const final = cfg.speaker ?? 'metan'
54  const want = role === 'interim' && cfg.speakerInterim && speakers[cfg.speakerInterim] ? cfg.speakerInterim : final
55  return { name: want, id: speakers[want]?.id ?? 2 }
56}
57
58export function enginePort(cfg: VoiceConfig): number {
59  return cfg.enginePort ?? 50021
60}
61
62// 本文から読む1〜2文
63export function pickFor(cfg: VoiceConfig, text: string): string | null {
64  const sp = cfg.speech ?? {}
65  return pickSentence(text, { shortMax: sp.shortSentenceChars, twoMax: sp.maxTwoSentenceChars })
66}
67
68export type StopInput = { cfg: VoiceConfig; answer: string; durationMs: number; running: number; engineAlive: boolean }
69export type StopPlan = {
70  role: Role
71  case: StopCase | 'solo'
72  pcase: StopCase | 'solo' | 'interim'
73  brief: boolean
74  phrases: string[]
75  read: boolean
76}
77
78export function planStop(i: StopInput): StopPlan {
79  const sp = i.cfg.speech ?? {}
80  const role: Role = i.running > 0 ? 'interim' : 'final'
81  const readable = !!pickFor(i.cfg, i.answer)
82  const kase: StopCase | 'solo' = readable && i.engineAlive ? stopCase(i.answer) : 'solo'
83  // 中間報告で「完了しました」と言わないよう、done / solo は interim に差し替える。ask / trouble はそのまま伝える
84  const pcase = role === 'interim' && (kase === 'done' || kase === 'solo') ? 'interim' : kase
85  // 作業が短いターンは完了を告げるだけにする(本文は読まない)。ask / trouble の別は残す
86  const brief = kase !== 'solo' && i.durationMs <= (sp.briefMaxSeconds ?? 30) * 1000
87  let first: string
88  if (brief) first = `stop/brief/${pcase}`
89  else if (kase === 'solo' && pcase === 'interim') first = 'stop/brief/interim'
90  else first = `stop/${pcase}`
91  // フレーズが無いときの代わり(0.2.0 の notify.ps1 と同じ順)
92  const chain = [first, ...(first.startsWith('stop/brief/') ? [`stop/${pcase}`] : []), `stop/${kase}`, 'stop']
93  return { role, case: kase, pcase, brief, phrases: [...new Set(chain)], read: !brief && kase !== 'solo' }
94}
95
96export type SummaryPlan = { skip: 'disabled' | 'event' | 'short' } | { skip: null; maxChars: number; system: string }
97
98export function planSummary(speech: SpeechConfig, event: 'stop' | 'agentstop', role: Role, answer: string): SummaryPlan {
99  if (!speech.summarize) return { skip: 'disabled' }
100  if (speech.summarizeEvents && !speech.summarizeEvents.includes(event)) return { skip: 'event' }
101  // 途中経過は上限=下限(上限より短い報告は要約しても縮まらない)
102  const interim = role === 'interim' && !!speech.interimMaxChars
103  const maxChars = interim ? (speech.interimMaxChars as number) : (speech.summaryMaxChars ?? 60)
104  const minChars = interim ? (speech.interimMaxChars as number) : (speech.summaryMinChars ?? 0)
105  if (answer.length < minChars) return { skip: 'short' }
106  const lines = speech.summaryPrompt && speech.summaryPrompt.length ? [...speech.summaryPrompt] : [DEFAULT_PROMPT]
107  if (role === 'interim' && speech.interimPrompt && speech.interimPrompt.length) lines.push(...speech.interimPrompt)
108  return { skip: null, maxChars, system: lines.join('\n').split('{maxChars}').join(String(maxChars)) }
109}
110
111// 「<説明>が完了しました。<報告>」。報告の頭30文字が既に「完了」を言っていれば繰り返さない
112export function agentText(desc: string | null, report: string | null): string | null {
113  const dupe = !!report && /完了|終わ|できました/.test(report.slice(0, 30))
114  if (desc && report && dupe) return `${desc}。${report}`
115  if (desc && report) return `${desc}が完了しました。${report}`
116  if (desc) return `${desc}が完了しました。`
117  return report || null
118}
119
120// 要約が無いときの抜き出し。報告はコミットID・ファイル一覧が続きやすく、長いものは読まずに説明だけにする
121export function agentReportFallback(pick: string | null, speech: SpeechConfig): string | null {
122  if (!pick) return null
123  const max = speech.interimMaxChars ?? speech.summaryMaxChars ?? 60
124  const min = speech.interimMaxChars ?? speech.summaryMinChars ?? 0
125  return pick.length > Math.max(max, min) + 1 ? null : pick
126}
127
128export function permissionText(cfg: VoiceConfig, toolName: string): string {
129  return `${cfg.notification?.toolLabels?.[toolName] ?? toolName}の許可待ちです。`
130}
131
132export type NoticeKind = 'idle' | 'permission' | 'notification'
133
134// permission_prompt は classic.PermissionRequest(ツール名付き)で読むので、ここでは扱わない
135export function noticeKind(notificationType: string): NoticeKind | null {
136  if (notificationType === 'idle_prompt') return 'idle'
137  if (/^(elicitation_dialog|elicitation_url_dialog|agent_needs_input)$/.test(notificationType)) return 'permission'
138  if (/^quota_auto_resume_(fired|stale|disabled)$/.test(notificationType)) return 'notification'
139  return null
140}
141
142// 直前と同じものを選ばない(「完了しました」の3連発を防ぐ)。random は [0, 1)
143export function choosePhrase(files: string[], prev: string | null, random: number): string | null {
144  if (files.length === 0) return null
145  const pool = files.filter(f => f !== prev)
146  const from = pool.length ? pool : files
147  return from[Math.min(from.length - 1, Math.floor(random * from.length))]!
148}
149
150const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
151
152// 設定と状態の置き場所(ホーム)。VOICE_NOTIFY_HOME、無ければ ~/.claude/voice-notify
153export function voiceHome(envHome: string | undefined, userProfile: string | undefined, home: string | undefined): string | null {
154  if (envHome && envHome.trim()) return norm(envHome.trim())
155  const base = (userProfile && userProfile.trim()) || (home && home.trim())
156  return base ? `${norm(base)}/.claude/voice-notify` : null
157}
158
159function stripBom(text: string): string {
160  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
161}
162
163const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
164
165// 正しい JSON でも、オブジェクトでなければ(null・配列など)壊れた設定として扱う
166export function parseConfig(text: string): VoiceConfig {
167  const v: unknown = JSON.parse(stripBom(text))
168  if (!isObject(v)) throw new Error('config.json の中身がオブジェクトではない')
169  return v as VoiceConfig
170}
171
172// 同梱の既定の設定に、利用者の設定を重ねる。オブジェクトはキーごとに重ね、配列と値は利用者のものを使う
173// (0.2.0 からの config.json に無いキーも、既定値で動く)
174export function mergeConfig(defaults: Record<string, unknown>, user: Record<string, unknown>): Record<string, unknown> {
175  const out: Record<string, unknown> = { ...defaults }
176  for (const [k, v] of Object.entries(user)) {
177    const d = defaults[k]
178    out[k] = isObject(d) && isObject(v) ? mergeConfig(d, v) : v
179  }
180  return out
181}
182
hooks/gate.ts 25 lines
1// 同じセッションの中で、非同期の処理を1つずつ通す関門($ に触れない)。
2// enter が返す関数を呼ぶと次が通る。
3export class Gate {
4  private tail: Promise<void> = Promise.resolve()
5
6  async enter(): Promise<() => void> {
7    let release!: () => void
8    const done = new Promise<void>(resolve => (release = resolve))
9    const prev = this.tail
10    this.tail = prev.then(() => done)
11    await prev
12    return release
13  }
14}
15
16export type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown }
17
18// 先に始めて後で待つ処理の結果を、始めた時点で受け止める(待つまでの間に失敗しても、未処理の例外にならない)
19export function capture<T>(work: Promise<T>): Promise<Settled<T>> {
20  return work.then(
21    value => ({ ok: true as const, value }),
22    error => ({ ok: false as const, error }),
23  )
24}
25
hooks/os.ts 54 lines
1// OS の違いを閉じ込める純粋関数。実行は register.ts($ を使えるのは hooks モジュールの中だけ)。
2
3export type Os = 'windows' | 'macos' | 'linux'
4
5export function osFrom(osEnv: string | undefined, uname: string): Os {
6  if (osEnv === 'Windows_NT') return 'windows'
7  return uname.trim() === 'Darwin' ? 'macos' : 'linux'
8}
9
10// $.fs は削除できないので、削除は OS のコマンドで行う。消すものが無ければ null。
11// Windows は cmd の del を使わない:コマンドラインが 8191 文字を超えると何も消えず、パスの & や % を cmd が解釈する。
12// パスを stdin で1行ずつ remove.ps1 に渡し、Remove-Item -LiteralPath で消す
13export function removeArgv(os: Os, pluginRoot: string, paths: readonly string[]): { argv: string[]; stdin: string } | null {
14  if (paths.length === 0) return null
15  if (os === 'windows') {
16    return {
17      argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${pluginRoot}/scripts/windows/remove.ps1`.replace(/\//g, '\\')],
18      stdin: paths.map(p => p.replace(/\//g, '\\')).join('\n'),
19    }
20  }
21  return { argv: ['rm', '-f', '--', ...paths], stdin: '' }
22}
23
24// マイクを使っているアプリ(Windows)。出力は日本語の環境では CP932 で、$.process.run は UTF-8 として読む。
25// 判定に使うのは値の数字なので影響しない(ログに出すアプリ名が化けることはある)
26export const MIC_QUERY: readonly string[] = [
27  'reg', 'query', 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone', '/s',
28]
29
30const leaf = (k: string) => k.slice(k.lastIndexOf('\\') + 1)
31
32// reg query /s の出力から、使用中(開始時刻があり終了時刻が 0)のアプリを探す。
33// Windows はマイク使用中のアプリを LastUsedTimeStop = 0 で表す
34export function micInUseFrom(regOutput: string): string | null {
35  let current: string | null = null
36  let start = BigInt(0)
37  let stop: bigint | null = null
38  // 末尾に区切りを1つ足して、最後のキーも同じ手順で判定する
39  for (const line of [...regOutput.split(/\r?\n/), 'HKEY_END']) {
40    if (line.startsWith('HKEY_')) {
41      if (current !== null && start > BigInt(0) && stop === BigInt(0)) return leaf(current)
42      current = line.trim()
43      start = BigInt(0)
44      stop = null
45      continue
46    }
47    const m = /^\s+(LastUsedTimeStart|LastUsedTimeStop)\s+REG_QWORD\s+0x([0-9a-fA-F]+)/.exec(line)
48    if (!m) continue
49    if (m[1] === 'LastUsedTimeStart') start = BigInt(`0x${m[2]}`)
50    else stop = BigInt(`0x${m[2]}`)
51  }
52  return null
53}
54
hooks/phrases.ts 47 lines
1// 定型フレーズの一覧と、作り直しが要るかの判定(純粋関数)。生成の I/O は register.ts。
2
3import type { VoiceConfig } from './decide'
4import { hash16 } from './voicevox'
5
6export type PhraseJob = { speaker: string; path: string; text: string }
7export type PhraseState = { total: number; missing: number; current: boolean; busy: boolean; progress: { done: number; total: number } | null }
8
9// 生成中の記録がこれより新しければ、別のセッションが作っているとみなす
10const BUSY_MS = 120_000
11
12// フレーズ定義は配列(<dir>/NN.wav)か、サブフォルダに分けるオブジェクト(0.2.0 の gen-phrases.ps1 と同じ並び)
13function walk(node: unknown, dir: string, speaker: string, out: PhraseJob[]): void {
14  if (Array.isArray(node)) {
15    node.forEach((text, i) => out.push({ speaker, path: `${dir}/${String(i + 1).padStart(2, '0')}.wav`, text: String(text) }))
16    return
17  }
18  if (node && typeof node === 'object') {
19    for (const [k, v] of Object.entries(node)) walk(v, `${dir}/${k}`, speaker, out)
20  }
21}
22
23export function phraseJobs(cfg: VoiceConfig, root: string): PhraseJob[] {
24  const out: PhraseJob[] = []
25  for (const speaker of Object.keys(cfg.speakers ?? {})) {
26    walk(cfg.phrases ?? {}, `${root}/phrases/${speaker}`, speaker, out)
27    // 説明も報告も取れなかったサブエージェントの完了。0.2.0 は agent/_default.wav というファイルだったが、
28    // 再生はフォルダから選ぶので、ほかのフレーズと同じ形(フォルダの中の NN.wav)にそろえる
29    out.push({ speaker, path: `${root}/phrases/${speaker}/agent/_default/01.wav`, text: `${cfg.subagent?.defaultLabel ?? 'エージェント'}が完了しました。` })
30  }
31  return out
32}
33
34// 作ったフレーズがどの設定のものか。文言・話者・話速・読み替え・先頭の無音のどれかが変われば変わる
35export function jobsStamp(jobs: readonly PhraseJob[], cfg: VoiceConfig): string {
36  const sp = cfg.speech ?? {}
37  const voice = JSON.stringify([
38    cfg.speakers ?? {}, cfg.speedScale, cfg.pitchScale, cfg.intonationScale, cfg.playback?.leadSilenceMs ?? 600,
39    sp.readings ?? {}, sp.lowercaseMinLength ?? 0, sp.keepUppercase ?? [],
40  ])
41  return hash16(`${voice}\n${jobs.map(j => `${j.path}\t${j.text}`).join('\n')}`)
42}
43
44export function phraseBusy(progress: { done: number; total: number; at: number } | null, now: number): boolean {
45  return !!progress && progress.done < progress.total && now - progress.at < BUSY_MS
46}
47
hooks/player.ts 23 lines
1// 再生コマンドの形。OS ごとの再生を、排他つきの小さなスクリプト(scripts/windows/play.ps1・scripts/posix/play.sh)経由で呼ぶ。
2// 実行は register.ts。$.audio.play は macOS 以外では鳴らない(型定義の説明)ので使わない。
3
4import type { Os } from './os'
5
6export const LINUX_PLAYERS: readonly string[] = ['pw-play', 'paplay', 'aplay']
7
8const win = (p: string) => p.replace(/\//g, '\\')
9
10// どれも wav を最後に渡す。Linux でプレイヤーが見つかっていなければ null
11export function playArgv(os: Os, pluginRoot: string, root: string, linuxPlayer: string | null, wav: string): string[] | null {
12  if (os === 'windows') {
13    return ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', win(`${pluginRoot}/scripts/windows/play.ps1`), win(wav)]
14  }
15  const player = os === 'macos' ? 'afplay' : linuxPlayer
16  if (!player) return null
17  return ['sh', `${pluginRoot}/scripts/posix/play.sh`, `${root}/state/playback.lock`, player, wav]
18}
19
20export function probeArgv(name: string): string[] {
21  return ['sh', '-c', `command -v ${name}`]
22}
23
hooks/autostart.ts 50 lines
1// ログオン時の ENGINE 起動(Windows はタスク、macOS は launchd、Linux は systemd のユーザーサービス)と、
2// ホットキー(Windows だけ)の形(純粋関数)。実行は register.ts。
3
4import { ng, ok, warn } from './setup'
5
6export const LAUNCHD_LABEL = 'jp.nextscape.voice-notify.engine'
7export const SYSTEMD_UNIT = 'voice-notify-engine.service'
8
9const win = (p: string) => p.replace(/\//g, '\\')
10
11export function xml(s: string): string {
12  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
13}
14
15// systemd の unit の値。% は指定子(%h など)として読まれるので %% にする
16export function systemdValue(s: string): string {
17  return s.replace(/%/g, '%%')
18}
19
20// ひな形の {{KEY}} を置き換える。値は書式に合わせて escape する(plist は xml、systemd の unit は systemdValue)
21export function fillTemplate(template: string, values: Record<string, string>, escape: (s: string) => string): string {
22  return template.replace(/\{\{(\w+)\}\}/g, (all, k: string) => (k in values ? escape(values[k]!) : all))
23}
24
25// install.ps1 の「OK / WARN / NG <文>」を setup の書式に直す
26export function fromScript(stdout: string): string[] {
27  return stdout.split(/\r?\n/).filter(Boolean).map(l => {
28    const m = /^(OK|WARN|NG) (.*)$/.exec(l)
29    if (!m) return `   ${l}`
30    return m[1] === 'OK' ? ok(m[2]!) : m[1] === 'WARN' ? warn(m[2]!) : ng(m[2]!)
31  })
32}
33
34export function installScriptArgv(pluginRoot: string, args: readonly string[]): string[] {
35  return ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', win(`${pluginRoot}/scripts/windows/install.ps1`), ...args]
36}
37
38// ENGINE の場所が分からない(外部の ENGINE)ときは、タスクを登録しない
39export function windowsInstallArgs(root: string, hotKey: string, enginePath: string | null): string[] {
40  return ['-Action', 'install', '-VHome', win(root), '-HotKey', hotKey, ...(enginePath ? ['-EnginePath', win(enginePath)] : [])]
41}
42
43export function plistPath(home: string): string {
44  return `${home}/Library/LaunchAgents/${LAUNCHD_LABEL}.plist`
45}
46
47export function unitPath(home: string): string {
48  return `${home}/.config/systemd/user/${SYSTEMD_UNIT}`
49}
50
hooks/setup.ts 133 lines
1// /voice-notify の引数と表示(純粋関数)。実行は register.ts。
2// mod のコマンド名は英数字・_・- だけなので、0.2.0 の /voice-notify:setup・/voice-notify:voice は /voice-notify のサブコマンドにした。
3// 表示は 0.2.0 の setup.ps1 と同じ書式(== 見出し、OK / 注意 / NG の行)。
4
5import { voiceFor } from './decide'
6import type { VoiceConfig } from './decide'
7import type { Os } from './os'
8import type { PhraseState } from './phrases'
9import type { FoundEngine } from './voicevox'
10
11export const COMMAND = 'voice-notify'
12const CMD = `/${COMMAND}`
13
14export const ok = (m: string) => `   OK   ${m}`
15export const warn = (m: string) => `   注意 ${m}`
16export const ng = (m: string) => `   NG   ${m}`
17export const step = (m: string) => `\n== ${m}`
18
19export const OS_NAME: Record<Os, string> = { windows: 'Windows', macos: 'macOS', linux: 'Linux' }
20export const INSTALL_HINT: Record<Os, string> = {
21  windows: 'winget install --id HiroshibaKazuyuki.VOICEVOX.CPU -e',
22  macos: '公式サイト(https://voicevox.hiroshiba.jp/)から dmg を入れる',
23  linux: '公式サイトの tar.gz を展開し、config.json の enginePath に vv-engine/run の絶対パスを書く',
24}
25
26// 0.2.0 の残り:状態ファイル(mod に移って要らなくなった)と、ホットキー・タスクの古い入口
27export const LEGACY_STATE_DIRS: readonly string[] = ['turns', 'agents', 'summaries']
28export const LEGACY_BIN: readonly string[] = ['launch.ps1', 'plugin-root.txt']
29
30export type SetupAction = 'install' | 'doctor' | 'force' | 'remove'
31export type VoiceAction = 'toggle' | 'mute' | 'unmute' | 'status'
32export type Command = { kind: 'voice'; action: VoiceAction } | { kind: 'setup'; action: SetupAction }
33
34export const USAGE = `使い方: ${CMD} [on|off|status](省略時は切替) / ${CMD} setup [force] / ${CMD} doctor / ${CMD} remove`
35
36const COMMANDS: Record<string, Command> = {
37  '': { kind: 'voice', action: 'toggle' },
38  toggle: { kind: 'voice', action: 'toggle' },
39  on: { kind: 'voice', action: 'unmute' },
40  unmute: { kind: 'voice', action: 'unmute' },
41  off: { kind: 'voice', action: 'mute' },
42  mute: { kind: 'voice', action: 'mute' },
43  status: { kind: 'voice', action: 'status' },
44  setup: { kind: 'setup', action: 'install' },
45  'setup force': { kind: 'setup', action: 'force' },
46  doctor: { kind: 'setup', action: 'doctor' },
47  remove: { kind: 'setup', action: 'remove' },
48}
49
50export function parseCommand(args: string): Command | null {
51  return COMMANDS[args.trim().toLowerCase().split(/\s+/).join(' ')] ?? null
52}
53
54export function unknownArg(args: string): string {
55  return `知らない引数です: ${args.trim()}。${USAGE}`
56}
57
58// どの表示からでも同じ言い方で次の操作を案内する
59export const HINT = {
60  setup: `${CMD} setup`,
61  force: `${CMD} setup force`,
62  doctor: `${CMD} doctor`,
63  remove: `${CMD} remove`,
64  unmute: `${CMD} on`,
65  voice: `${CMD} [on|off|status]`,
66}
67
68export function voiceStatus(cfg: VoiceConfig, os: Os, muted: boolean): string {
69  const label = (n: string) => cfg.speakers?.[n]?.label ?? n
70  return [
71    `手動ミュート: ${muted ? 'ON' : 'OFF'}`,
72    `マイク使用中の自動ミュート: ${os === 'windows' && cfg.mute?.whenMicInUse ? '有効' : '無効'}`,
73    `話者: 最終 ${label(voiceFor(cfg, 'final').name)} / 中間 ${label(voiceFor(cfg, 'interim').name)} / 話速: ${cfg.speedScale ?? 1}`,
74    `クレジット: ${creditLine(cfg)}`,
75  ].join('\n')
76}
77
78// 音源の規約はアプリでの利用に「少し探せばわかる場所」へのクレジット表記を求める。
79// 使っている話者の speakers.*.credit を並べる。credit の無い話者は VOICEVOX とだけ書く
80export function creditLine(cfg: VoiceConfig): string {
81  const names = [voiceFor(cfg, 'final').name, voiceFor(cfg, 'interim').name]
82  return [...new Set(names.map(n => cfg.speakers?.[n]?.credit ?? 'VOICEVOX'))].join('、')
83}
84
85// ENGINE 0.24 から英単語をカタカナで読む
86export function hasKanaReading(version: string): boolean {
87  const [maj = 0, min = 0] = version.split('.').map(Number)
88  return maj > 0 || min >= 24
89}
90
91export type DoctorFacts = {
92  pluginRoot: string
93  root: string
94  os: Os
95  player: string | null // Linux で見つかったプレイヤー
96  curl: string | null // curl --version の1行目
97  engine: FoundEngine | null
98  port: number
99  version: string | null
100  phrases: PhraseState
101  autostart: string[] // OS ごとの「常駐まわり」の節
102  muted: boolean
103  legacy: number // 0.2.0 の残りの件数
104  errors: string[] // 直近200行の ERR の行
105  credit: string // creditLine
106}
107
108export function doctorReport(f: DoctorFacts): string[] {
109  const out = ['voice-notify 診断', `プラグイン: ${f.pluginRoot}`, `ホーム:     ${f.root}`, step('設定(config.json)'), ok(`${f.root}/config.json`)]
110  out.push(step('OS と再生'))
111  if (f.os === 'linux') out.push(f.player ? ok(`Linux / 再生: ${f.player}`) : ng('Linux / 再生コマンドが見つからない(pw-play / paplay / aplay のどれかを入れる)'))
112  else out.push(ok(`${OS_NAME[f.os]} / 再生: ${f.os === 'windows' ? 'SoundPlayer' : 'afplay'}`))
113  out.push(f.curl ? ok(`curl: ${f.curl}`) : ng('curl が見つからない(合成に使う)'))
114  out.push(step('VOICEVOX'))
115  out.push(f.engine?.path ? ok(f.engine.path) : f.engine ? warn('場所は不明(応答はある)') : ng(`見つからない。${INSTALL_HINT[f.os]}`))
116  if (f.version) {
117    out.push(ok(`ENGINE 応答あり (port ${f.port})`))
118    out.push(hasKanaReading(f.version) ? ok(`ENGINE ${f.version}(英単語のカタカナ読みあり)`) : warn(`ENGINE ${f.version} は英単語を1文字ずつ読む。0.24 以降を推奨`))
119  } else out.push(ng(`ENGINE が応答しない (port ${f.port})。${HINT.setup} で起動する`))
120  out.push(step('定型フレーズ'))
121  const p = f.phrases
122  if (p.missing) out.push(warn(`定型フレーズが足りない: ${p.missing}/${p.total} 件${p.progress ? `(生成中 ${p.progress.done}/${p.progress.total})` : ''}。${HINT.setup} を実行する`))
123  else if (!p.current) out.push(warn(`定型フレーズが今の設定(文言・話者・話速・読み替え・先頭の無音)と合わない。${HINT.force} で作り直す`))
124  else out.push(ok(`${p.total} 件`))
125  out.push(...f.autostart)
126  out.push(step('状態'))
127  out.push(ok(f.muted ? `手動ミュート中(${HINT.unmute} で解除)` : 'ミュートなし'))
128  if (f.legacy) out.push(warn(`0.2.0 の残りがある(${f.legacy} 件)。${HINT.setup} で消える`))
129  out.push(f.errors.length ? warn(`直近200行にエラー ${f.errors.length} 件。最後: ${f.errors.at(-1)!.slice(21)}`) : ok('直近200行にエラーなし'))
130  out.push(step('クレジット'), ok(f.credit))
131  return out
132}
133
hooks/store.ts 26 lines
1// ホーム(~/.claude/voice-notify)の中身の形。読み書きは register.ts($ を使えるのは hooks モジュールの中だけ)。
2
3export type Level = 'INFO' | 'WARN' | 'ERR'
4
5const LOG_MAX = 200 * 1024
6const LOG_KEEP = 500
7
8const pad = (n: number) => String(n).padStart(2, '0')
9
10// notify.log の1行(0.2.0 と同じ書式)
11export function logLine(at: Date, level: Level, event: string, msg: string): string {
12  const stamp = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
13  return `${stamp}  ${level.padEnd(4)} ${event.padEnd(11)} ${msg}`
14}
15
16// $.fs に追記は無いので、読んだ中身に足して書き直す。約200KB を超えたら新しい 500 行だけ残す
17export function appendLog(old: string, lines: readonly string[]): string {
18  const text = `${old}${lines.join('\n')}\n`
19  return text.length > LOG_MAX ? `${text.trimEnd().split('\n').slice(-LOG_KEEP).join('\n')}\n` : text
20}
21
22// フレーズのフォルダ('stop/brief/done' など)ごとに、直前に選んだ wav を覚えるファイル(0.2.0 と同じ名前)
23export function phraseMemo(root: string, name: string): string {
24  return `${root}/state/last_phrase_${name.replace(/[\\/]/g, '_')}`
25}
26
hooks/text.ts 86 lines
1// 読み上げ用の整形。$ に触れない純粋関数だけを置く(0.2.0 の notify.ps1 / lib.ps1 から移した)。
2
3export type StopCase = 'ask' | 'trouble' | 'done'
4export type ReadingConfig = { readings?: Record<string, string>; lowercaseMinLength?: number; keepUppercase?: string[] }
5
6const SENTENCE = /[^。!?!?]{1,200}[。!?!?]/g
7
8// 読み上げに向かない要素(コードブロック・表・リンク先・URL)を落とす
9function stripMarkup(t: string): string {
10  return t
11    .replace(/```[\s\S]*?```/g, '')
12    .replace(/^\s*\|.*$/gm, '')
13    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
14    .replace(/https?:\/\/\S+/g, '')
15}
16
17const squash = (t: string) => t.replace(/\s+/g, ' ').trim()
18
19// 「記号は使わない」と指示しても、要約が本文の Markdown を写してくることがある
20export function clearSpeech(t: string): string {
21  return squash(t.replace(/[`*_#>|[\]]/g, ' '))
22}
23
24// 先頭の1〜2文。先頭1文が shortMax 以下なら2文目まで、ただし合計が twoMax を超えるなら1文だけ
25export function pickSentence(txt: string, opt: { shortMax?: number; twoMax?: number } = {}): string | null {
26  if (!txt) return null
27  const t = squash(stripMarkup(txt).replace(/[`*#>_-]/g, ' '))
28  if (t.length < 4) return null
29  const sentences = t.match(SENTENCE) ?? []
30  if (sentences.length === 0) return `${t.slice(0, 60)}。`
31  const s1 = sentences[0]!
32  const shortMax = opt.shortMax ?? 20
33  const twoMax = opt.twoMax ?? 60
34  if (s1.length <= shortMax && sentences.length >= 2) {
35    const two = s1 + sentences[1]
36    if (two.length <= twoMax) return two
37  }
38  return s1
39}
40
41// 前置きの定型フレーズを選ぶため、末尾2文だけを見て種類を決める。
42// 末尾に限るのは「当初はエラーで停止していました。…修正して全件通っています。」を trouble にしないため
43export function stopCase(txt: string): StopCase {
44  if (!txt) return 'done'
45  const t = squash(stripMarkup(txt).replace(/[`*#>_]/g, ' '))
46  if (t.length < 2) return 'done'
47  const sentences = t.match(SENTENCE) ?? []
48  const rest = t.slice(Math.min(sentences.join('').length, t.length)).trim()
49  const parts = rest.length >= 4 ? [...sentences, rest] : sentences
50  const tail = parts.slice(-2).join('') || t
51  if (/[??]\s*$/.test(tail)) return 'ask'
52  if (/(ますか|ましょうか|でしょうか|いかがですか|どうしますか|どうするか)[。.!!??]?\s*$/.test(tail)) return 'ask'
53  if (/(ご判断|ご確認ください|お選びください|お決めください|ご指示|どちらに|どちらで|番号で|教えてください|よろしいですか|いかがでしょう)/.test(tail)) return 'ask'
54  // 解決済みの言及は打ち消してから未解決語を探す(「エラーを修正しました」は done)
55  const chk = tail.replace(/(失敗|エラー|不具合|例外|問題)[をがはも]?[^。]{0,8}?(修正|解決|直し|直り|対処|復旧|解消|通るように)/g, '')
56  if (/(失敗|エラー|不具合|例外|できませんでした|できていません|通りません|落ちて|未解決|解決していません|原因が分から|うまくいかな|うまくいっていません)/.test(chk)) return 'trouble'
57  return 'done'
58}
59
60const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
61
62// VOICEVOX は辞書に無い英字を1文字ずつ読む。辞書で置き換え、長い全部大文字の語は小文字にして英単語として読ませる。
63// 前後が英数字か _ なら触らない(\b は日本語も単語文字に数えるので使えない)
64export function convertReading(text: string, r: ReadingConfig): string {
65  if (!text) return text
66  let out = text
67  for (const [word, reading] of Object.entries(r.readings ?? {})) {
68    out = out.replace(new RegExp(`(?<![A-Za-z0-9_])${escape(word)}(?![A-Za-z0-9_])`, 'gi'), () => reading)
69  }
70  const min = r.lowercaseMinLength ?? 0
71  if (min > 0) {
72    const keep = new Set(r.keepUppercase ?? [])
73    out = out.replace(new RegExp(`(?<![A-Za-z0-9_])[A-Z]{${min},}(?![A-Za-z0-9_])`, 'g'), w => (keep.has(w) ? w : w.toLowerCase()))
74  }
75  return out
76}
77
78// Haiku は「要約文:」のような前置きや2行目を付けることがある(2026-10-06 実測)
79export function cleanSummary(raw: string, maxChars: number): { text: string } | { error: string } {
80  const lines = raw.split(/\r?\n/).map(s => s.replace(/^\s*(要約|報告)文?\s*[::]/, '').replace(/\s+/g, ' ').trim())
81  const text = lines.find(s => s.length > 0) ?? ''
82  if (!text) return { error: 'empty-reply' }
83  if (text.length > maxChars * 3) return { error: `too-long (${text.length})` }
84  return { text }
85}
86
hooks/voicevox.ts 144 lines
1// VOICEVOX ENGINE(127.0.0.1)との通信の形。実行は register.ts($ を使えるのは hooks モジュールの中だけ)。
2// audio_query は JSON なので $.http.fetch。synthesis は WAV(バイナリ)で $.http.fetch では受け取れないので curl でファイルに落とす。
3
4import { enginePort } from './decide'
5import type { Voice, VoiceConfig } from './decide'
6import type { Os } from './os'
7
8export type SynthParams = {
9  root: string
10  os: Os
11  port: number
12  speakerId: number
13  speed: number
14  pitch: number
15  intonation: number
16  leadMs: number
17}
18
19const win = (p: string) => p.replace(/\//g, '\\')
20const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
21const base = (port: number) => `http://127.0.0.1:${port}`
22
23export function synthParams(cfg: VoiceConfig, voice: Voice, root: string, os: Os): SynthParams {
24  return {
25    root,
26    os,
27    port: enginePort(cfg),
28    speakerId: voice.id,
29    speed: cfg.speedScale ?? 1,
30    pitch: cfg.pitchScale ?? 0,
31    intonation: cfg.intonationScale ?? 1,
32    leadMs: cfg.playback?.leadSilenceMs ?? 600,
33  }
34}
35
36// FNV-1a(32bit)を種違いで2回。キャッシュの名前に使うだけなので衝突耐性は要らない
37function fnv(text: string, seed: number): string {
38  let h = seed >>> 0
39  for (let i = 0; i < text.length; i++) {
40    h ^= text.charCodeAt(i)
41    h = Math.imul(h, 0x01000193) >>> 0
42  }
43  return h.toString(16).padStart(8, '0')
44}
45
46export function hash16(text: string): string {
47  return fnv(text, 0x811c9dc5) + fnv(text, 0x050c5d1f)
48}
49
50// 既定の出力先がディスプレイの音声(HDMI/DisplayPort)だと鳴らし始めが欠ける。
51// 0.2.0 は再生の前に WAV の頭へ無音を足していたが、mod はバイナリを書けないので、合成の時点で入れる
52export function tuneQuery(raw: Record<string, unknown>, p: SynthParams): Record<string, unknown> {
53  const pre = typeof raw.prePhonemeLength === 'number' ? raw.prePhonemeLength : 0.1
54  return {
55    ...raw,
56    speedScale: p.speed,
57    pitchScale: p.pitch,
58    intonationScale: p.intonation,
59    prePhonemeLength: Math.round((pre + p.leadMs / 1000) * 1000) / 1000,
60  }
61}
62
63// 合成結果のキャッシュ。文と声の設定がすべて同じなら同じファイル
64export function cachePath(p: SynthParams, text: string): string {
65  return `${p.root}/cache/${hash16([text, p.speakerId, p.speed, p.pitch, p.intonation, p.leadMs].join('|'))}.wav`
66}
67
68export function versionUrl(port: number): string {
69  return `${base(port)}/version`
70}
71
72export function audioQueryUrl(p: SynthParams, text: string): string {
73  return `${base(p.port)}/audio_query?text=${encodeURIComponent(text)}&speaker=${p.speakerId}`
74}
75
76// クエリは stdin で渡す(一時ファイルを作って消す手間をかけない)。
77// --create-dirs は使わない:Windows 標準の curl は非 ASCII のフォルダを作れない(exit 23)ので、フォルダは呼ぶ側が先に作る
78export function synthArgv(p: SynthParams, out: string): string[] {
79  return [
80    p.os === 'windows' ? 'curl.exe' : 'curl', '-s', '-f', '-m', '120', '-H', 'Content-Type: application/json',
81    '--data-binary', '@-', '-o', out, `${base(p.port)}/synthesis?speaker=${p.speakerId}`,
82  ]
83}
84
85// /version は JSON の文字列("0.25.2")を返す
86export function parseVersion(text: string): string | null {
87  try {
88    const v: unknown = JSON.parse(text)
89    return typeof v === 'string' ? v : null
90  } catch {
91    return null
92  }
93}
94
95// 新しいものから max 件を残す。キャッシュに当たっても更新時刻は変えられない($.fs はバイナリを書き直せない)ので、作った順に古いものから消える
96export function cacheToDrop(entries: ReadonlyArray<{ name: string; kind: string; mtimeMs: number }>, root: string, max: number): string[] {
97  return entries
98    .filter(f => f.kind === 'file' && f.name.endsWith('.wav'))
99    .sort((a, b) => b.mtimeMs - a.mtimeMs)
100    .slice(max)
101    .map(f => `${root}/cache/${f.name}`)
102}
103
104// ---- ENGINE の場所と起動(setup が使う) ----
105
106// path が null は「場所は分からないが応答はある」(Docker や手で起動した ENGINE)
107export type FoundEngine = { path: string | null; running: boolean }
108
109export function engineCandidates(os: Os, env: { LOCALAPPDATA?: string; ProgramFiles?: string; HOME?: string }, wingetDirs: readonly string[]): string[] {
110  if (os === 'windows') {
111    const local = env.LOCALAPPDATA ? slash(env.LOCALAPPDATA) : null
112    return [
113      ...(local ? wingetDirs.map(d => `${local}/Microsoft/WinGet/Packages/${d}/VOICEVOX/vv-engine/run.exe`) : []),
114      ...(local ? [`${local}/Programs/VOICEVOX/vv-engine/run.exe`] : []),
115      ...(env.ProgramFiles ? [`${slash(env.ProgramFiles)}/VOICEVOX/vv-engine/run.exe`] : []),
116    ]
117  }
118  const home = env.HOME ? slash(env.HOME) : null
119  if (os === 'macos') {
120    // .app の中の位置は版で変わりうるので2通り試す
121    return ['Resources', 'MacOS'].flatMap(d => [
122      `/Applications/VOICEVOX.app/Contents/${d}/vv-engine/run`,
123      ...(home ? [`${home}/Applications/VOICEVOX.app/Contents/${d}/vv-engine/run`] : []),
124    ])
125  }
126  return [
127    ...(home ? [`${home}/.voicevox/vv-engine/run`, `${home}/VOICEVOX/vv-engine/run`] : []),
128    '/opt/voicevox/vv-engine/run',
129    ...(home ? [`${home}/.local/share/voicevox/vv-engine/run`] : []),
130  ]
131}
132
133// セッションから切り離して起動する($.process.spawn の子はモジュールと一緒に終わる)。
134// Windows はログオン時のタスクと同じ start-engine.ps1(起動を待って終了コードで答える)。それ以外は nohup で切り離し、呼ぶ側が応答を待つ
135export function startArgv(os: Os, pluginRoot: string, exe: string): string[] {
136  if (os === 'windows') {
137    return [
138      'powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', win(`${pluginRoot}/scripts/windows/start-engine.ps1`),
139      '-EnginePath', win(exe), '-Quiet',
140    ]
141  }
142  return ['sh', '-c', 'cd "$(dirname "$1")" && nohup "$1" >/dev/null 2>&1 &', 'sh', exe]
143}
144