Skip to content

Commit e3e9eaf

Browse files
committed
fix: tolerate macOS microphone deep idle
1 parent 3171b27 commit e3e9eaf

5 files changed

Lines changed: 170 additions & 16 deletions

File tree

.github/release-notes/v5.0.8.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# VoCoType-linux v5.0.8
22

3-
VoCoType-linux v5.0.8 是 V5 稳定线的 macOS 输入法更新可靠性热修复版本。它修复了更新后旧 InputMethod 进程可能继续驻留、导致麦克风 TCC 身份与磁盘中新 bundle 不一致的问题。
3+
VoCoType-linux v5.0.8 是 V5 稳定线的 macOS 麦克风恢复与输入法更新可靠性热修复版本。它同时修复“长时间不用电脑后第一次 F9 开不了麦克风”的 CoreAudio 启动竞态,以及更新后旧 InputMethod 进程可能继续驻留、导致 TCC 身份与磁盘中新 bundle 不一致的问题。
44

55
发布日期:2026-09-12。
66

@@ -9,11 +9,21 @@ VoCoType-linux v5.0.8 是 V5 稳定线的 macOS 输入法更新可靠性热修
99
- **更新前先禁用旧输入源**:DMG 安装脚本在替换 `~/Library/Input Methods/VoCoType-linux.app` 前先禁用旧 VoCoType 输入源,再终止旧 InputMethod 和录音相关进程,避免 InputMethodKit 在文件复制过程中自动重新拉起旧进程。
1010
- **最终安装后强制建立新的进程边界**:新 bundle 完整复制、LaunchServices 注册和输入源安装完成后,再终止一次 InputMethod 并重新激活,确保最终常驻进程一定从最终安装、最终签名的 bundle 启动。
1111
- **Settings App 自动更新采用同样策略**:嵌入式 InputMethod 更新完成并激活后,也会做最终进程重启,DMG 安装与应用内更新行为保持一致。
12-
- **保留 v5.0.7 的录音启动 watchdog**:本次没有放宽或移除 CoreAudio 5 秒启动保护;现场验证表明当前问题不是 watchdog 阈值,而是更新后遗留的进程身份错位。
12+
- **修正休眠/长时间闲置后的启动 watchdog 竞态**:macOS 26.5 真机日志显示,内建麦克风长时间未使用后,从 CoreAudio `idle` 唤醒到 `_StartIO succeeded` 实测约需 **5.004 秒**。v5.0.7 的 5 秒 watchdog 会在 HAL 刚进入 Running 的瞬间 `_Exit(2)`,导致第一次 F9 必然失败。v5.0.8 将阈值提高到 **8 秒**,保留卡死保护的同时给深度 idle 唤醒留出余量。
13+
- **Playground 的“录制 3 秒”从真实 PCM 开始计时**:macOS Settings 不再直接在自身进程里阻塞式打开 CoreAudio,而是调用隔离的 recorder helper;只有第一块真实音频到达后才开始 3 秒计时,并通过 `level` 事件继续驱动波形。Playground helper 禁用流式 ASR preview,避免麦克风测试额外启动识别链路。若 CoreAudio 真正卡死,8 秒 watchdog 只终止 recorder 子进程,不会让 Settings App 永久卡住。
1314

1415
## 真机证据
1516

16-
受影响机器上,磁盘中的 `VoCoType-linux.app` 能通过 `codesign --verify --deep --strict`,但常驻 InputMethod 进程仍是前一天更新前启动的旧进程。真实 F9 录音时,TCC 日志出现:
17+
受影响机器上抓到了两类独立但会叠加的问题。
18+
19+
第一类是长时间闲置后的 CoreAudio 深度 idle 唤醒:
20+
21+
- 18:35:19.865:`BuiltInMicrophoneDevice` 开始 `IOWorkLoopInit`
22+
- 18:35:24.869:`Digital Mic` 进入 `runn``HALS_IOEngine2::_StartIO succeeded`
23+
- 总耗时约 **5.004 秒**
24+
- 旧 5 秒 watchdog 正好在这一边界杀掉 recorder,随后 CoreAudio 立即 stop,用户看到 F9 一直无法开始采音。
25+
26+
第二类是更新后进程身份错位。磁盘中的 `VoCoType-linux.app` 能通过 `codesign --verify --deep --strict`,但常驻 InputMethod 进程仍可能是前一天更新前启动的旧进程。真实 F9 录音时,TCC 日志出现:
1727

