[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-43":3},{"id":4,"title":5,"title_en":6,"abstract":7,"abstract_en":8,"content":9,"content_en":10,"category":11,"banner_id":12,"banner_path":13,"tags":14,"is_recommend":16,"prev_article":17,"next_article":21,"created_at":25},43,"避坑指南:桌面端 WebRTC 音频兼容性：为什么 AirPods 接上就\"没声音\"？","Pitfall Guide: Desktop WebRTC Audio Compatibility: Why Do AirPods Produce 'No Sound' When Connected?","做桌面 IM 的音视频通话功能时，我们遇到过这样一个诡异的反馈：\n> “用笔记本内置麦克风通话完全正","When working on the audio and video calling feature for desktop IM, we encountered this bizarre feedback","做桌面 IM 的音视频通话功能时，我们遇到过这样一个诡异的反馈：\n> “用笔记本内置麦克风通话完全正常，但戴上 AirPods 之后，对方听不到我的声音。”\n\n这不是偶尔听不到，是每次都听不到。更奇怪的是：**本地预览有波形、设备枚举能找到 AirPods、`getUserMedia` 也没报错**——一切看起来完全正常，但推流出去就是静音的。\n\n排查了一上午，最终定位到原因：**浏览器的软件降噪和蓝牙耳机的硬件降噪在“打架”**。\n\n---\n\n## 一、 先搞清楚音频链路长什么样\n\n在桌面端发起一个 WebRTC 音频流，信号要经过这样一条链路：\n\n$$\\text{物理麦克风} \\longrightarrow \\text{驱动层} \\longrightarrow \\text{操作系统音频子系统} \\longrightarrow \\text{浏览器 AudioContext} \\longrightarrow \\text{WebRTC 编码} \\longrightarrow \\text{网络传输}$$\n\n其中在浏览器这一层，WebRTC 提供了几个可配置的 **DSP（数字信号处理）** 开关：\n\n| 参数 | 作用 |\n| :--- | :--- |\n| `echoCancellation` | **回声消除（AEC）**：抑制自己说话被扬声器播放后又被麦克风录到的回声 |\n| `noiseSuppression` | **噪声抑制（ANS）**：滤除背景噪音 |\n| `autoGainControl` | **自动增益控制（AGC）**：把音量拉到统一水平 |\n\n这些参数在 `getUserMedia` 的约束对象里配置：\n\n```javascript\nconst constraints = {\n  audio: {\n    echoCancellation: true,\n    noiseSuppression: true,\n    autoGainControl: true\n  }\n}\nconst stream = await navigator.mediaDevices.getUserMedia(constraints)\n```\n\n对于内置麦克风来说，开启这三个选项几乎是标准做法——内置麦克风的信噪比低、离扬声器近，确实需要这些辅助算法。但问题出在：**这套逻辑对蓝牙耳机并不适用。**\n\n---\n\n## 二、 AirPods 不是“普通麦克风”\n\n### 2.1 蓝牙耳机自带硬件 DSP\n以 AirPods 为代表的现代蓝牙 TWS 耳机，内部集成了完整的音频处理芯片：\n* **ENC（环境噪声抑制）**：通过双麦阵列做波束成形，在硬件层面过滤背景噪声。\n* **AEC 回声消除**：耳机同时处理输入和输出通道，在芯片内部完成回声对消。\n* **AGC 自动增益**：固件级别动态调整输入增益。\n\n也就是说，降噪这件事，已经在耳机硬件内部完成了。\n\n### 2.2 双重降噪 = 静音\n当你在代码里开启了软件降噪，而用户戴的是 AirPods 时，音频链路变成了：\n\n$$\\text{AirPods 硬件 AEC\u002FANS} \\longrightarrow \\text{浏览器软件 AEC\u002FANS} \\longrightarrow \\text{WebRTC 编码}$$\n\n两层降噪算法处理的都是同一份音频信号，且它们之间没有协调机制。结果就是：\n1. 硬件降噪已经把“非语音”部分压得很低了。\n2. 浏览器软件降噪看到的是一个“几乎没声音”的原始信号，误以为是噪音，于是**进一步把它当成噪声压制**。\n3. 最终输出接近静音。\n\n> [!WARNING]\n> 这就像两个人同时在调同一个音量旋钮——一个人往上拧，一个人往下拧，最终结果是声音彻底没了。\n\n### 2.3 为什么移动端没有这个问题？\niOS 和 Android 系统会自动识别蓝牙耳机的类型并调整音频路由策略。例如，iOS 在检测到 HFP\u002FHSP 协议连接时，会自动关闭系统底层的软件 AEC。\n\n但 **桌面端浏览器没有这个能力**。 Chrome\u002FChromium（以及基于它的 Electron）在调用 `getUserMedia` 时，不会去判断当前设备的硬件能力，它只看你传了什么 `constraints`。你开了降噪，它就开；你不开，它就不开。这也是为什么同一个 AirPods，手机端 App 通话正常，Electron 桌面端却静音的原因。\n\n---\n\n## 三、 解决方案：根据设备类型动态切换策略\n\n核心思路很简单：**先探测用户当前用的是不是蓝牙耳机。如果是，就把浏览器的降噪全部关掉，把音频处理权全权交给耳机本身。**\n\n### 3.1 设备探测\n通过枚举 `MediaDevices` 来识别设备标签（Label）：\n\n```typescript\nfunction isBluetoothHeadset(label?: string): boolean {\n  if (!label) return false\n  const lower = label.toLowerCase()\n\n  const keywords = [\n    'airpods', 'bluetooth', 'hands-free',\n    'headset', 'headphone', 'earbuds', 'beats', 'freebuds'\n  ]\n\n  return keywords.some(k => lower.includes(k))\n}\n\n\u002F\u002F 获取所有的音频输入和输出设备\nconst inputs = await navigator.mediaDevices.enumerateDevices()\n  .then(devices => devices.filter(d => d.kind === 'audioinput'))\n\nconst outputs = await navigator.mediaDevices.enumerateDevices()\n  .then(devices => devices.filter(d => d.kind === 'audiooutput'))\n```\n\n> [!IMPORTANT]\n> **关键点**：不仅要看输入设备，还要看输出设备。用户的实际使用场景通常是：**“我用 AirPods 听声音（输出），我也希望用它收音（输入）。”**\n> 因此，如果默认输出设备是蓝牙耳机，我们应当优先选择对应的蓝牙输入设备。\n\n```javascript\nasync function resolvePreferredInput() {\n  const [inputs, outputs] = await Promise.all([\n    getAudioInputs(),\n    getAudioOutputs()\n  ])\n\n  \u002F\u002F 判断当前默认输出是否为蓝牙设备\n  const defaultOutput = outputs.find(\n    d => d.deviceId === 'default' || d.deviceId === 'communications'\n  )\n  const preferBluetooth = isBluetoothHeadset(defaultOutput?.label)\n\n  \u002F\u002F 查找蓝牙输入设备\n  const bluetoothInput = inputs.find(d => isBluetoothHeadset(d.label))\n  const defaultInput = inputs.find(\n    d => d.deviceId === 'default' || d.deviceId === 'communications'\n  )\n\n  \u002F\u002F 路由策略：输出是蓝牙 -> 优先用蓝牙输入；否则用系统默认输入\n  const preferred =\n    (preferBluetooth ? bluetoothInput : null) ||\n    defaultInput ||\n    bluetoothInput ||\n    null\n\n  return { isBluetooth: !!preferBluetooth, device: preferred }\n}\n```\n\n### 3.2 动态配置约束（Constraints）\n拿到设备信息后，根据是否为蓝牙设备生成不同的 `getUserMedia` 约束：\n\n```javascript\nconst { isBluetooth, device } = await resolvePreferredInput()\n\nconst captureOptions = {\n  deviceId: { exact: device.deviceId },\n  \u002F\u002F 关键差异在这里 👇\n  autoGainControl: !isBluetooth,\n  echoCancellation: !isBluetooth,\n  noiseSuppression: !isBluetooth,\n  voiceIsolation: !isBluetooth \u002F\u002F 针对支持语音隔离的新系统\n}\n```\n* **蓝牙耳机**：四项降噪控制全关，让硬件 DSP 全权处理。\n* **内置\u002F普通麦克风**：四项全开，靠浏览器软件算法补足。\n\n### 3.3 Probe 探针：激活硬件通道\n还有一个容易被忽略的细节：**macOS 对蓝牙音频设备的激活是延迟的**。有时候设备虽然出现在列表里，但实际上音频物理通道还没真正唤醒。\n\n在正式发布音轨前，我们可以先做一个短暂的“探针”请求：\n\n```javascript\nasync function probeMicrophone(constraints) {\n  try {\n    const stream = await navigator.mediaDevices.getUserMedia({\n      audio: constraints,\n      video: false\n    })\n    \u002F\u002F 立即停止所有音轨，目的是“唤醒”设备通道，不产生实际音频流\n    stream.getTracks().forEach(track => track.stop())\n    return true\n  } catch (error) {\n    console.warn('探针失败，设备可能不可用:', error)\n    throw error\n  }\n}\n\n\u002F\u002F 实际使用流程\nawait probeMicrophone(captureOptions) \u002F\u002F 先唤醒并探测\nconst publication = await room.localParticipant.setMicrophoneEnabled(true, captureOptions)\n```\n* 如果权限被拒或设备占用，在进入通话前就能捕获报错，而不是在 SDK 内部抛出难以定位的异常。\n* 强制触发 macOS 的音频路由切换，确保蓝牙通道真正激活。\n* 探针通常在几十毫秒内完成，用户感知不到任何延迟。\n\n---\n\n## 四、 完整实现架构\n\n把以上环节串起来，整个音频初始化流程如下：\n\n```mermaid\ngraph TD\n    A[用户点击“开始通话”] --> B[1. 设备探测\u003Cbr\u002F>枚举输入\u002F输出设备\u003Cbr\u002F>识别是否为蓝牙耳机]\n    B --> C[2. 约束适配\u003Cbr\u002F>生成 captureOptions\u003Cbr\u002F>蓝牙 -> 关降噪\u003Cbr\u002F>其他 -> 开降噪]\n    C --> D[3. 激活探针\u003Cbr\u002F>getUserMedia 唤醒设备通道\u003Cbr\u002F>失败则抛出友好提示]\n    D --> E[4. 轨道发布\u003Cbr\u002F>LiveKit\u002FWebRTC 正式发布音频\u003Cbr\u002F>进入通话房间]\n```\n\n---\n\n## 五、 踩过的坑\n\n### 坑 1：`deviceId: 'default'` 不等于当前“系统默认”\n在 Chrome 中，`default` 是一个虚拟设备 ID。当你直接把 `{ deviceId: { exact: 'default' } }` 传给 `getUserMedia` 时，它可能解析到的是上一次缓存的设备，而不是系统当前实际的默认设备。\n* **正确做法**：\n  ```javascript\n  deviceId: preferredDevice?.deviceId !== 'default'\n    ? { exact: preferredDevice.deviceId }\n    : { ideal: 'default' }\n  ```\n\n### 坑 2：`enumerateDevices()` 需要先授权\n在用户授予麦克风权限之前，`enumerateDevices()` 返回的设备列表中，`label` 字段全是**空字符串**。因此，设备探测必须放在权限获取之后（可以先用默认约束 `getUserMedia` 获取权限，再重新枚举设备并做第二次精确采集）。\n\n### 坑 3：不同品牌的蓝牙关键词不同\nAirPods 的 label 可能是 `\"AirPods Pro\"`、`\"AirPods-Left\"`；华为 FreeBuds 叫 `\"HUAWEI FreeBuds 4\"`；索尼则带有 `\"WH-1000XM\"`。\n* **解决思路**：关键词库需要持续维护，或者添加兜底逻辑：如果设备名称包含 `\"headset\"`、`\"ear\"`、`\"buds\"`、`\"hands-free\"` 等通用词，一律按蓝牙设备处理。\n\n---\n\n## 六、 总结\n\n桌面端 WebRTC 的音频兼容性是个经常被忽视但极其影响体验的话题。\n\n问题的本质在于：**浏览器把所有音频输入设备当成了“裸麦克风”对待，试图套用同一套软件降噪（DSP）策略。但现代蓝牙耳机早就不只是“麦克风”，而是自带完整音频处理管线的小型计算机。**\n\n解决思路其实并不复杂，只需三步：\n1. **识别**：通过设备枚举和标签匹配，判断是否使用了蓝牙耳机。\n2. **适配**：如果是蓝牙设备则关掉浏览器的降噪，由耳机硬件全权打理。\n3. **探针**：发布前进行一次极短的采集唤醒，提前暴露权限和通道未激活问题。\n\n这三步加起来不到一百行代码，但能把 AirPods 等主流蓝牙耳机在桌面端的通话体验从“完全不能用”提升到“和手机端一样好用”。\n","When working on the audio and video calling feature for desktop IM, we encountered this bizarre feedback:\n> \"Calling with the laptop's built-in microphone works perfectly fine, but after putting on AirPods, the other party cannot hear my voice.\"\n\nThis was not an occasional silence; it happened every single time. What was even stranger: **local preview had waveforms, device enumeration could find the AirPods, and `getUserMedia` did not report any errors** —— everything looked completely normal, yet the stream pushed out was silent.\n\nAfter investigating for a whole morning, we finally located the cause: **the browser's software noise suppression and the Bluetooth headset's hardware noise cancellation were \"clashing\"**.\n\n---\n\n## 1. First, Understand What the Audio Link Looks Like\n\nTo initiate a WebRTC audio stream on the desktop, the signal goes through the following path:\n\n$$\\text{Physical Microphone} \\longrightarrow \\text{Driver Layer} \\longrightarrow \\text{OS Audio Subsystem} \\longrightarrow \\text{Browser AudioContext} \\longrightarrow \\text{WebRTC Encoding} \\longrightarrow \\text{Network Transmission}$$\n\nWithin the browser layer, WebRTC provides several configurable **DSP (Digital Signal Processing)** switches:\n\n| Parameter | Function |\n| :--- | :--- |\n| `echoCancellation` | **Acoustic Echo Cancellation (AEC)**: Suppresses echoes of one's own voice played by speakers and re-recorded by the microphone |\n| `noiseSuppression` | **Acoustic Noise Suppression (ANS)**: Filters out background noise |\n| `autoGainControl` | **Automatic Gain Control (AGC)**: Levelizes the volume to a standardized level |\n\nThese parameters are configured in the constraints object of `getUserMedia`:\n\n```javascript\nconst constraints = {\n  audio: {\n    echoCancellation: true,\n    noiseSuppression: true,\n    autoGainControl: true\n  }\n}\nconst stream = await navigator.mediaDevices.getUserMedia(constraints)\n```\n\nFor built-in microphones, enabling these three options is almost standard practice —— built-in microphones have low signal-to-noise ratios and are close to speakers, making these auxiliary algorithms necessary. However, the problem lies in the fact that: **this logic does not apply to Bluetooth headsets.**\n\n---\n\n## 2. AirPods Are Not \"Ordinary Microphones\"\n\n### 2.1 Bluetooth Headsets Have Built-in Hardware DSP\nModern Bluetooth TWS headsets, represented by AirPods, integrate complete audio processing chips:\n* **ENC (Environmental Noise Cancellation)**: Uses dual-mic arrays for beamforming to filter out background noise at the hardware level.\n* **AEC (Acoustic Echo Cancellation)**: The headset processes input and output channels simultaneously to achieve echo cancellation inside the chip.\n* **AGC (Automatic Gain Control)**: Dynamically adjusts input gain at the firmware level.\n\nIn other words, noise cancellation is already completed inside the headset hardware.\n\n### 2.2 Double Noise Cancellation = Mute\nWhen you enable software noise cancellation in your code and the user wears AirPods, the audio link becomes:\n\n$$\\text{AirPods Hardware AEC\u002FANS} \\longrightarrow \\text{Browser Software AEC\u002FANS} \\longrightarrow \\text{WebRTC Encoding}$$\n\nTwo layers of noise cancellation algorithms process the exact same audio signal without any coordination mechanism. The result is:\n1. Hardware noise cancellation has already compressed the \"non-speech\" part very low.\n2. The browser's software noise suppression sees a raw signal that is \"nearly silent\", misinterprets it as noise, and **subsequently suppresses it further as noise**.\n3. The final output is close to mute.\n\n> [!WARNING]\n> This is like two people adjusting the same volume knob at the same time —— one turning it up and the other turning it down, resulting in the sound disappearing completely.\n\n### 2.3 Why Does This Problem Not Exist on Mobile?\niOS and Android systems automatically identify the type of Bluetooth headset and adjust the audio routing strategy. For example, when iOS detects an HFP\u002FHSP protocol connection, it automatically disables the system's underlying software AEC.\n\nHowever, **desktop browsers do not have this capability**. When calling `getUserMedia`, Chrome\u002FChromium (and Electron based on it) does not evaluate the current device's hardware capabilities; it only respects the `constraints` you pass. If you enable noise cancellation, it enables it; if you don't, it doesn't. This is why the same AirPods work fine on mobile apps but result in silence on Electron desktop clients.\n\n---\n\n## 3. Solution: Dynamically Switch Strategies Based on Device Type\n\nThe core idea is simple: **First, detect if the user's current device is a Bluetooth headset. If it is, completely disable the browser's noise cancellation and hand the audio processing rights entirely over to the headset itself.**\n\n### 3.1 Device Detection\nIdentify device labels (Label) by enumerating `MediaDevices`:\n\n```typescript\nfunction isBluetoothHeadset(label?: string): boolean {\n  if (!label) return false\n  const lower = label.toLowerCase()\n\n  const keywords = [\n    'airpods', 'bluetooth', 'hands-free',\n    'headset', 'headphone', 'earbuds', 'beats', 'freebuds'\n  ]\n\n  return keywords.some(k => lower.includes(k))\n}\n\n\u002F\u002F Get all audio input and output devices\nconst inputs = await navigator.mediaDevices.enumerateDevices()\n  .then(devices => devices.filter(d => d.kind === 'audioinput'))\n\nconst outputs = await navigator.mediaDevices.enumerateDevices()\n  .then(devices => devices.filter(d => d.kind === 'audiooutput'))\n```\n\n> [!IMPORTANT]\n> **Key Point**: Inspect not only input devices but also output devices. The user's actual use case is usually: **\"I listen with AirPods (output), and I also want to capture audio with them (input).\"**\n> Therefore, if the default output device is a Bluetooth headset, we should prioritize choosing the corresponding Bluetooth input device.\n\n```javascript\nasync function resolvePreferredInput() {\n  const [inputs, outputs] = await Promise.all([\n    getAudioInputs(),\n    getAudioOutputs()\n  ])\n\n  \u002F\u002F Determine if the current default output is a Bluetooth device\n  const defaultOutput = outputs.find(\n    d => d.deviceId === 'default' || d.deviceId === 'communications'\n  )\n  const preferBluetooth = isBluetoothHeadset(defaultOutput?.label)\n\n  \u002F\u002F Find the Bluetooth input device\n  const bluetoothInput = inputs.find(d => isBluetoothHeadset(d.label))\n  const defaultInput = inputs.find(\n    d => d.deviceId === 'default' || d.deviceId === 'communications'\n  )\n\n  \u002F\u002F Routing strategy: output is Bluetooth -> prioritize Bluetooth input; otherwise, use system default input\n  const preferred =\n    (preferBluetooth ? bluetoothInput : null) ||\n    defaultInput ||\n    bluetoothInput ||\n    null\n\n  return { isBluetooth: !!preferBluetooth, device: preferred }\n}\n```\n\n### 3.2 Dynamic Configuration of Constraints\nAfter obtaining the device information, generate different `getUserMedia` constraints based on whether it is a Bluetooth device:\n\n```javascript\nconst { isBluetooth, device } = await resolvePreferredInput()\n\nconst captureOptions = {\n  deviceId: { exact: device.deviceId },\n  \u002F\u002F Key difference is here 👇\n  autoGainControl: !isBluetooth,\n  echoCancellation: !isBluetooth,\n  noiseSuppression: !isBluetooth,\n  voiceIsolation: !isBluetooth \u002F\u002F For new operating systems supporting Voice Isolation\n}\n```\n* **Bluetooth Headset**: All four noise suppression controls are off, letting the hardware DSP take full charge.\n* **Built-in\u002FOrdinary Microphone**: All four options are enabled, relying on the browser's software algorithms to compensate.\n\n### 3.3 Probe: Activating Hardware Channels\nThere is another detail that is easily overlooked: **macOS activation of Bluetooth audio devices is delayed**. Sometimes, although the device appears in the list, the physical audio channel has not actually been woken up yet.\n\nBefore officially publishing the audio track, we can first make a brief \"probe\" request:\n\n```javascript\nasync function probeMicrophone(constraints) {\n  try {\n    const stream = await navigator.mediaDevices.getUserMedia({\n      audio: constraints,\n      video: false\n    })\n    \u002F\u002F Stop all audio tracks immediately to \"wake up\" the device channel without producing an actual audio stream\n    stream.getTracks().forEach(track => track.stop())\n    return true\n  } catch (error) {\n    console.warn('Probe failed, the device may be unavailable:', error)\n    throw error\n  }\n}\n\n\u002F\u002F Actual usage flow\nawait probeMicrophone(captureOptions) \u002F\u002F Wake up and probe first\nconst publication = await room.localParticipant.setMicrophoneEnabled(true, captureOptions)\n```\n* If permissions are denied or the device is occupied, errors can be caught before entering the call, rather than throwing hard-to-locate exceptions inside the SDK.\n* Force triggering macOS audio routing switch to ensure the Bluetooth channel is truly activated.\n* Probing usually completes within tens of milliseconds, completely imperceptible to users.\n\n---\n\n## 4. Complete Implementation Architecture\n\nTying the above steps together, the entire audio initialization process is as follows:\n\n```mermaid\ngraph TD\n    A[User clicks \"Start Call\"] --> B[1. Device Detection\u003Cbr\u002F>Enumerate input\u002Foutput devices\u003Cbr\u002F>Identify if Bluetooth headset]\n    B --> C[2. Constraint Adaptation\u003Cbr\u002F>Generate captureOptions\u003Cbr\u002F>Bluetooth -> Disable noise suppression\u003Cbr\u002F>Other -> Enable noise suppression]\n    C --> D[3. Activate Probe\u003Cbr\u002F>getUserMedia wakes up device channel\u003Cbr\u002F>Friendly tip thrown if failed]\n    D --> E[4. Track Publication\u003Cbr\u002F>LiveKit\u002FWebRTC officially publishes audio\u003Cbr\u002F>Enter call room]\n```\n\n---\n\n## 5. Pitfalls Encountered\n\n### Pitfall 1: `deviceId: 'default'` Does Not Equal Current \"System Default\"\nIn Chrome, `default` is a virtual device ID. When you pass `{ deviceId: { exact: 'default' } }` directly to `getUserMedia`, it may resolve to the previously cached device rather than the system's actual current default device.\n* **Correct Practice**:\n  ```javascript\n  deviceId: preferredDevice?.deviceId !== 'default'\n    ? { exact: preferredDevice.deviceId }\n    : { ideal: 'default' }\n  ```\n\n### Pitfall 2: `enumerateDevices()` Requires Prior Authorization\nBefore the user grants microphone permissions, the `label` fields in the device list returned by `enumerateDevices()` are all **empty strings**. Therefore, device detection must be placed after permissions acquisition (you can first get permissions with a default constraint `getUserMedia`, then re-enumerate devices and perform the second accurate capture).\n\n### Pitfall 3: Different Bluetooth Brands Use Different Keywords\nAirPods' labels might be `\"AirPods Pro\"`, `\"AirPods-Left\"`; Huawei FreeBuds is named `\"HUAWEI FreeBuds 4\"`; Sony contains `\"WH-1000XM\"`.\n* **Solution**: The keyword library needs continuous maintenance, or fallback logic should be added: if the device name contains generic terms like `\"headset\"`, `\"ear\"`, `\"buds\"`, `\"hands-free\"`, treat them all as Bluetooth devices.\n\n---\n\n## 6. Summary\n\nAudio compatibility of WebRTC on desktop is an issue that is often overlooked but severely impacts user experience.\n\nThe essence of the problem is: **the browser treats all audio input devices as \"bare microphones\", attempting to apply the same software noise suppression (DSP) strategy. However, modern Bluetooth headsets are no longer just \"microphones\", but small computers equipped with complete audio processing pipelines.**\n\nThe solution is not complex, requiring only three steps:\n1. **Identify**: Determine if a Bluetooth headset is being used through device enumeration and label matching.\n2. **Adapt**: If it is a Bluetooth device, disable the browser's noise cancellation and let the headset hardware manage it entirely.\n3. **Probe**: Perform an extremely short capture wake-up before publishing to expose permission issues and unactivated channels in advance.\n\nThese three steps add up to less than a hundred lines of code, but they can elevate the desktop calling experience of mainstream Bluetooth headsets like AirPods from \"completely unusable\" to \"as good as on mobile\".\n","前端",27,"https:\u002F\u002Fblog4-1316398321.cos.ap-nanjing.myqcloud.com\u002Fblog5\u002F20260621004623__春日-树-白色花朵.png",[15],"Vue",false,{"id":18,"title":19,"title_en":20},42,"从零搭建多币种支付系统：汇率动态转换实战","Building a multi-currency payment system from scratch: Practical practice of dynamic exchange rate conversion",{"id":22,"title":23,"title_en":24},44,"AI 系统出问题时，我如何用 TraceID 把黑盒拆开","When Your AI System Breaks: How I Used TraceID to Debug a Black Box","2026-06-20T23:39:07.502329+08:00"]