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…

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文に要約して読み上げます。
音声:VOICEVOX:四国めたん、VOICEVOX:ずんだもん(クレジット表記)
| 項目 | 内容 |
|---|---|
| OS | Windows 10 / 11、macOS、Linux。macOS・Linux は試験的な対応です(自動テストのみで、実機では確かめていません) |
| 音声合成 | VOICEVOX。同梱の ENGINE を使います。GUI は起動しません |
| Claude Code | CLI(ターミナル)だけ。2.1.292 で動作を確認。デスクトップアプリの Code タブや VS Code 拡張では鳴りません(mod から外部コマンドを起動できるのが CLI だけのため) |
| 再生 | Windows は標準の機能(SoundPlayer)。macOS は afplay。Linux は pw-play・paplay・aplay のどれか |
| curl | 合成に使います。Windows 10 以降と macOS には標準で入っています |
| 権限 | 管理者権限は不要です。すべてユーザー領域に収まります |
/plugin install voice-notify --marketplace nextscape/ns-mods
| OS | 方法 |
|---|---|
| Windows | winget 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 など)。
/voice-notify setup
| # | 内容 |
|---|---|
| 1 | ホーム(~/.claude/voice-notify/)の用意。config.json が無ければ既定をコピー。0.2.0 の残りを消す |
| 2 | VOICEVOX の確認。見つからなければ、OS ごとの入れ方を案内して止まります |
| 3 | VOICEVOX ENGINE の起動 |
| 4 | 定型フレーズの生成(裏で実行。初回は数分。進み具合は /voice-notify doctor で確認) |
| 5 | ログオン時に ENGINE を起動する登録(Windows はタスク、macOS は launchd、Linux は systemd のユーザーサービス。ENGINE の場所が分からない(Docker など)ときは登録しない)。ミュート切替のホットキーの登録(Windows だけ。既定 Ctrl+Alt+M) |
音声通知は、setup を実行したセッションからすぐ有効です。
/plugin update voice-notify で 0.3.0 にし、新しいセッションを開く。/voice-notify setup を実行する(0.2.0 の状態ファイルを消し、Windows のタスクとホットキーを新しいスクリプトに向け直す)。/voice-notify doctor で確かめる。定型フレーズの作り直しを案内されたら /voice-notify setup force(0.2.0 のフレーズには、鳴らし始めが欠けないための無音が入っていないため)。コマンドの名前が変わりました(mod のコマンド名に : を使えないため)。
| 0.2.0 | 0.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 エラー・タスク完了・利用枠の自動再開など | 定型フレーズ |
subagent.debounceSeconds)。/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/ に残し、同じ文はもう一度合成しません。scripts/windows/play.ps1・scripts/posix/play.sh)が行い、別のセッションの音と重ならないよう1つずつ鳴らします。CapabilityAccessManager\ConsentStore\microphone)で行います。会議アプリに限らず、マイクを使っているアプリがあれば黙ります。voice-notify ミュート切替.lnk)のショートカットキーです。常駐プロセスはありません。押すたびに PowerShell が起動するので、切り替わるまで1秒ほどかかります。ほかのアプリと同じキーだと、どちらかが効きません。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.plist | remove で消える |
| Linux | ~/.config/systemd/user/voice-notify-engine.service | remove で消える |
/voice-notify remove
/plugin uninstall voice-notify
remove は、ログオン時の起動とホットキーを外します。起動中の VOICEVOX ENGINE は止めません(次のログオンからは起動しません)。Windows ではタスクマネージャーで run.exe を終了してください。~/.claude/voice-notify/ を削除してください。winget uninstall --id HiroshibaKazuyuki.VOICEVOX.CPU -e で外します。/voice-notify doctor を実行し、NG・注意の行に従う。~/.claude/voice-notify/notify.log を見る。何が起きたか、なぜ黙ったか(「マイク使用中 (アプリ名)」など)、何を読んだかが残ります。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 を読み込むとエンジンが置く)
$ は、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)
hooks/register.ts 917 lines1import 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}
917hooks/decide.ts 182 lines1// 何を読むかの判定。$ に触れない純粋関数だけを置く(単体テストの対象)。
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}
182hooks/gate.ts 25 lines1// 同じセッションの中で、非同期の処理を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}
25hooks/os.ts 54 lines1// 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}
54hooks/phrases.ts 47 lines1// 定型フレーズの一覧と、作り直しが要るかの判定(純粋関数)。生成の 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}
47hooks/player.ts 23 lines1// 再生コマンドの形。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}
23hooks/autostart.ts 50 lines1// ログオン時の 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
50hooks/setup.ts 133 lines1// /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}
133hooks/store.ts 26 lines1// ホーム(~/.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}
26hooks/text.ts 86 lines1// 読み上げ用の整形。$ に触れない純粋関数だけを置く(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}
86hooks/voicevox.ts 144 lines1// 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