1828
`IDENTITY_ATTRIBUTION: Failed to copy signing info ... -67034`
1929

@@ -29,6 +39,9 @@ VoCoType-linux v5.0.8 是 V5 稳定线的 macOS 输入法更新可靠性热修
2939

3040
验证结果:
3141

42+
- 修复前,长时间闲置后第一次真实 F9 在 5.015 秒处被 watchdog 终止,且 CoreAudio 日志显示硬件刚在 5.004 秒左右进入 Running;
43+
- 修复后 watchdog 固定为 8 秒,并新增 source contract 禁止回退到 5 秒;
44+
- Playground timed capture 改为从第一块真实 PCM 计时,并通过隔离 helper 提供波形 level 事件;
3245
- macOS CTest:10/10 PASS;
3346
- native source contracts:PASS;
3447
- `packaging/macos/install.command` shell syntax:PASS。

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Fixed
1313

14+
- macOS microphone startup watchdog now allows 8 seconds instead of 5 seconds for the first PCM block. On macOS 26.5, a built-in microphone left unused for a long period was observed taking about 5.004 seconds to transition from CoreAudio `idle` to `Running`; the previous 5-second watchdog killed the recorder at the exact moment the HAL finished waking, making the first F9 after inactivity fail deterministically.
15+
- macOS timed recorder sessions now start their requested duration from the first real PCM block instead of process launch. The Settings Playground records through the isolated recorder helper with waveform level events and no streaming-ASR preview, so a cold microphone can finish waking before the 3-second capture window begins and a genuine CoreAudio hang cannot wedge the Settings process.
1416
- macOS upgrades now disable the old input source before replacing its bundle and force a final InputMethod process restart only after the new bundle is fully installed and registered. This prevents a stale in-memory input-method executable from remaining responsible for a newly signed on-disk bundle, which can make TCC microphone identity attribution fail intermittently even though the replacement bundle passes `codesign` verification.
1517
- The Settings App embedded-input-method updater now enforces the same final process boundary after activation, so both DMG installs and in-app upgrades converge on a fresh process loaded from the final installed bundle.
1618

scripts/test/contracts.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,19 @@ rg -Fq 'prewarm_offline_asr(socket, config, asr_lease)' src/integrations/macos/V
239239
fail "macOS does not prewarm final ASR at recording start"
240240
rg -Fq 'wait_for_asr_prepare(asr_lease' src/integrations/macos/VocoTypeInputController.mm || \
241241
fail "macOS final ASR does not wait for recording-time preparation"
242+
rg -Fq 'kMicrophoneStartupTimeout = std::chrono::seconds(8)' src/desktop/src/audio_recorder_main.cpp || \
243+
fail "macOS microphone startup watchdog must allow the observed >5s deep-idle wakeup"
244+
if rg -Fq 'kMicrophoneStartupTimeout = std::chrono::seconds(5)' src/desktop/src/audio_recorder_main.cpp; then
245+
fail "macOS microphone startup watchdog regressed to the post-idle 5s race"
246+
fi
247+
for token in 'first_audio_started_ns' '--emit-levels' '--no-preview'; do
248+
rg -Fq -- "$token" src/desktop/src/audio_recorder_main.cpp || \
249+
fail "macOS timed recorder no longer measures from real PCM or exposes isolated Playground capture: $token"
250+
done
251+
for token in 'capture_recording_via_helper' '"--emit-levels"' '"--no-preview"'; do
252+
rg -Fq -- "$token" src/desktop/src/settings_backend.cpp || \
253+
fail "macOS Playground capture no longer uses the watchdog-protected recorder helper: $token"
254+
done
242255
mac_install=packaging/macos/install.command
243256
rg -Fq '"$OLD_TOOL" --disable "$IDENTIFIER"' "$mac_install" || \
244257
fail "macOS installer does not disable the old input source before replacement"

