Skip to content

Dictation always returns "No speech detected": PvRecorder N-API addon never writes PCM into the JS buffer under Bun #13

Description

@jhulten

Note from the Human

After trying to figure this out, my pi agent was able to isolate the problem I was seeing. This is model generated and only lightly coerced by human hands (mine) since js and bun are not my strong suit. Let me know if you need any additional information or if this is WAY too much.
-- Jeff

Summary

Microphone dictation silently produces empty transcripts on macOS. Every recording reaches the model as digital silence, so stopAndTranscribe takes the else branch at src/runtime.ts:251 and reports No speech detected in Xs of audio.

The cause is in @picovoice/pvrecorder-node@1.2.9, whose N-API addon fills a JS-allocated Int16Array in place. Under pi's Bun runtime that write never lands in the JS-visible backing store. read() still returns PV_RECORDER_STATUS_SUCCESS, so every layer above sees well-formed frames arriving at the correct rate — they are just all zeros.

Environment

pi 0.84.4 (embeds bun-v1.3.14, JavaScriptCore)
pi-transcribe 1f44de6 ("move to q8 for all models for now")
@picovoice/pvrecorder-node 1.2.9
macOS 26.6.2 (25G83), arm64
Model parakeet-unified-en-0.6b (Q8_0), transcriptionLanguage: "en"
Microphone MacBook Pro Microphone, pinned via {"type":"device"} (also reproduces with system-default)

Symptom

Instrumented stopAndTranscribe and TranscriptionService.runReservationWork:

dictation-start microphone={"type":"device","name":"MacBook Pro Microphone","occurrence":0} language=en model=parakeet-unified-en-0.6b
dictation-submit samples=244224 secs=15.26 peak=0.0000(-180.0dBFS) rms=0.0000(-180.0dBFS)
path=stream fedChunks=30 fedSamples=244224 fullPcmSamples=244224
stream-finalize text=""
dictation-result text=""

15.26 s captured, all 30 chunks fed, stream finalized without error — and every one of the 244,224 samples is exactly 0. The extension is behaving correctly; it was handed silence.

Root cause / minimal repro

PvRecorder.read() allocates new Int16Array(this._frameLength) and passes it to native, which is expected to write into it. Pre-filling each frame with a sentinel shows native never touches it under Bun.

Run inside pi's process — save as sentinel-probe.ts and run pi -e ./sentinel-probe.ts --no-session -p x:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const DIST = "<path>/node_modules/@picovoice/pvrecorder-node/dist";

export default function (pi: ExtensionAPI) {
  pi.on("session_start", async () => {
    const { PvRecorder } = await import(`${DIST}/index.js`);
    const { getSystemLibraryPath } = await import(`${DIST}/platforms.js`);
    const native = require(getSystemLibraryPath());

    const r = new PvRecorder(512, 0);
    r.start();
    for (let i = 0; i < 5; i++) {
      const pcm = new Int16Array(512).fill(0x4242);      // sentinel
      const status = native.read((r as any)._handle, pcm);
      console.log(status, pcm.every((v: number) => v === 0x4242), pcm.slice(0, 4));
      await new Promise((res) => setTimeout(res, 40));
    }
    r.stop(); r.release();
    process.exit(0);
  });
}

Results, same machine, same device index, same .node binary, seconds apart:

Runtime status Sentinel after read() First samples
pi (Bun 1.3.14) 0 SUCCESS intact [16962,16962,16962,16962]
node v26.8.1 0 SUCCESS destroyed [-15,-17,-13,-5]

Under Bun the buffer is returned exactly as allocated. PvRecorder.getAvailableDevices() (string returns) and the recorder handle both work, so it is specific to the typed-array out-parameter — likely how the addon obtains the data pointer via napi_get_typedarray_info against Bun's N-API shim.

Corroboration: Bun 1.0.36 fails the same class of call loudly, on the handle out-pointer:

error: Unable to get the address of the instance of PvRecorder properly
      at start (.../pv_recorder.js:80:24)

So Bun 1.3.14 appears to have fixed write-back for the handle but not for typed arrays — turning a hard error into silent zeros.

Why this is hard to diagnose from the UI

Three signals actively point away from the real cause:

  1. The permission preflight can't see the recording process. testMicrophonePermission() (src/audio.ts:66) shells out to osascript, so AVCaptureDevice.authorizationStatusForMediaType reports permission for osascript, not for pi. It returns 3 (authorized) while pi's own capture is empty. It would return 3 even if pi genuinely lacked access.
  2. The level meter is fed from the same zero frames (capture.onFrame → meter.push), so a flat meter looks like "user didn't speak" rather than "capture is broken".
  3. Nothing errors. Status codes are SUCCESS, frame count and timing are exact (244,224 samples ≈ 15.26 s at 16 kHz), and the model legitimately returns "" for silence.

I initially misdiagnosed this as a Bluetooth default-device problem, then as a TCC denial — both plausible and both wrong. Ruling out TCC took an ad-hoc-re-signed node binary (still captured fine), which is more work than a user should need.

Verified not the cause

  • Device selection — findDeviceIndex resolves MacBook Pro Microphone → index 0 correctly.
  • Microphone hardware/permissions — same device, same PvRecorder version, captured live audio at −19.6 dBFS from a plain node process; the host terminal holds mic permission.
  • The model and both transcription paths — feeding real 16 kHz PCM to the configured GGUF returns correct text via both model.transcribe() and session.stream() + finalize().
  • Stream plumbing — fedSamples === fullPcmSamples, no feed-dropped, no streamError.

Suggested fixes

  1. Capture outside pi's process. Child processes of pi are unaffected — a plain node process recording from the same device works. A small helper streaming raw Int16 frames over stdout would sidestep the Bun N-API path entirely and keep MicrophoneCapture's interface unchanged.
  2. Don't rely on in-place N-API buffer writes under Bun. Either use a Bun-native FFI path, or a PvRecorder API that returns a natively-allocated buffer instead of filling a JS-allocated one.
  3. Fail loudly rather than silently. A one-time sentinel check at capture.start() (write a sentinel, read one frame, verify it changed) would turn this from an unexplained empty transcript into a precise diagnostic. Cheaper alternative: if a submitted clip is bit-exactly zero, say "no audio was captured — the microphone returned silence" instead of "No speech detected", since those are very different conditions.
  4. Fix the preflight to check permission in-process, or drop the claim it makes — as written it can only ever describe osascript.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions