pi-voicekit 0.1.2 → 0.1.3

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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Capture-tool preference.
3
+ *
4
+ * SoX' `rec` is the best default for local capture, but it is known to stall on
5
+ * network PulseAudio servers: over an SSH audio tunnel (`PULSE_SERVER` pointing at
6
+ * a forwarded TCP port, e.g. `tcp:127.0.0.1:4713`) the same `rec` invocation
7
+ * returned zero bytes in 40% of runs on this stack, while ffmpeg was reliable in
8
+ * every run. So when a remote Pulse server is configured, probe ffmpeg first and
9
+ * keep SoX as the fallback; local capture keeps SoX first.
10
+ */
11
+
12
+ export type AudioToolName = "ffmpeg" | "sox" | "arecord";
13
+
14
+ export function audioToolOrder(env: { PULSE_SERVER?: string | undefined } = process.env): AudioToolName[] {
15
+ return env.PULSE_SERVER ? ["ffmpeg", "sox", "arecord"] : ["sox", "ffmpeg", "arecord"];
16
+ }
@@ -100,6 +100,7 @@ import {
100
100
  } from "./voice/local";
101
101
  import { shouldArmReleaseDetectOnRepeat, decideRecordingStartTimer } from "./voice/hold-to-talk";
102
102
  import { GapTimer, type TimerPort } from "./voice/release-controller";
103
+ import { audioToolOrder, type AudioToolName } from "./voice/audio-tool";
103
104
 
104
105
  /** Adapter for the real event loop — lets GapTimer run under the real setTimeout. */
