pi-voice 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2026 Yuku Kotani
2
+
3
+ Permission is hereby granted, free of
4
+ charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # pi-voice
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ bun install
7
+ bun run build
8
+ bun link # `pi-voice` コマンドをグローバルに登録
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ pi-voice は **daemon 型**のアプリケーションです。Docker と同じように、`start` でバックグラウンドに常駐し、CLI で操作します。起動時にウィンドウは表示されません。
14
+
15
+ `status` / `stop` / `show` は Electron を起動せず、Unix socket 経由で daemon と通信して即応します。
16
+
17
+ ```bash
18
+ # daemon をバックグラウンドで起動(ウィンドウは表示されない)
19
+ pi-voice start
20
+
21
+ # daemon の状態を確認(state・PID・uptime を表示)
22
+ pi-voice status
23
+
24
+ # ウィンドウを表示
25
+ pi-voice show
26
+
27
+ # daemon を停止(Fn キーも無効化)
28
+ pi-voice stop
29
+ ```
30
+
31
+ - `start` は引数なしのデフォルトコマンドです。既に起動中ならエラーで終了します。
32
+ - `start` は事前に `bun run build` が必要です(`out/main/index.js` がなければエラー)。
33
+ - ウィンドウを閉じても daemon はバックグラウンドで動作し続けます。完全に停止するには `stop` か Cmd+Q を使ってください。
34
+ - 実行状態は `~/.pi-voice/runtime-state.json`、制御 socket は `~/.pi-voice/daemon.sock` に配置されます。
35
+
36
+ ### 開発モード
37
+
38
+ ```bash
39
+ bun run dev:electron
40
+ ```
41
+
42
+ HMR 付きの Vite dev server で renderer を配信しつつ Electron を起動します(開発時はウィンドウを閉じると終了します)。
43
+
44
+ CLI 単体で実行する場合:
45
+
46
+ ```bash
47
+ bun run dev:cli
48
+ ```
49
+
50
+ ## Build
51
+
52
+ ```bash
53
+ bun run build
54
+ ```
55
+
56
+ `out/` にプロダクションビルドを出力します。
57
+
58
+ ## Preview
59
+
60
+ ```bash
61
+ bun run preview
62
+ ```
63
+
64
+ ビルド済みの成果物で Electron を起動して動作確認します。
65
+
66
+ ## Distribution
67
+
68
+ ```bash
69
+ bun run dist
70
+ ```
71
+
72
+ `bun run build` + electron-builder で macOS 向けの dmg/zip を `release/` に生成します。
73
+
74
+ パッケージングせずディレクトリ出力のみ(テスト用):
75
+
76
+ ```bash
77
+ bun run dist:dir
78
+ ```
package/bin/pi-voice ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../out/cli/cli.js";
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.cs.allow-jit</key>
6
+ <true/>
7
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8
+ <true/>
9
+ <key>com.apple.security.device.audio-input</key>
10
+ <true/>
11
+ <key>com.apple.security.automation.apple-events</key>
12
+ <true/>
13
+ </dict>
14
+ </plist>
package/out/cli/cli.js ADDED
@@ -0,0 +1,265 @@
1
+ // src/cli.ts
2
+ import { resolve, join as join2 } from "node:path";
3
+ import { existsSync as existsSync2 } from "node:fs";
4
+ import { spawn } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+
7
+ // src/services/runtime-state.ts
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import {
11
+ readFileSync,
12
+ writeFileSync,
13
+ unlinkSync,
14
+ existsSync,
15
+ mkdirSync
16
+ } from "node:fs";
17
+ var STATE_DIR = join(homedir(), ".pi-voice");
18
+ var STATE_FILE = join(STATE_DIR, "runtime-state.json");
19
+ var SOCKET_FILE = join(STATE_DIR, "daemon.sock");
20
+ function getSocketPath() {
21
+ ensureDir();
22
+ return SOCKET_FILE;
23
+ }
24
+ function ensureDir() {
25
+ if (!existsSync(STATE_DIR)) {
26
+ mkdirSync(STATE_DIR, { recursive: true });
27
+ }
28
+ }
29
+ function isProcessAlive(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ function readRuntimeState() {
38
+ if (!existsSync(STATE_FILE))
39
+ return null;
40
+ try {
41
+ const raw = readFileSync(STATE_FILE, "utf-8");
42
+ const state = JSON.parse(raw);
43
+ if (!isProcessAlive(state.pid)) {
44
+ removeRuntimeState();
45
+ return null;
46
+ }
47
+ return state;
48
+ } catch {
49
+ removeRuntimeState();
50
+ return null;
51
+ }
52
+ }
53
+ function removeRuntimeState() {
54
+ try {
55
+ if (existsSync(STATE_FILE))
56
+ unlinkSync(STATE_FILE);
57
+ } catch {}
58
+ }
59
+
60
+ // src/services/daemon-ipc.ts
61
+ import { createServer, createConnection } from "node:net";
62
+ function sendCommand(command, socketPath) {
63
+ const target = socketPath ?? getSocketPath();
64
+ return new Promise((resolve, reject) => {
65
+ const conn = createConnection(target);
66
+ let buffer = "";
67
+ const timeout = setTimeout(() => {
68
+ conn.destroy();
69
+ reject(new Error("Daemon did not respond within 5 seconds"));
70
+ }, 5000);
71
+ conn.on("connect", () => {
72
+ conn.write(JSON.stringify({ command }) + `
73
+ `);
74
+ });
75
+ conn.on("data", (data) => {
76
+ buffer += data.toString();
77
+ const idx = buffer.indexOf(`
78
+ `);
79
+ if (idx !== -1) {
80
+ clearTimeout(timeout);
81
+ const line = buffer.slice(0, idx);
82
+ conn.end();
83
+ try {
84
+ resolve(JSON.parse(line));
85
+ } catch {
86
+ reject(new Error(`Invalid response from daemon: ${line}`));
87
+ }
88
+ }
89
+ });
90
+ conn.on("error", (err) => {
91
+ clearTimeout(timeout);
92
+ reject(err);
93
+ });
94
+ });
95
+ }
96
+
97
+ // src/cli.ts
98
+ function usage() {
99
+ console.log(`Usage: pi-voice <command>
100
+
101
+ Commands:
102
+ start Start the pi-voice daemon in the background (default)
103
+ status Show daemon status (state, PID, uptime)
104
+ stop Stop the running daemon
105
+ show Bring the window to front`);
106
+ process.exit(0);
107
+ }
108
+ function parseCommand() {
109
+ const arg = process.argv[2];
110
+ if (!arg || arg === "start")
111
+ return "start";
112
+ if (arg === "status")
113
+ return "status";
114
+ if (arg === "stop")
115
+ return "stop";
116
+ if (arg === "show")
117
+ return "show";
118
+ if (arg === "--help" || arg === "-h")
119
+ usage();
120
+ console.error(`Unknown command: ${arg}`);
121
+ usage();
122
+ }
123
+ function findPackageRoot(dir) {
124
+ let current = resolve(dir);
125
+ while (true) {
126
+ if (existsSync2(join2(current, "package.json"))) {
127
+ return current;
128
+ }
129
+ const parent = resolve(current, "..");
130
+ if (parent === current) {
131
+ console.error("Could not find package root (no package.json found).");
132
+ process.exit(1);
133
+ }
134
+ current = parent;
135
+ }
136
+ }
137
+ function isDaemonRunning() {
138
+ return readRuntimeState() !== null;
139
+ }
140
+ function dieNotRunning() {
141
+ console.error("pi-voice daemon is not running. Use 'pi-voice start' first.");
142
+ process.exit(1);
143
+ }
144
+ async function cmdStatus() {
145
+ const state = readRuntimeState();
146
+ if (!state) {
147
+ console.log("not running");
148
+ return;
149
+ }
150
+ try {
151
+ const res = await sendCommand("status");
152
+ if (res.ok) {
153
+ const uptime = typeof res.uptime === "number" ? Math.floor(res.uptime) : "?";
154
+ console.log(`running: ${res.cwd} (pid: ${res.pid}, state: ${res.state}, uptime: ${uptime}s)`);
155
+ } else {
156
+ console.log(`running: ${state.cwd} (pid: ${state.pid}, since: ${state.startedAt})`);
157
+ console.log(` (daemon responded with error: ${res.error})`);
158
+ }
159
+ } catch {
160
+ removeRuntimeState();
161
+ console.log("not running (stale state cleaned up)");
162
+ }
163
+ }
164
+ async function cmdStop() {
165
+ if (!isDaemonRunning()) {
166
+ console.log("pi-voice daemon is not running.");
167
+ process.exit(1);
168
+ }
169
+ try {
170
+ const res = await sendCommand("stop");
171
+ if (res.ok) {
172
+ console.log("Stopping pi-voice daemon...");
173
+ } else {
174
+ console.error(`Failed to stop daemon: ${res.error}`);
175
+ process.exit(1);
176
+ }
177
+ } catch {
178
+ const state = readRuntimeState();
179
+ if (state) {
180
+ try {
181
+ process.kill(state.pid, "SIGTERM");
182
+ console.log(`Stopping pi-voice daemon (pid: ${state.pid})...`);
183
+ } catch {
184
+ removeRuntimeState();
185
+ console.log("pi-voice daemon is not running (stale state cleaned up).");
186
+ process.exit(1);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ async function cmdShow() {
192
+ if (!isDaemonRunning())
193
+ dieNotRunning();
194
+ try {
195
+ const res = await sendCommand("show");
196
+ if (res.ok) {
197
+ console.log("Showing pi-voice window...");
198
+ } else {
199
+ console.error(`Failed to show window: ${res.error}`);
200
+ process.exit(1);
201
+ }
202
+ } catch {
203
+ const state = readRuntimeState();
204
+ if (state) {
205
+ try {
206
+ process.kill(state.pid, "SIGUSR1");
207
+ console.log("Showing pi-voice window...");
208
+ } catch {
209
+ removeRuntimeState();
210
+ console.error("pi-voice daemon is not running (stale state cleaned up).");
211
+ process.exit(1);
212
+ }
213
+ } else {
214
+ dieNotRunning();
215
+ }
216
+ }
217
+ }
218
+ async function cmdStart() {
219
+ if (isDaemonRunning()) {
220
+ const state = readRuntimeState();
221
+ console.error(`pi-voice daemon is already running in ${state.cwd} (pid: ${state.pid}).`);
222
+ process.exit(1);
223
+ }
224
+ const cwd = process.cwd();
225
+ const projectRoot = findPackageRoot(import.meta.dirname);
226
+ let electronBin;
227
+ try {
228
+ const _require = createRequire(import.meta.url);
229
+ electronBin = _require("electron");
230
+ } catch {
231
+ console.error("Could not find electron binary. Is 'electron' installed?");
232
+ process.exit(1);
233
+ }
234
+ const mainEntry = join2(projectRoot, "out", "main", "index.js");
235
+ if (!existsSync2(mainEntry)) {
236
+ console.error("Electron main entry not found. Run 'bun run build' first.");
237
+ process.exit(1);
238
+ }
239
+ const child = spawn(electronBin, [mainEntry], {
240
+ cwd,
241
+ env: {
242
+ ...process.env,
243
+ PI_VOICE_CWD: cwd
244
+ },
245
+ detached: true,
246
+ stdio: "ignore"
247
+ });
248
+ child.unref();
249
+ console.log(`pi-voice daemon started (pid: ${child.pid}, cwd: ${cwd})`);
250
+ }
251
+ var command = parseCommand();
252
+ switch (command) {
253
+ case "start":
254
+ await cmdStart();
255
+ break;
256
+ case "status":
257
+ await cmdStatus();
258
+ break;
259
+ case "stop":
260
+ await cmdStop();
261
+ break;
262
+ case "show":
263
+ await cmdShow();
264
+ break;
265
+ }
@@ -0,0 +1,528 @@
1
+ import { app, session as session$1, BrowserWindow, ipcMain } from "electron";
2
+ import { fileURLToPath } from "node:url";
3
+ import iohook from "iohook-macos";
4
+ import { GoogleGenAI } from "@google/genai";
5
+ import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
9
+ import { createServer } from "node:net";
10
+ class FnHook {
11
+ fnDown = false;
12
+ callbacks;
13
+ started = false;
14
+ constructor(callbacks) {
15
+ this.callbacks = callbacks;
16
+ }
17
+ start() {
18
+ if (this.started) return;
19
+ const perms = iohook.checkAccessibilityPermissions();
20
+ if (!perms.hasPermissions) {
21
+ console.log(
22
+ "[FnHook] Accessibility permissions not granted. Requesting..."
23
+ );
24
+ iohook.requestAccessibilityPermissions();
25
+ throw new Error(
26
+ "Accessibility permissions required. Please grant access in System Preferences > Privacy & Security > Accessibility, then restart the app."
27
+ );
28
+ }
29
+ iohook.setEventFilter({
30
+ filterByEventType: true,
31
+ allowKeyboard: true,
32
+ allowMouse: false,
33
+ allowScroll: false
34
+ });
35
+ iohook.enablePerformanceMode();
36
+ iohook.on("flagsChanged", (event) => {
37
+ const fnNow = event.modifiers.fn;
38
+ if (fnNow && !this.fnDown) {
39
+ this.fnDown = true;
40
+ this.callbacks.onFnDown();
41
+ } else if (!fnNow && this.fnDown) {
42
+ this.fnDown = false;
43
+ this.callbacks.onFnUp();
44
+ }
45
+ });
46
+ iohook.startMonitoring();
47
+ this.started = true;
48
+ console.log("[FnHook] Started monitoring Fn key");
49
+ }
50
+ stop() {
51
+ if (!this.started) return;
52
+ iohook.stopMonitoring();
53
+ this.started = false;
54
+ this.fnDown = false;
55
+ console.log("[FnHook] Stopped monitoring");
56
+ }
57
+ get isFnDown() {
58
+ return this.fnDown;
59
+ }
60
+ }
61
+ let ai$1 = null;
62
+ function getClient$1() {
63
+ if (ai$1) return ai$1;
64
+ const project = process.env.GOOGLE_CLOUD_PROJECT;
65
+ const location = process.env.GOOGLE_CLOUD_LOCATION ?? "us-central1";
66
+ if (!project) {
67
+ throw new Error("GOOGLE_CLOUD_PROJECT environment variable is required");
68
+ }
69
+ ai$1 = new GoogleGenAI({ vertexai: true, project, location });
70
+ return ai$1;
71
+ }
72
+ async function transcribe(audioBuffer) {
73
+ const client = getClient$1();
74
+ const base64Audio = audioBuffer.toString("base64");
75
+ const response = await client.models.generateContent({
76
+ model: "gemini-2.5-flash",
77
+ contents: [
78
+ {
79
+ role: "user",
80
+ parts: [
81
+ {
82
+ inlineData: {
83
+ mimeType: "audio/webm",
84
+ data: base64Audio
85
+ }
86
+ },
87
+ {
88
+ text: "Transcribe this audio exactly as spoken. Output only the transcription, nothing else. If the audio is in Japanese, output in Japanese. If the audio is silent or empty, output an empty string."
89
+ }
90
+ ]
91
+ }
92
+ ]
93
+ });
94
+ const text = response.text?.trim() ?? "";
95
+ console.log(`[STT] Transcribed: "${text}"`);
96
+ return text;
97
+ }
98
+ let ai = null;
99
+ function getClient() {
100
+ if (ai) return ai;
101
+ const project = process.env.GOOGLE_CLOUD_PROJECT;
102
+ const location = process.env.GOOGLE_CLOUD_LOCATION ?? "us-central1";
103
+ if (!project) {
104
+ throw new Error("GOOGLE_CLOUD_PROJECT environment variable is required");
105
+ }
106
+ ai = new GoogleGenAI({ vertexai: true, project, location });
107
+ return ai;
108
+ }
109
+ const TTS_SAMPLE_RATE = 24e3;
110
+ const TTS_CHANNELS = 1;
111
+ const TTS_BITS_PER_SAMPLE = 16;
112
+ async function* synthesizeStream(text) {
113
+ const client = getClient();
114
+ const response = await client.models.generateContentStream({
115
+ model: "gemini-2.5-flash-preview-tts",
116
+ contents: [
117
+ {
118
+ role: "user",
119
+ parts: [{ text }]
120
+ }
121
+ ],
122
+ config: {
123
+ responseModalities: ["AUDIO"],
124
+ speechConfig: {
125
+ voiceConfig: {
126
+ prebuiltVoiceConfig: {
127
+ voiceName: "Aoede"
128
+ }
129
+ }
130
+ }
131
+ }
132
+ });
133
+ let totalBytes = 0;
134
+ let leftover = null;
135
+ for await (const chunk of response) {
136
+ const candidate = chunk.candidates?.[0];
137
+ const parts = candidate?.content?.parts;
138
+ if (!parts) continue;
139
+ for (const part of parts) {
140
+ if (!part.inlineData?.data) continue;
141
+ let pcm = Buffer.from(part.inlineData.data, "base64");
142
+ if (leftover) {
143
+ pcm = Buffer.concat([leftover, pcm]);
144
+ leftover = null;
145
+ }
146
+ const bytesPerSample = TTS_BITS_PER_SAMPLE / 8;
147
+ const remainder = pcm.length % bytesPerSample;
148
+ if (remainder !== 0) {
149
+ leftover = pcm.subarray(pcm.length - remainder);
150
+ pcm = pcm.subarray(0, pcm.length - remainder);
151
+ }
152
+ if (pcm.length > 0) {
153
+ totalBytes += pcm.length;
154
+ yield pcm;
155
+ }
156
+ }
157
+ }
158
+ if (leftover && leftover.length > 0) {
159
+ totalBytes += leftover.length;
160
+ yield leftover;
161
+ }
162
+ console.log(
163
+ `[TTS] Streamed ${totalBytes} bytes of PCM audio for "${text.substring(0, 50)}..."`
164
+ );
165
+ }
166
+ let session = null;
167
+ let sessionCwd = process.cwd();
168
+ function setSessionCwd(cwd) {
169
+ sessionCwd = cwd;
170
+ }
171
+ async function getOrCreateSession() {
172
+ if (session) return session;
173
+ console.log(`[PiSession] Creating new agent session (cwd: ${sessionCwd})...`);
174
+ const result = await createAgentSession({
175
+ cwd: sessionCwd,
176
+ sessionManager: SessionManager.inMemory()
177
+ });
178
+ session = result.session;
179
+ console.log("[PiSession] Session created");
180
+ return session;
181
+ }
182
+ async function prompt(text, options) {
183
+ const s = await getOrCreateSession();
184
+ const unsubscribe = s.subscribe((event) => {
185
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_end") {
186
+ const content = event.assistantMessageEvent.content.trim();
187
+ if (content.length > 0) {
188
+ console.log(`[PiSession] Response: ${content}`);
189
+ options?.onTextEnd?.(content);
190
+ }
191
+ }
192
+ });
193
+ try {
194
+ await s.prompt(text);
195
+ } finally {
196
+ unsubscribe();
197
+ }
198
+ }
199
+ function dispose() {
200
+ if (session) {
201
+ session.dispose();
202
+ session = null;
203
+ console.log("[PiSession] Session disposed");
204
+ }
205
+ }
206
+ const IPC = {
207
+ // main -> renderer
208
+ START_RECORDING: "start-recording",
209
+ STOP_RECORDING: "stop-recording",
210
+ PLAY_AUDIO_STREAM_START: "play-audio-stream-start",
211
+ PLAY_AUDIO_STREAM_CHUNK: "play-audio-stream-chunk",
212
+ PLAY_AUDIO_STREAM_END: "play-audio-stream-end",
213
+ STATE_CHANGED: "state-changed",
214
+ STATUS_MESSAGE: "status-message",
215
+ // renderer -> main
216
+ RECORDING_DATA: "recording-data",
217
+ RECORDING_ERROR: "recording-error",
218
+ PLAYBACK_DONE: "playback-done"
219
+ };
220
+ const STATE_DIR = join(homedir(), ".pi-voice");
221
+ const STATE_FILE = join(STATE_DIR, "runtime-state.json");
222
+ const SOCKET_FILE = join(STATE_DIR, "daemon.sock");
223
+ function getSocketPath() {
224
+ ensureDir();
225
+ return SOCKET_FILE;
226
+ }
227
+ function ensureDir() {
228
+ if (!existsSync(STATE_DIR)) {
229
+ mkdirSync(STATE_DIR, { recursive: true });
230
+ }
231
+ }
232
+ function saveRuntimeState(cwd) {
233
+ ensureDir();
234
+ const state = {
235
+ pid: process.pid,
236
+ cwd,
237
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
238
+ };
239
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
240
+ }
241
+ function removeRuntimeState() {
242
+ try {
243
+ if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE);
244
+ } catch {
245
+ }
246
+ }
247
+ let server = null;
248
+ function startDaemonServer(handler) {
249
+ const socketPath = getSocketPath();
250
+ if (existsSync(socketPath)) {
251
+ try {
252
+ unlinkSync(socketPath);
253
+ } catch {
254
+ }
255
+ }
256
+ server = createServer((conn) => {
257
+ let buffer = "";
258
+ conn.on("data", async (data) => {
259
+ buffer += data.toString();
260
+ const lines = buffer.split("\n");
261
+ buffer = lines.pop();
262
+ for (const line of lines) {
263
+ if (!line.trim()) continue;
264
+ try {
265
+ const req = JSON.parse(line);
266
+ const res = await handler(req.command);
267
+ conn.write(JSON.stringify(res) + "\n");
268
+ } catch (err) {
269
+ const msg = err instanceof Error ? err.message : String(err);
270
+ conn.write(JSON.stringify({ ok: false, error: msg }) + "\n");
271
+ }
272
+ }
273
+ });
274
+ conn.on("error", () => {
275
+ });
276
+ });
277
+ server.listen(socketPath);
278
+ console.log(`[DaemonIPC] Listening on ${socketPath}`);
279
+ return socketPath;
280
+ }
281
+ function stopDaemonServer() {
282
+ if (server) {
283
+ server.close();
284
+ server = null;
285
+ }
286
+ const socketPath = getSocketPath();
287
+ if (existsSync(socketPath)) {
288
+ try {
289
+ unlinkSync(socketPath);
290
+ } catch {
291
+ }
292
+ }
293
+ console.log("[DaemonIPC] Server stopped");
294
+ }
295
+ const workingCwd = process.env["PI_VOICE_CWD"] || process.cwd();
296
+ let mainWindow = null;
297
+ let fnHook = null;
298
+ let currentState = "idle";
299
+ let forceQuit = false;
300
+ setSessionCwd(workingCwd);
301
+ function setState(state, message) {
302
+ currentState = state;
303
+ console.log(`[Main] State: ${state}${message ? ` - ${message}` : ""}`);
304
+ mainWindow?.webContents.send(IPC.STATE_CHANGED, state);
305
+ if (message) {
306
+ mainWindow?.webContents.send(IPC.STATUS_MESSAGE, message);
307
+ }
308
+ }
309
+ function createWindow() {
310
+ mainWindow = new BrowserWindow({
311
+ width: 400,
312
+ height: 300,
313
+ resizable: true,
314
+ alwaysOnTop: true,
315
+ titleBarStyle: "hiddenInset",
316
+ // Daemon-first: window starts hidden
317
+ show: false,
318
+ webPreferences: {
319
+ preload: fileURLToPath(
320
+ new URL("../preload/index.cjs", import.meta.url)
321
+ ),
322
+ contextIsolation: true,
323
+ nodeIntegration: false
324
+ }
325
+ });
326
+ if (!app.isPackaged && process.env["ELECTRON_RENDERER_URL"]) {
327
+ mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
328
+ } else {
329
+ mainWindow.loadFile(
330
+ fileURLToPath(
331
+ new URL("../renderer/index.html", import.meta.url)
332
+ )
333
+ );
334
+ }
335
+ mainWindow.on("close", (e) => {
336
+ if (!forceQuit) {
337
+ e.preventDefault();
338
+ mainWindow?.hide();
339
+ }
340
+ });
341
+ mainWindow.on("closed", () => {
342
+ mainWindow = null;
343
+ });
344
+ }
345
+ function showWindow() {
346
+ if (mainWindow) {
347
+ mainWindow.show();
348
+ mainWindow.focus();
349
+ } else {
350
+ createWindow();
351
+ mainWindow.once("ready-to-show", () => {
352
+ mainWindow.show();
353
+ mainWindow.focus();
354
+ });
355
+ }
356
+ }
357
+ function setupIpcHandlers() {
358
+ ipcMain.on(IPC.RECORDING_DATA, async (_event, data) => {
359
+ if (currentState !== "recording") return;
360
+ const audioBuffer = Buffer.from(data);
361
+ if (audioBuffer.length < 1e3) {
362
+ console.log("[Main] Recording too short, ignoring");
363
+ setState("idle", "Recording too short");
364
+ return;
365
+ }
366
+ try {
367
+ setState("transcribing", "Transcribing...");
368
+ const text = await transcribe(audioBuffer);
369
+ if (!text) {
370
+ setState("idle", "No speech detected");
371
+ return;
372
+ }
373
+ setState("thinking", `Sent: "${text}"`);
374
+ let streamStarted = false;
375
+ let ttsChain = Promise.resolve();
376
+ await prompt(text, {
377
+ onTextEnd: (segment) => {
378
+ if (!streamStarted) {
379
+ streamStarted = true;
380
+ setState("speaking", "Generating speech...");
381
+ mainWindow?.webContents.send(IPC.PLAY_AUDIO_STREAM_START, {
382
+ sampleRate: TTS_SAMPLE_RATE,
383
+ channels: TTS_CHANNELS,
384
+ bitsPerSample: TTS_BITS_PER_SAMPLE
385
+ });
386
+ }
387
+ ttsChain = ttsChain.then(async () => {
388
+ for await (const pcmChunk of synthesizeStream(segment)) {
389
+ mainWindow?.webContents.send(
390
+ IPC.PLAY_AUDIO_STREAM_CHUNK,
391
+ pcmChunk.buffer.slice(
392
+ pcmChunk.byteOffset,
393
+ pcmChunk.byteOffset + pcmChunk.byteLength
394
+ )
395
+ );
396
+ }
397
+ });
398
+ }
399
+ });
400
+ await ttsChain;
401
+ if (streamStarted) {
402
+ mainWindow?.webContents.send(IPC.PLAY_AUDIO_STREAM_END);
403
+ } else {
404
+ setState("idle", "No response from pi");
405
+ }
406
+ } catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ console.error("[Main] Pipeline error:", msg);
409
+ setState("error", msg);
410
+ setTimeout(() => {
411
+ if (currentState === "error") setState("idle");
412
+ }, 3e3);
413
+ }
414
+ });
415
+ ipcMain.on(IPC.RECORDING_ERROR, (_event, error) => {
416
+ console.error("[Main] Recording error:", error);
417
+ setState("error", error);
418
+ setTimeout(() => {
419
+ if (currentState === "error") setState("idle");
420
+ }, 3e3);
421
+ });
422
+ ipcMain.on(IPC.PLAYBACK_DONE, () => {
423
+ if (currentState === "speaking") {
424
+ setState("idle");
425
+ }
426
+ });
427
+ }
428
+ function setupFnHook() {
429
+ fnHook = new FnHook({
430
+ onFnDown: () => {
431
+ if (currentState !== "idle") {
432
+ console.log(
433
+ `[Main] Fn pressed but state is ${currentState}, ignoring`
434
+ );
435
+ return;
436
+ }
437
+ setState("recording", "Recording...");
438
+ mainWindow?.webContents.send(IPC.START_RECORDING);
439
+ },
440
+ onFnUp: () => {
441
+ if (currentState !== "recording") return;
442
+ mainWindow?.webContents.send(IPC.STOP_RECORDING);
443
+ }
444
+ });
445
+ try {
446
+ fnHook.start();
447
+ } catch (err) {
448
+ const msg = err instanceof Error ? err.message : String(err);
449
+ console.error("[Main] FnHook error:", msg);
450
+ setState("error", msg);
451
+ }
452
+ }
453
+ function setupCsp() {
454
+ const isDev = !app.isPackaged && !!process.env["ELECTRON_RENDERER_URL"];
455
+ session$1.defaultSession.webRequest.onHeadersReceived(
456
+ (details, callback) => {
457
+ const csp = isDev ? "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*; media-src 'self' blob:" : "default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:";
458
+ callback({
459
+ responseHeaders: {
460
+ ...details.responseHeaders,
461
+ "Content-Security-Policy": [csp]
462
+ }
463
+ });
464
+ }
465
+ );
466
+ }
467
+ function handleDaemonCommand(command) {
468
+ switch (command) {
469
+ case "status":
470
+ return {
471
+ ok: true,
472
+ state: currentState,
473
+ cwd: workingCwd,
474
+ pid: process.pid,
475
+ uptime: process.uptime()
476
+ };
477
+ case "show":
478
+ showWindow();
479
+ return { ok: true };
480
+ case "stop":
481
+ setImmediate(() => {
482
+ forceQuit = true;
483
+ app.quit();
484
+ });
485
+ return { ok: true };
486
+ default:
487
+ return { ok: false, error: `Unknown command: ${command}` };
488
+ }
489
+ }
490
+ function gracefulShutdown() {
491
+ console.log("[Main] Shutting down...");
492
+ fnHook?.stop();
493
+ dispose();
494
+ stopDaemonServer();
495
+ removeRuntimeState();
496
+ }
497
+ process.on("SIGTERM", () => {
498
+ gracefulShutdown();
499
+ forceQuit = true;
500
+ app.quit();
501
+ });
502
+ const gotLock = app.requestSingleInstanceLock();
503
+ if (!gotLock) {
504
+ console.log("[Main] Another instance is already running. Exiting.");
505
+ app.quit();
506
+ } else {
507
+ app.on("second-instance", () => {
508
+ showWindow();
509
+ });
510
+ }
511
+ app.whenReady().then(() => {
512
+ setupCsp();
513
+ createWindow();
514
+ setupIpcHandlers();
515
+ setupFnHook();
516
+ startDaemonServer(handleDaemonCommand);
517
+ saveRuntimeState(workingCwd);
518
+ console.log(`[Main] pi-voice daemon started (cwd: ${workingCwd})`);
519
+ });
520
+ app.on("window-all-closed", () => {
521
+ });
522
+ app.on("activate", () => {
523
+ showWindow();
524
+ });
525
+ app.on("before-quit", () => {
526
+ forceQuit = true;
527
+ gracefulShutdown();
528
+ });
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ const electron = require("electron");
3
+ const IPC = {
4
+ // main -> renderer
5
+ START_RECORDING: "start-recording",
6
+ STOP_RECORDING: "stop-recording",
7
+ PLAY_AUDIO_STREAM_START: "play-audio-stream-start",
8
+ PLAY_AUDIO_STREAM_CHUNK: "play-audio-stream-chunk",
9
+ PLAY_AUDIO_STREAM_END: "play-audio-stream-end",
10
+ STATE_CHANGED: "state-changed",
11
+ STATUS_MESSAGE: "status-message",
12
+ // renderer -> main
13
+ RECORDING_DATA: "recording-data",
14
+ RECORDING_ERROR: "recording-error",
15
+ PLAYBACK_DONE: "playback-done"
16
+ };
17
+ const api = {
18
+ onStartRecording: (callback) => {
19
+ electron.ipcRenderer.on(IPC.START_RECORDING, () => callback());
20
+ },
21
+ onStopRecording: (callback) => {
22
+ electron.ipcRenderer.on(IPC.STOP_RECORDING, () => callback());
23
+ },
24
+ onPlayAudioStreamStart: (callback) => {
25
+ electron.ipcRenderer.on(
26
+ IPC.PLAY_AUDIO_STREAM_START,
27
+ (_event, meta) => callback(meta)
28
+ );
29
+ },
30
+ onPlayAudioStreamChunk: (callback) => {
31
+ electron.ipcRenderer.on(
32
+ IPC.PLAY_AUDIO_STREAM_CHUNK,
33
+ (_event, pcmData) => callback(pcmData)
34
+ );
35
+ },
36
+ onPlayAudioStreamEnd: (callback) => {
37
+ electron.ipcRenderer.on(IPC.PLAY_AUDIO_STREAM_END, () => callback());
38
+ },
39
+ onStateChanged: (callback) => {
40
+ electron.ipcRenderer.on(IPC.STATE_CHANGED, (_event, state) => callback(state));
41
+ },
42
+ onStatusMessage: (callback) => {
43
+ electron.ipcRenderer.on(IPC.STATUS_MESSAGE, (_event, message) => callback(message));
44
+ },
45
+ sendRecordingData: (data) => {
46
+ electron.ipcRenderer.send(IPC.RECORDING_DATA, data);
47
+ },
48
+ sendRecordingError: (error) => {
49
+ electron.ipcRenderer.send(IPC.RECORDING_ERROR, error);
50
+ },
51
+ sendPlaybackDone: () => {
52
+ electron.ipcRenderer.send(IPC.PLAYBACK_DONE);
53
+ }
54
+ };
55
+ electron.contextBridge.exposeInMainWorld("piVoice", api);
@@ -0,0 +1,162 @@
1
+ const toggleOnUrl = "" + new URL("toggle_on-D9c1Kpa8.wav", import.meta.url).href;
2
+ const toggleOffUrl = "" + new URL("toggle_off-DBeRrNFR.wav", import.meta.url).href;
3
+ document.getElementById("indicator");
4
+ const icon = document.getElementById("icon");
5
+ const stateLabel = document.getElementById("stateLabel");
6
+ const statusMessage = document.getElementById("statusMessage");
7
+ let mediaRecorder = null;
8
+ let audioChunks = [];
9
+ let audioContext = null;
10
+ function playSoundEffect(url) {
11
+ const audio = new Audio(url);
12
+ audio.play().catch((err) => {
13
+ console.error("Failed to play sound effect:", err);
14
+ });
15
+ }
16
+ const stateConfig = {
17
+ idle: { icon: "⏸", label: "IDLE", defaultMessage: "Hold Fn to speak" },
18
+ recording: {
19
+ icon: "🔴",
20
+ label: "RECORDING",
21
+ defaultMessage: "Listening..."
22
+ },
23
+ transcribing: {
24
+ icon: "🔄",
25
+ label: "TRANSCRIBING",
26
+ defaultMessage: "Converting speech to text..."
27
+ },
28
+ thinking: {
29
+ icon: "🧠",
30
+ label: "THINKING",
31
+ defaultMessage: "pi is thinking..."
32
+ },
33
+ speaking: {
34
+ icon: "🔊",
35
+ label: "SPEAKING",
36
+ defaultMessage: "Playing response..."
37
+ },
38
+ error: { icon: "⚠", label: "ERROR", defaultMessage: "An error occurred" }
39
+ };
40
+ window.piVoice.onStateChanged((state) => {
41
+ document.body.className = "";
42
+ document.body.classList.add(`state-${state}`);
43
+ const config = stateConfig[state];
44
+ if (config) {
45
+ icon.textContent = config.icon;
46
+ stateLabel.textContent = config.label;
47
+ statusMessage.textContent = config.defaultMessage;
48
+ }
49
+ });
50
+ window.piVoice.onStatusMessage((message) => {
51
+ statusMessage.textContent = message;
52
+ });
53
+ window.piVoice.onStartRecording(async () => {
54
+ playSoundEffect(toggleOnUrl);
55
+ try {
56
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
57
+ audioChunks = [];
58
+ mediaRecorder = new MediaRecorder(stream, {
59
+ mimeType: "audio/webm;codecs=opus"
60
+ });
61
+ mediaRecorder.ondataavailable = (event) => {
62
+ if (event.data.size > 0) {
63
+ audioChunks.push(event.data);
64
+ }
65
+ };
66
+ mediaRecorder.onstop = async () => {
67
+ stream.getTracks().forEach((track) => track.stop());
68
+ if (audioChunks.length === 0) {
69
+ window.piVoice.sendRecordingError("No audio data captured");
70
+ return;
71
+ }
72
+ const blob = new Blob(audioChunks, { type: "audio/webm" });
73
+ const arrayBuffer = await blob.arrayBuffer();
74
+ window.piVoice.sendRecordingData(arrayBuffer);
75
+ };
76
+ mediaRecorder.start(100);
77
+ } catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ window.piVoice.sendRecordingError(`Microphone access failed: ${msg}`);
80
+ }
81
+ });
82
+ window.piVoice.onStopRecording(() => {
83
+ playSoundEffect(toggleOffUrl);
84
+ if (mediaRecorder && mediaRecorder.state !== "inactive") {
85
+ mediaRecorder.stop();
86
+ }
87
+ });
88
+ let streamSampleRate = 24e3;
89
+ let streamChannels = 1;
90
+ let streamBitsPerSample = 16;
91
+ let streamNextPlayTime = 0;
92
+ let streamActiveSources = 0;
93
+ let streamEnded = false;
94
+ function stopStreamPlayback() {
95
+ streamActiveSources = 0;
96
+ streamEnded = false;
97
+ streamNextPlayTime = 0;
98
+ }
99
+ window.piVoice.onPlayAudioStreamStart((meta) => {
100
+ try {
101
+ if (!audioContext) {
102
+ audioContext = new AudioContext();
103
+ }
104
+ stopStreamPlayback();
105
+ streamSampleRate = meta.sampleRate;
106
+ streamChannels = meta.channels;
107
+ streamBitsPerSample = meta.bitsPerSample;
108
+ streamNextPlayTime = 0;
109
+ streamEnded = false;
110
+ } catch (err) {
111
+ console.error("Stream start error:", err);
112
+ }
113
+ });
114
+ window.piVoice.onPlayAudioStreamChunk((pcmData) => {
115
+ try {
116
+ if (!audioContext) {
117
+ audioContext = new AudioContext();
118
+ }
119
+ const raw = pcmData instanceof ArrayBuffer ? pcmData : new Uint8Array(pcmData).buffer;
120
+ const bytesPerSample = streamBitsPerSample / 8;
121
+ const sampleCount = raw.byteLength / bytesPerSample / streamChannels;
122
+ if (sampleCount <= 0) return;
123
+ const audioBuffer = audioContext.createBuffer(
124
+ streamChannels,
125
+ sampleCount,
126
+ streamSampleRate
127
+ );
128
+ const view = new DataView(raw);
129
+ for (let ch = 0; ch < streamChannels; ch++) {
130
+ const channelData = audioBuffer.getChannelData(ch);
131
+ for (let i = 0; i < sampleCount; i++) {
132
+ const byteOffset = (i * streamChannels + ch) * bytesPerSample;
133
+ const int16 = view.getInt16(byteOffset, true);
134
+ channelData[i] = int16 / 32768;
135
+ }
136
+ }
137
+ const source = audioContext.createBufferSource();
138
+ source.buffer = audioBuffer;
139
+ source.connect(audioContext.destination);
140
+ const now = audioContext.currentTime;
141
+ if (streamNextPlayTime < now) {
142
+ streamNextPlayTime = now;
143
+ }
144
+ source.start(streamNextPlayTime);
145
+ streamNextPlayTime += audioBuffer.duration;
146
+ streamActiveSources++;
147
+ source.onended = () => {
148
+ streamActiveSources--;
149
+ if (streamEnded && streamActiveSources <= 0) {
150
+ window.piVoice.sendPlaybackDone();
151
+ }
152
+ };
153
+ } catch (err) {
154
+ console.error("Stream chunk playback error:", err);
155
+ }
156
+ });
157
+ window.piVoice.onPlayAudioStreamEnd(() => {
158
+ streamEnded = true;
159
+ if (streamActiveSources <= 0) {
160
+ window.piVoice.sendPlaybackDone();
161
+ }
162
+ });
@@ -0,0 +1,147 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>pi-voice</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
16
+ background: #1a1a2e;
17
+ color: #e0e0e0;
18
+ display: flex;
19
+ flex-direction: column;
20
+ align-items: center;
21
+ justify-content: center;
22
+ height: 100vh;
23
+ user-select: none;
24
+ -webkit-app-region: drag;
25
+ overflow: hidden;
26
+ }
27
+
28
+ .container {
29
+ display: flex;
30
+ flex-direction: column;
31
+ align-items: center;
32
+ gap: 24px;
33
+ }
34
+
35
+ .indicator {
36
+ width: 120px;
37
+ height: 120px;
38
+ border-radius: 50%;
39
+ background: #2a2a4a;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ transition: all 0.3s ease;
44
+ position: relative;
45
+ }
46
+
47
+ .indicator::after {
48
+ content: '';
49
+ position: absolute;
50
+ width: 100%;
51
+ height: 100%;
52
+ border-radius: 50%;
53
+ border: 3px solid transparent;
54
+ transition: all 0.3s ease;
55
+ }
56
+
57
+ /* State styles */
58
+ .state-idle .indicator {
59
+ background: #2a2a4a;
60
+ }
61
+
62
+ .state-recording .indicator {
63
+ background: #e74c3c;
64
+ box-shadow: 0 0 30px rgba(231, 76, 60, 0.5);
65
+ animation: pulse 1s ease-in-out infinite;
66
+ }
67
+
68
+ .state-transcribing .indicator {
69
+ background: #f39c12;
70
+ animation: spin 1.5s linear infinite;
71
+ }
72
+
73
+ .state-thinking .indicator {
74
+ background: #3498db;
75
+ animation: think 2s ease-in-out infinite;
76
+ }
77
+
78
+ .state-speaking .indicator {
79
+ background: #2ecc71;
80
+ animation: speak 0.5s ease-in-out infinite alternate;
81
+ }
82
+
83
+ .state-error .indicator {
84
+ background: #c0392b;
85
+ }
86
+
87
+ @keyframes pulse {
88
+ 0%, 100% { transform: scale(1); }
89
+ 50% { transform: scale(1.08); }
90
+ }
91
+
92
+ @keyframes spin {
93
+ from { transform: rotate(0deg); }
94
+ to { transform: rotate(360deg); }
95
+ }
96
+
97
+ @keyframes think {
98
+ 0%, 100% { opacity: 1; }
99
+ 50% { opacity: 0.5; }
100
+ }
101
+
102
+ @keyframes speak {
103
+ from { transform: scale(1); }
104
+ to { transform: scale(1.05); }
105
+ }
106
+
107
+ .icon {
108
+ font-size: 48px;
109
+ line-height: 1;
110
+ }
111
+
112
+ .state-label {
113
+ font-size: 16px;
114
+ font-weight: 600;
115
+ text-transform: uppercase;
116
+ letter-spacing: 2px;
117
+ }
118
+
119
+ .status-message {
120
+ font-size: 13px;
121
+ color: #888;
122
+ max-width: 300px;
123
+ text-align: center;
124
+ word-break: break-word;
125
+ min-height: 20px;
126
+ }
127
+
128
+ .hint {
129
+ font-size: 11px;
130
+ color: #555;
131
+ position: fixed;
132
+ bottom: 16px;
133
+ }
134
+ </style>
135
+ <script type="module" crossorigin src="./assets/index-dks-nI81.js"></script>
136
+ </head>
137
+ <body>
138
+ <div class="container" id="app">
139
+ <div class="indicator" id="indicator">
140
+ <span class="icon" id="icon">&#x23F8;</span>
141
+ </div>
142
+ <div class="state-label" id="stateLabel">IDLE</div>
143
+ <div class="status-message" id="statusMessage">Hold Fn to speak</div>
144
+ </div>
145
+ <div class="hint">Fn key: push-to-talk</div>
146
+ </body>
147
+ </html>
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pi-voice",
3
+ "version": "0.1.0",
4
+ "description": "Voice interface for pi coding agent",
5
+ "author": "Yuku Kotani",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./out/main/index.js",
9
+ "bin": {
10
+ "pi-voice": "./bin/pi-voice"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "out/",
15
+ "build/"
16
+ ],
17
+ "scripts": {
18
+ "cli": "bun src/cli.ts",
19
+ "dev:electron": "electron-vite dev",
20
+ "dev:cli": "bun src/cli.ts",
21
+ "build": "bun run build:electron && bun run build:cli",
22
+ "build:electron": "electron-vite build",
23
+ "build:cli": "bun build src/cli.ts --outdir out/cli --target node --format esm --external electron",
24
+ "preview": "electron-vite preview",
25
+ "prepack": "bun run build",
26
+ "dist": "bun run build && electron-builder --mac --config",
27
+ "dist:dir": "bun run build && electron-builder --mac --dir --config",
28
+ "prepublish": "bun run build"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "latest",
32
+ "electron-vite": "^3.1.0",
33
+ "electron-builder": "^26.0.0"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5"
37
+ },
38
+ "dependencies": {
39
+ "@google/genai": "^1.40.0",
40
+ "@mariozechner/pi-coding-agent": "^0.52.7",
41
+ "electron": "^40.2.1",
42
+ "iohook-macos": "^1.2.1"
43
+ },
44
+ "trustedDependencies": [
45
+ "electron",
46
+ "iohook-macos",
47
+ "protobufjs"
48
+ ]
49
+ }