src/desktop/src/audio_recorder_main.cpp

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "vocotype/desktop/ipc.hpp"
44
#include "vocotype/desktop/streaming_preview.hpp"
55
#include "vocotype/desktop/wav.hpp"
6+
#include <algorithm>
67
#include <atomic>
78
#include <chrono>
89
#include <condition_variable>
@@ -33,6 +34,8 @@ void emit(const Json &value) {
3334
struct Options {
3435
bool list_devices = false;
3536
bool probe = false;
37+
bool emit_levels = false;
38+
bool preview = true;
3639
int duration_ms = 0;
3740
std::filesystem::path config;
3841
std::string socket = backend_socket_path();
@@ -45,6 +48,10 @@ Options parse(int argc, char **argv) {
4548
options.list_devices = true;
4649
else if (arg == "--probe")
4750
options.probe = true;
51+
else if (arg == "--emit-levels")
52+
options.emit_levels = true;
53+
else if (arg == "--no-preview")
54+
options.preview = false;
4855
else if (arg == "--duration-ms" && i + 1 < argc)
4956
options.duration_ms = std::stoi(argv[++i]);
5057
else if (arg == "--config" && i + 1 < argc)
@@ -53,7 +60,8 @@ Options parse(int argc, char **argv) {
5360
options.socket = argv[++i];
5461
else if (arg == "--help") {
5562
std::cout << "Usage: vocotype-audio-recorder [--list-devices|--probe] "
56-
"[--duration-ms N] [--config PATH] [--socket PATH]\n";
63+
"[--emit-levels] [--no-preview] [--duration-ms N] "
64+
"[--config PATH] [--socket PATH]\n";
5765
std::exit(0);
5866
} else
5967
throw std::runtime_error("unknown argument: " + arg);
@@ -97,7 +105,9 @@ int main(int argc, char **argv) {
97105
std::atomic_bool preview_accepting{true};
98106
int preview_chunk_samples = 9600;
99107

100-
std::thread preview_thread([&] {
108+
std::thread preview_thread;
109+
if (options.preview) {
110+
preview_thread = std::thread([&] {
101111
StreamingPreviewTranscript transcript;
102112
std::deque<std::vector<std::int16_t>> recent_chunks;
103113
constexpr std::size_t kRecentChunkLimit = 3;
@@ -264,12 +274,16 @@ int main(int argc, char **argv) {
264274
}
265275
}
266276
preview_accepting.store(false);
267-
});
277+
});
278+
} else {
279+
preview_accepting.store(false);
280+
}
268281

269282
AudioCapture capture(device, sample_rate, config.block_ms,
270283
input.native_capture_name);
271284
std::string capture_error;
272285
std::atomic_bool first_audio_block{false};
286+
std::atomic_int64_t first_audio_started_ns{0};
273287
std::atomic_bool capture_finished{false};
274288
#ifdef __APPLE__
275289
// CoreAudio can occasionally wedge inside AudioDeviceStart without
@@ -278,28 +292,45 @@ int main(int argc, char **argv) {
278292
// not sufficient here: a timed recording may request stop while the
279293
// capture thread is still blocked inside AudioDeviceStart.
280294
std::thread([&first_audio_block, &capture_finished] {
281-
constexpr auto kMicrophoneStartupTimeout = std::chrono::seconds(5);
295+
// A built-in microphone that has been idle for a long time can take just
296+
// over five seconds to leave its deep CoreAudio power state on macOS 26.
297+
// Keep enough margin to avoid killing the recorder exactly as the HAL
298+
// reports the device Running, while still bounding genuine startup hangs.
299+
constexpr auto kMicrophoneStartupTimeout = std::chrono::seconds(8);
282300
std::this_thread::sleep_for(kMicrophoneStartupTimeout);
283301
if (!first_audio_block.load(std::memory_order_acquire) &&
284302
!capture_finished.load(std::memory_order_acquire)) {
285303
emit({{"type", "error"},
286-
{"error", "麦克风启动超时(CoreAudio 未返回音频);请重试,必要时重启系统音频服务"}});
304+
{"error", "麦克风启动超过 8 秒(CoreAudio 未返回音频);请重试,必要时重启系统音频服务"}});
287305
std::_Exit(2);
288306
}
289307
}).detach();
290308
#endif
291309
std::thread capture_thread([&] {
292310
try {
293311
capture.run(stop, [&](const std::vector<std::int16_t> &block) {
294-
if (!first_audio_block.exchange(true))
312+
if (!first_audio_block.exchange(true)) {
313+
first_audio_started_ns.store(
314+
std::chrono::duration_cast<std::chrono::nanoseconds>(
315+
std::chrono::steady_clock::now().time_since_epoch())
316+
.count(),
317+
std::memory_order_release);
295318
emit({{"type", "recording"},
296319
{"device_id", device.id},
297320
{"device_name", device.name},
298321
{"sample_rate", sample_rate}});
322+
}
299323
{
300324
std::lock_guard lock(samples_mutex);
301325
samples.insert(samples.end(), block.begin(), block.end());
302326
}
327+
if (options.emit_levels && !block.empty()) {
328+
const auto [minimum, maximum] =
329+
std::minmax_element(block.begin(), block.end());
330+
emit({{"type", "level"},
331+
{"minimum", static_cast<double>(*minimum) / 32768.0},
332+
{"maximum", static_cast<double>(*maximum) / 32768.0}});
333+
}
303334
if (preview_accepting.load(std::memory_order_relaxed)) {
304335
auto converted = resample_linear(block, sample_rate, 16000);
305336
std::lock_guard lock(queue_mutex);
@@ -315,13 +346,19 @@ int main(int argc, char **argv) {
315346
capture_finished.store(true, std::memory_order_release);
316347
});
317348

318-
const auto started_at = std::chrono::steady_clock::now();
319349
while (!stop.load()) {
320-
if (options.duration_ms > 0 &&
321-
std::chrono::steady_clock::now() - started_at >=
322-
std::chrono::milliseconds(options.duration_ms))
323-
break;
324350
if (options.duration_ms > 0) {
351+
const std::int64_t audio_started_ns =
352+
first_audio_started_ns.load(std::memory_order_acquire);
353+
if (audio_started_ns > 0) {
354+
const std::int64_t now_ns =
355+
std::chrono::duration_cast<std::chrono::nanoseconds>(
356+
std::chrono::steady_clock::now().time_since_epoch())
357+
.count();
358+
if (now_ns - audio_started_ns >=
359+
static_cast<std::int64_t>(options.duration_ms) * 1000000)
360+
break;
361+
}
325362
std::this_thread::sleep_for(std::chrono::milliseconds(20));
326363
continue;
327364
}
@@ -347,7 +384,8 @@ int main(int argc, char **argv) {
347384
preview_done = true;
348385
}
349386
queue_cv.notify_all();
350-
preview_thread.join();
387+
if (preview_thread.joinable())
388+
preview_thread.join();
351389
throw std::runtime_error(capture_error);
352390
}
353391

@@ -364,7 +402,8 @@ int main(int argc, char **argv) {
364402
preview_done = true;
365403
}
366404
queue_cv.notify_all();
367-
preview_thread.join();
405+
if (preview_thread.joinable())
406+
preview_thread.join();
368407
throw std::runtime_error("recording produced no audio samples");
369408
}
370409

src/desktop/src/settings_backend.cpp

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,93 @@ Json run_process(const std::vector<std::string> &arguments) {
351351
{"error", exit_code == 0 ? "" : output}};
352352
}
353353

354+
#ifdef __APPLE__
355+
Json capture_recording_via_helper(int duration_ms,
356+
const WaveformCallback &callback) {
357+
const auto recorder = runtime_root() / "bin/vocotype-audio-recorder";
358+
if (!std::filesystem::is_regular_file(recorder))
359+
return {{"success", false}, {"error", "找不到原生录音器"}};
360+
361+
int output_pipe[2]{};
362+
if (vocotype::common::create_pipe_close_on_exec(output_pipe) != 0)
363+
return {{"success", false}, {"error", "无法创建录音器输出管道"}};
364+
365+
const pid_t child = ::fork();
366+
if (child < 0) {
367+
::close(output_pipe[0]);
368+
::close(output_pipe[1]);
369+
return {{"success", false}, {"error", "无法启动原生录音器"}};
370+
}
371+
if (child == 0) {
372+
::dup2(output_pipe[1], STDOUT_FILENO);
373+
::close(output_pipe[0]);
374+
::close(output_pipe[1]);
375+
const std::string duration = std::to_string(std::max(1, duration_ms));
376+
const std::string config = runtime_config_path().string();
377+
::execl(recorder.c_str(), recorder.c_str(), "--duration-ms",
378+
duration.c_str(), "--config", config.c_str(), "--emit-levels",
379+
"--no-preview", static_cast<char *>(nullptr));
380+
::_exit(127);
381+
}
382+
383+
::close(output_pipe[1]);
384+
std::string error;
385+
Json audio = Json::object();
386+
FILE *file = ::fdopen(output_pipe[0], "r");
387+
if (file) {
388+
char *line = nullptr;
389+
std::size_t capacity = 0;
390+
while (::getline(&line, &capacity, file) >= 0) {
391+
try {
392+
const Json event = Json::parse(line);
393+
const std::string type = event.value("type", "");
394+
if (type == "level") {
395+
if (callback)
396+
callback(event.value("minimum", 0.0),
397+
event.value("maximum", 0.0));
398+
} else if (type == "audio") {
399+
audio = {{"success", true},
400+
{"path", event.value("path", "")},
401+
{"sample_rate", event.value("sample_rate", 0)},
402+
{"frames", event.value("frames", std::size_t{0})},
403+
{"device_id", event.value("device_id", -1)},
404+
{"device", event.value("device_name", "")}};
405+
} else if (type == "error") {
406+
error = event.value("error", "录音失败");
407+
}
408+
} catch (const std::exception &) {
409+
}
410+
}
411+
std::free(line);
412+
::fclose(file);
413+
} else {
414+
::close(output_pipe[0]);
415+
}
416+
417+
int status = 0;
418+
while (::waitpid(child, &status, 0) < 0 && errno == EINTR) {
419+
}
420+
if (!audio.empty() && WIFEXITED(status) && WEXITSTATUS(status) == 0)
421+
return audio;
422+
if (error.empty()) {
423+
if (WIFEXITED(status))
424+
error = "原生录音器退出,状态码 " + std::to_string(WEXITSTATUS(status));
425+
else
426+
error = "原生录音器异常退出";
427+
}
428+
return {{"success", false}, {"error", error}};
429+
}
430+
#endif
431+
354432
Json capture_recording(int duration_ms, const WaveformCallback &callback) {
433+
#ifdef __APPLE__
434+
// Keep CoreAudio in a disposable helper process. A deep-idle microphone can
435+
// need more than five seconds before the first PCM callback, while a truly
436+
// wedged AudioDeviceStart cannot be safely cancelled from inside Settings.
437+
// The helper owns the bounded startup watchdog and starts the requested
438+
// duration only after the first real audio block arrives.
439+
return capture_recording_via_helper(duration_ms, callback);
440+
#else
355441
try {
356442
const auto config = load_audio_config();
357443
const auto device = resolve_input_device(config);
@@ -395,6 +481,7 @@ Json capture_recording(int duration_ms, const WaveformCallback &callback) {
395481
} catch (const std::exception &error) {
396482
return {{"success", false}, {"error", error.what()}};
397483
}
484+
#endif
398485
}
399486

400487
Json play_recording(const std::filesystem::path &path, int output_device_id) {

0 commit comments

Comments
 (0)