105
106
  const realTimerPort: TimerPort = {
@@ -245,87 +246,95 @@ interface AudioCaptureTool {
245
246
  args: string[];
246
247
  }
247
248
 
248
- // Try available audio capture tools in order of preference
249
+ // Try available audio capture tools in the order given by audioToolOrder()
249
250
  let _cachedAudioTool: AudioCaptureTool | null | undefined;
250
- function detectAudioCaptureTool(): AudioCaptureTool | null {
251
- if (_cachedAudioTool !== undefined) return _cachedAudioTool;
252
251
 
253
- // 1. SoX rec — purpose-built for recording, best quality
254
- if (commandExists("rec")) {
255
- _cachedAudioTool = {
256
- name: "sox",
257
- cmd: "rec",
258
- args: [
259
- "-q",
260
- "--buffer",
261
- "4096",
262
- "-c",
263
- String(CHANNELS),
264
- "-b",
265
- "16",
266
- "-e",
267
- "signed-integer",
268
- "-t",
269
- "raw",
270
- "-",
271
- "rate",
272
- String(SAMPLE_RATE),
273
- ],
274
- };
275
- return _cachedAudioTool;
276
- }
252
+ function probeSox(): AudioCaptureTool | null {
253
+ if (!commandExists("rec")) return null;
254
+ return {
255
+ name: "sox",
256
+ cmd: "rec",
257
+ args: [
258
+ "-q",
259
+ "--buffer",
260
+ "4096",
261
+ "-c",
262
+ String(CHANNELS),
263
+ "-b",
264
+ "16",
265
+ "-e",
266
+ "signed-integer",
267
+ "-t",
268
+ "raw",
269
+ "-",
270
+ "rate",
271
+ String(SAMPLE_RATE),
272
+ ],
273
+ };
274
+ }
277
275
 
278
- // 2. ffmpeg — widely installed, captures from default mic
279
- if (commandExists("ffmpeg")) {
280
- const isLinux = process.platform === "linux";
281
- const isMac = process.platform === "darwin";
282
- const isWin = process.platform === "win32";
283
- // Input device varies by platform
284
- let inputArgs: string[];
285
- if (isMac) {
286
- inputArgs = ["-f", "avfoundation", "-i", ":default"];
287
- } else if (isLinux) {
288
- inputArgs = ["-f", "pulse", "-i", "default"];
289
- } else if (isWin) {
290
- // DirectShow has no "default" alias — enumerate devices and pick the first audio device
291
- const dshowDevice = detectWindowsAudioDevice();
292
- inputArgs = dshowDevice
293
- ? ["-f", "dshow", "-i", `audio=${dshowDevice}`]
294
- : ["-f", "dshow", "-i", "audio=Microphone"]; // last-resort guess
295
- } else {
296
- inputArgs = ["-f", "pulse", "-i", "default"]; // fallback for other platforms
297
- }
298
- _cachedAudioTool = {
299
- name: "ffmpeg",
300
- cmd: "ffmpeg",
301
- args: [
302
- ...inputArgs,
303
- "-ac",
304
- String(CHANNELS),
305
- "-ar",
306
- String(SAMPLE_RATE),
307
- "-sample_fmt",
308
- "s16",
309
- "-f",
310
- "s16le",
311
- "-loglevel",
312
- "error",
313
- "pipe:1",
314
- ],
315
- };
316
- return _cachedAudioTool;
276
+ function probeFfmpeg(): AudioCaptureTool | null {
277
+ if (!commandExists("ffmpeg")) return null;
278
+ const isLinux = process.platform === "linux";
279
+ const isMac = process.platform === "darwin";
280
+ const isWin = process.platform === "win32";
281
+ // Input device varies by platform
282
+ let inputArgs: string[];
283
+ if (isMac) {
284
+ inputArgs = ["-f", "avfoundation", "-i", ":default"];
285
+ } else if (isLinux) {
286
+ inputArgs = ["-f", "pulse", "-i", "default"];
287
+ } else if (isWin) {
288
+ // DirectShow has no "default" alias — enumerate devices and pick the first audio device
289
+ const dshowDevice = detectWindowsAudioDevice();
290
+ inputArgs = dshowDevice ? ["-f", "dshow", "-i", `audio=${dshowDevice}`] : ["-f", "dshow", "-i", "audio=Microphone"]; // last-resort guess
291
+ } else {
292
+ inputArgs = ["-f", "pulse", "-i", "default"]; // fallback for other platforms
317
293
  }
294
+ return {
295
+ name: "ffmpeg",
296
+ cmd: "ffmpeg",
297
+ args: [
298
+ ...inputArgs,
299
+ "-ac",
300
+ String(CHANNELS),
301
+ "-ar",
302
+ String(SAMPLE_RATE),
303
+ "-sample_fmt",
304
+ "s16",
305
+ "-f",
306
+ "s16le",
307
+ "-loglevel",
308
+ "error",
309
+ "pipe:1",
310
+ ],
311
+ };
312
+ }
318
313
 
319
- // 3. arecord — built into Linux ALSA, zero install
320
- if (process.platform === "linux" && commandExists("arecord")) {
321
- _cachedAudioTool = {
322
- name: "arecord",
323
- cmd: "arecord",
324
- args: ["-q", "-f", "S16_LE", "-r", String(SAMPLE_RATE), "-c", String(CHANNELS), "-t", "raw"],
325
- };
326
- return _cachedAudioTool;
327
- }
314
+ function probeArecord(): AudioCaptureTool | null {
315
+ if (process.platform !== "linux" || !commandExists("arecord")) return null;
316
+ return {
317
+ name: "arecord",
318
+ cmd: "arecord",
319
+ args: ["-q", "-f", "S16_LE", "-r", String(SAMPLE_RATE), "-c", String(CHANNELS), "-t", "raw"],
320
+ };
321
+ }
328
322
 
323
+ const audioProbes: Record<AudioToolName, () => AudioCaptureTool | null> = {
324
+ sox: probeSox,
325
+ ffmpeg: probeFfmpeg,
326
+ arecord: probeArecord,
327
+ };
328
+
329
+ function detectAudioCaptureTool(): AudioCaptureTool | null {
330
+ if (_cachedAudioTool !== undefined) return _cachedAudioTool;
331
+ for (const name of audioToolOrder()) {
332
+ const tool = audioProbes[name]();
333
+ if (tool) {
334
+ _cachedAudioTool = tool;
335
+ return tool;
336
+ }
337
+ }
329
338
  _cachedAudioTool = null;
330
339
  return null;
331
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-voicekit",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Voice in + voice out for Pi CLI — hold-to-talk STT (Deepgram streaming or 21 offline models) plus TTS (Kitten Nano, Piper, Kokoro, or Deepgram Aura)",
5
5
  "type": "module",
6
6
  "keywords": [