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:
- 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.
- 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".
- 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
- 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.
- 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.
- 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.
- Fix the preflight to check permission in-process, or drop the claim it makes — as written it can only ever describe
osascript.
Summary
Microphone dictation silently produces empty transcripts on macOS. Every recording reaches the model as digital silence, so
stopAndTranscribetakes theelsebranch atsrc/runtime.ts:251and reportsNo speech detected in Xs of audio.The cause is in
@picovoice/pvrecorder-node@1.2.9, whose N-API addon fills a JS-allocatedInt16Arrayin place. Under pi's Bun runtime that write never lands in the JS-visible backing store.read()still returnsPV_RECORDER_STATUS_SUCCESS, so every layer above sees well-formed frames arriving at the correct rate — they are just all zeros.Environment
bun-v1.3.14, JavaScriptCore)1f44de6("move to q8 for all models for now")@picovoice/pvrecorder-nodeparakeet-unified-en-0.6b(Q8_0),transcriptionLanguage: "en"{"type":"device"}(also reproduces withsystem-default)Symptom
Instrumented
stopAndTranscribeandTranscriptionService.runReservationWork: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()allocatesnew 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.tsand runpi -e ./sentinel-probe.ts --no-session -p x:Results, same machine, same device index, same
.nodebinary, seconds apart:statusread()0SUCCESS[16962,16962,16962,16962]0SUCCESS[-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 vianapi_get_typedarray_infoagainst Bun's N-API shim.Corroboration: Bun 1.0.36 fails the same class of call loudly, on the handle out-pointer:
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:
testMicrophonePermission()(src/audio.ts:66) shells out toosascript, soAVCaptureDevice.authorizationStatusForMediaTypereports permission for osascript, not for pi. It returns3(authorized) while pi's own capture is empty. It would return3even if pi genuinely lacked access.capture.onFrame→meter.push), so a flat meter looks like "user didn't speak" rather than "capture is broken".""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
nodebinary (still captured fine), which is more work than a user should need.Verified not the cause
findDeviceIndexresolvesMacBook Pro Microphone→ index 0 correctly.model.transcribe()andsession.stream()+finalize().fedSamples === fullPcmSamples, nofeed-dropped, nostreamError.Suggested fixes
MicrophoneCapture's interface unchanged.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.osascript.