pi-meta-oauth 0.4.2 → 0.4.4

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.
@@ -73,22 +73,12 @@ private final class MicrophoneSession {
73
73
  }
74
74
  let tapBufferSize = AVAudioFrameCount(inputFormat.sampleRate * 0.1)
75
75
  do {
76
- if #available(macOS 27.0, *) {
77
- try inputNode.__installTap(
78
- onBus: 0,
79
- bufferSize: tapBufferSize,
80
- format: inputFormat,
81
- error: (),
82
- block: tap
83
- )
84
- } else {
85
- inputNode.installTap(
86
- onBus: 0,
87
- bufferSize: tapBufferSize,
88
- format: inputFormat,
89
- block: tap
90
- )
91
- }
76
+ inputNode.installTap(
77
+ onBus: 0,
78
+ bufferSize: tapBufferSize,
79
+ format: inputFormat,
80
+ block: tap
81
+ )
92
82
  audioEngine.prepare()
93
83
  try audioEngine.start()
94
84
  } catch {
@@ -6,33 +6,29 @@ import { META_PROVIDER_ID } from "./meta.ts";
6
6
  import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
7
7
  import { spawn } from "node:child_process";
8
8
  import { randomUUID } from "node:crypto";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
9
12
  import {
10
- existsSync,
11
- mkdirSync,
12
- readFileSync,
13
- statSync,
14
- writeFileSync,
15
- } from "node:fs";
16
- import { homedir, platform } from "node:os";
17
- import { dirname, join } from "node:path";
18
- import { fileURLToPath } from "node:url";
13
+ asrEndpoint,
14
+ chooseTranscript,
15
+ DEFAULT_ASR_MODEL,
16
+ formatAuthorization,
17
+ isNormalSocketClose,
18
+ parseJsonObject,
19
+ pcmAudioLevel,
20
+ socketMessageText,
21
+ } from "./voice/asr.ts";
22
+ import {
23
+ ensureHelper,
24
+ isSupportedPlatform,
25
+ supportedPlatformLabel,
26
+ } from "./voice/helpers.ts";
27
+
28
+ export { asrEndpoint, formatAuthorization, pcmAudioLevel } from "./voice/asr.ts";
19
29
 
20
30
  const AGENT_DIR = join(homedir(), ".pi/agent");
21
31
  const SETTINGS_FILE = join(AGENT_DIR, "pi-meta-oauth-voice.json");
22
- const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
23
- const HELPER_DIR = join(EXTENSION_DIR, "voice");
24
- const HELPER_SOURCE = join(HELPER_DIR, "macos-audio.swift");
25
- const HELPER_PLIST = join(HELPER_DIR, "Info.plist");
26
- const HELPER_ENTITLEMENTS = join(HELPER_DIR, "Entitlements.plist");
27
- const HELPER_BINARY = join(AGENT_DIR, "bin/pi-meta-oauth-voice-v1");
28
- // Windows helpers
29
- const WINDOWS_HELPER_SOURCE = join(HELPER_DIR, "windows-audio.cs");
30
- const WINDOWS_HELPER_PS1 = join(HELPER_DIR, "windows-audio.ps1");
31
- const WINDOWS_HELPER_BINARY = join(AGENT_DIR, "bin/pi-meta-oauth-voice-v1.exe");
32
-
33
- const DEFAULT_ASR_ENDPOINT =
34
- "wss://shortwave.facebook.com/voyager/v1/asr/duplex";
35
- const DEFAULT_ASR_MODEL = "prod_tbh";
36
32
  const AUDIO_FRAME_BYTES = 3_200;
37
33
  const ASR_PREBUFFER_FRAMES = 6;
38
34
  const MAX_QUEUED_AUDIO_BYTES = 2 * 1024 * 1024;
@@ -43,30 +39,11 @@ const FINAL_TRANSCRIPT_TIMEOUT_MS = 6_000;
43
39
  type Settings = { enabled: boolean };
44
40
  type VoicePhase = "idle" | "preparing" | "recording" | "stopping";
45
41
  type HelperEvent = Record<string, unknown> & { type?: string };
46
-
47
42
  type AsrTranscript = {
48
43
  transcript?: unknown;
49
44
  final?: unknown;
50
45
  };
51
46
 
52
- function isMacOS(): boolean {
53
- return platform() === "darwin";
54
- }
55
-
56
- function isWindows(): boolean {
57
- return platform() === "win32";
58
- }
59
-
60
- function isSupportedPlatform(): boolean {
61
- return isMacOS() || isWindows();
62
- }
63
-
64
- function supportedPlatformLabel(): string {
65
- if (isMacOS()) return "macOS";
66
- if (isWindows()) return "Windows";
67
- return `${platform()}`;
68
- }
69
-
70
47
  function loadSettings(): Settings {
71
48
  if (existsSync(SETTINGS_FILE)) {
72
49
  try {
@@ -92,295 +69,6 @@ function combineEditorText(original: string, transcript: string): string {
92
69
  return `${original.replace(/\s+$/, "")} ${transcript.trim()}`;
93
70
  }
94
71
 
95
- export function pcmAudioLevel(chunk: Buffer): number {
96
- if (chunk.length < 2) return 0;
97
- let sumSquares = 0;
98
- let samples = 0;
99
- for (let offset = 0; offset + 1 < chunk.length; offset += 4) {
100
- const normalized = chunk.readInt16LE(offset) / 32_768;
101
- sumSquares += normalized * normalized;
102
- samples += 1;
103
- }
104
- if (samples === 0) return 0;
105
- const rms = Math.sqrt(sumSquares / samples);
106
- return Math.min(1, Math.log10(1 + rms * 90));
107
- }
108
-
109
- function hypothesisWords(value: string): string[] {
110
- return value
111
- .trim()
112
- .split(/\s+/)
113
- .map((word) => word.toLocaleLowerCase().replace(/[^\p{L}\p{N}'’]/gu, ""))
114
- .filter(Boolean);
115
- }
116
-
117
- function chooseTranscript(previous: string, incoming: string): string {
118
- const next = incoming.trim();
119
- if (!next) return previous;
120
- if (!previous.trim()) return next;
121
-
122
- const previousWords = hypothesisWords(previous);
123
- const nextWords = hypothesisWords(next);
124
- const sharedLength = Math.min(previousWords.length, nextWords.length);
125
- let commonPrefix = 0;
126
- while (
127
- commonPrefix < sharedLength &&
128
- previousWords[commonPrefix] === nextWords[commonPrefix]
129
- ) {
130
- commonPrefix += 1;
131
- }
132
-
133
- if (commonPrefix === sharedLength || commonPrefix >= 2) {
134
- const isRegression =
135
- nextWords.length < previousWords.length &&
136
- nextWords.every((word, index) => previousWords[index] === word);
137
- return isRegression ? previous : next;
138
- }
139
- return next.length >= previous.length ? next : previous;
140
- }
141
-
142
- export function formatAuthorization(apiKey: string): string {
143
- return apiKey.startsWith("OAuth ") ? apiKey : `OAuth ${apiKey}`;
144
- }
145
-
146
- export function asrEndpoint(sessionId: string): string {
147
- const configured =
148
- process.env.PI_META_VOICE_ASR_ENDPOINT ??
149
- process.env.MUSE_VOICE_ASR_ENDPOINT ??
150
- DEFAULT_ASR_ENDPOINT;
151
- let endpoint: URL;
152
- try {
153
- endpoint = new URL(configured);
154
- } catch {
155
- throw new Error("Voice ASR endpoint is not a valid URL");
156
- }
157
- const localInsecureEndpoint =
158
- endpoint.protocol === "ws:" &&
159
- (endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1");
160
- if (endpoint.protocol !== "wss:" && !localInsecureEndpoint) {
161
- throw new Error(
162
- "Voice ASR endpoint must use wss:// (or ws://localhost for testing)",
163
- );
164
- }
165
- endpoint.searchParams.set("sessionId", sessionId);
166
- return endpoint.toString();
167
- }
168
-
169
- async function socketMessageText(data: unknown): Promise<string | undefined> {
170
- if (typeof data === "string") return data;
171
- if (data instanceof Blob) return data.text();
172
- if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
173
- if (ArrayBuffer.isView(data)) {
174
- return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString(
175
- "utf8",
176
- );
177
- }
178
- return undefined;
179
- }
180
-
181
- function parseJsonObject(text: string): Record<string, unknown> | undefined {
182
- try {
183
- const value = JSON.parse(text) as unknown;
184
- if (value && typeof value === "object" && !Array.isArray(value)) {
185
- return value as Record<string, unknown>;
186
- }
187
- } catch {
188
- // Ignore non-JSON ASR messages.
189
- }
190
- return undefined;
191
- }
192
-
193
- async function runProcess(command: string, args: string[]): Promise<void> {
194
- await new Promise<void>((resolve, reject) => {
195
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
196
- let stdout = "";
197
- let stderr = "";
198
- child.stdout?.on("data", (chunk: Buffer) => {
199
- stdout = `${stdout}${chunk.toString("utf8")}`.slice(-8_000);
200
- });
201
- child.stderr?.on("data", (chunk: Buffer) => {
202
- stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
203
- });
204
- child.once("error", reject);
205
- child.once("close", (code) => {
206
- if (code === 0) {
207
- resolve();
208
- return;
209
- }
210
- reject(
211
- new Error(
212
- (
213
- stderr ||
214
- stdout ||
215
- `${command} exited with code ${code ?? "unknown"}`
216
- ).trim(),
217
- ),
218
- );
219
- });
220
- });
221
- }
222
-
223
- function helperNeedsBuild(): boolean {
224
- if (!existsSync(HELPER_BINARY)) return true;
225
- const binaryTime = statSync(HELPER_BINARY).mtimeMs;
226
- return [HELPER_SOURCE, HELPER_PLIST, HELPER_ENTITLEMENTS].some(
227
- (path) => !existsSync(path) || statSync(path).mtimeMs > binaryTime,
228
- );
229
- }
230
-
231
- function windowsHelperNeedsBuild(): boolean {
232
- if (!existsSync(WINDOWS_HELPER_BINARY)) return true;
233
- if (!existsSync(WINDOWS_HELPER_SOURCE)) return false;
234
- const binaryTime = statSync(WINDOWS_HELPER_BINARY).mtimeMs;
235
- return statSync(WINDOWS_HELPER_SOURCE).mtimeMs > binaryTime;
236
- }
237
-
238
- async function ensureMacOSHelper(): Promise<string> {
239
- if (!isMacOS()) {
240
- throw new Error(
241
- "Muse-style voice input is currently available only on macOS and Windows",
242
- );
243
- }
244
- if (
245
- !existsSync(HELPER_SOURCE) ||
246
- !existsSync(HELPER_PLIST) ||
247
- !existsSync(HELPER_ENTITLEMENTS)
248
- ) {
249
- throw new Error("The macOS microphone helper sources are incomplete");
250
- }
251
- if (!helperNeedsBuild()) return HELPER_BINARY;
252
-
253
- mkdirSync(join(AGENT_DIR, "bin"), { recursive: true });
254
- await runProcess("/usr/bin/xcrun", [
255
- "swiftc",
256
- HELPER_SOURCE,
257
- "-o",
258
- HELPER_BINARY,
259
- "-framework",
260
- "AVFoundation",
261
- "-Xlinker",
262
- "-sectcreate",
263
- "-Xlinker",
264
- "__TEXT",
265
- "-Xlinker",
266
- "__info_plist",
267
- "-Xlinker",
268
- HELPER_PLIST,
269
- ]);
270
- await runProcess("/usr/bin/codesign", [
271
- "--force",
272
- "--sign",
273
- "-",
274
- "--entitlements",
275
- HELPER_ENTITLEMENTS,
276
- HELPER_BINARY,
277
- ]);
278
- return HELPER_BINARY;
279
- }
280
-
281
- async function tryCompileWindowsHelper(): Promise<boolean> {
282
- if (!existsSync(WINDOWS_HELPER_SOURCE)) return false;
283
- mkdirSync(join(AGENT_DIR, "bin"), { recursive: true });
284
-
285
- const candidates: string[] = [
286
- "csc",
287
- "csc.exe",
288
- "C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe",
289
- "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe",
290
- ];
291
- // Also try where-found csc via PATH expansion if on Windows
292
- for (const c of candidates) {
293
- try {
294
- await runProcess(c, [
295
- "/nologo",
296
- "/target:exe",
297
- `/out:${WINDOWS_HELPER_BINARY}`,
298
- WINDOWS_HELPER_SOURCE,
299
- ]);
300
- if (existsSync(WINDOWS_HELPER_BINARY)) return true;
301
- } catch {
302
- // try next candidate
303
- }
304
- }
305
- return false;
306
- }
307
-
308
- function findPowerShellCommand(): string {
309
- // Prefer pwsh (PowerShell 7) if present, otherwise Windows PowerShell
310
- // We check filesystem first to avoid spawning during hot path
311
- if (existsSync("C:\\Program Files\\PowerShell\\7\\pwsh.exe"))
312
- return "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
313
- if (existsSync("C:\\Program Files\\PowerShell\\7\\pwsh")) return "pwsh";
314
- // Windows PowerShell is inbox in System32
315
- if (
316
- existsSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
317
- )
318
- return "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
319
- return "powershell.exe";
320
- }
321
-
322
- async function ensureWindowsHelper(): Promise<{
323
- command: string;
324
- args: string[];
325
- }> {
326
- if (!isWindows()) {
327
- throw new Error(
328
- "Muse-style voice input is currently available only on macOS and Windows",
329
- );
330
- }
331
- if (!existsSync(WINDOWS_HELPER_SOURCE) && !existsSync(WINDOWS_HELPER_PS1)) {
332
- throw new Error("The Windows microphone helper sources are incomplete");
333
- }
334
-
335
- // If compiled exe exists and is fresh, use it
336
- if (existsSync(WINDOWS_HELPER_BINARY) && !windowsHelperNeedsBuild()) {
337
- return { command: WINDOWS_HELPER_BINARY, args: [] };
338
- }
339
-
340
- // Try to compile exe for better performance
341
- if (existsSync(WINDOWS_HELPER_SOURCE)) {
342
- if (!windowsHelperNeedsBuild() && existsSync(WINDOWS_HELPER_BINARY)) {
343
- return { command: WINDOWS_HELPER_BINARY, args: [] };
344
- }
345
- const compiled = await tryCompileWindowsHelper();
346
- if (compiled && existsSync(WINDOWS_HELPER_BINARY)) {
347
- return { command: WINDOWS_HELPER_BINARY, args: [] };
348
- }
349
- }
350
-
351
- // Fallback to PowerShell script (no compilation needed)
352
- if (existsSync(WINDOWS_HELPER_PS1)) {
353
- const ps = findPowerShellCommand();
354
- return {
355
- command: ps,
356
- args: [
357
- "-NoProfile",
358
- "-ExecutionPolicy",
359
- "Bypass",
360
- "-File",
361
- WINDOWS_HELPER_PS1,
362
- ],
363
- };
364
- }
365
-
366
- throw new Error(
367
- "Windows voice helper could not be prepared — no compiled binary and no PowerShell fallback found",
368
- );
369
- }
370
-
371
- async function ensureHelper(): Promise<{ command: string; args: string[] }> {
372
- if (isMacOS()) {
373
- const bin = await ensureMacOSHelper();
374
- return { command: bin, args: [] };
375
- }
376
- if (isWindows()) {
377
- return ensureWindowsHelper();
378
- }
379
- throw new Error(
380
- `Muse-style voice input is currently available only on macOS and Windows (current: ${supportedPlatformLabel()})`,
381
- );
382
- }
383
-
384
72
  class MetaVoiceController {
385
73
  private readonly pi: ExtensionAPI;
386
74
  private readonly settings = loadSettings();
@@ -620,7 +308,7 @@ class MetaVoiceController {
620
308
  });
621
309
  socket.addEventListener("message", (event) => {
622
310
  if (currentGeneration !== this.generation) return;
623
- void this.handleSocketMessage(ctx, event.data);
311
+ void this.handleSocketMessage(ctx, event.data, currentGeneration);
624
312
  });
625
313
  socket.addEventListener("error", () => {
626
314
  if (currentGeneration !== this.generation) return;
@@ -629,7 +317,11 @@ class MetaVoiceController {
629
317
  socket.addEventListener("close", (event) => {
630
318
  if (currentGeneration !== this.generation || this.phase === "idle")
631
319
  return;
632
- if (this.phase === "stopping" && this.transcript) {
320
+ if (
321
+ this.phase === "stopping" &&
322
+ this.transcript &&
323
+ isNormalSocketClose(event.code)
324
+ ) {
633
325
  this.complete(ctx);
634
326
  return;
635
327
  }
@@ -646,8 +338,10 @@ class MetaVoiceController {
646
338
  private async handleSocketMessage(
647
339
  ctx: ExtensionContext,
648
340
  data: unknown,
341
+ currentGeneration: number,
649
342
  ): Promise<void> {
650
343
  const text = await socketMessageText(data);
344
+ if (currentGeneration !== this.generation) return;
651
345
  if (text === undefined) return;
652
346
  const payload = parseJsonObject(text);
653
347
  if (!payload) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-meta-oauth",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Meta Model API OAuth provider and Muse-style voice dictation for pi",
5
5
  "keywords": [
6
6
  "pi-package",