# Vext 1.2.0: Diarization สองรอบ, Event Tap ซ่อมตัวเอง และห้าภาษา

> บทวิเคราะห์ด้านวิศวกรรมของ Vext 1.2.0 ทำไม streaming diarization อย่างเดียวถึงพังเมื่อพูดสลับกันเร็ว ทำไมเราถึงเอา API echo cancellation ของ Apple ออกแทนที่จะสู้กับมัน และ event tap ระดับ global สามารถโกหกเรื่องสถานะหลังจากหลับได้อย่างไร

[Vext 1.1](/blog/introducing-vext-voice-to-text-for-mac) ออกไปสามอาทิตย์ก่อน **Vext 1.2.0** ลงสนามวันนี้

[**getvext.app**](https://getvext.app) · 1.1.0 → **1.2.0**

[ประกาศ 1.2.0 สำหรับผู้ใช้](https://getvext.app/blog/vext-1-2-0-multilanguage-speakers) ครอบคลุมสิ่งใหม่ในแอป — แท็บ Speakers, UI หลายภาษาเต็มรูปแบบ, transcript ประชุมที่ชัดขึ้น โพสต์นี้คือส่วนวิศวกรรม: ปัญหาที่ยากจริง ๆ ในการ ship และตอนนี้มันหน้าตาอย่างไรในโค้ด

---

## Streaming diarization ไม่พอ — แม้แต่ตอนที่มัน "ถูกต้อง"

การถอดเสียงประชุมแบบ real-time มีอิสระหนึ่งองศาที่เราไม่สามารถกู้คืนได้ภายหลัง: มันได้ยินเสียงครั้งเดียว ตามลำดับ Streaming diarizer กำหนดป้าย speaker เดียวให้แต่ละ VAD chunk โดยใช้ embedding หนึ่งต่อหนึ่ง chunk นั่นเป็นการประมาณที่ดีเมื่อคนพูดสลับกัน แต่มันพังเมื่อไม่ได้พูดสลับกัน

สอง speaker พูดทับกันสิบวินาทีไม่ควรกลายเป็น "Speaker 1 สิบวินาที"

1.2.0 เก็บ streaming pass ไว้ — transcript ประชุมยังคงปรากฏแบบ live chunk ต่อ chunk — แต่หลังจากประชุมเสร็จและบันทึก transcript ชั่วคราวแล้ว Vext รัน diarization รอบสองบนไฟล์ WAV archive ต่อ stream offline pipeline คือ:

- **pyannote Community-1** สำหรับ segmentation
- **WeSpeaker** embeddings พร้อม overlap-frame masking
- **VBx** Bayesian refinement เพื่อรวม cluster

Offline pass re-attribute ทุก chunk ไปยัง cluster ที่ถูกต้องในระดับ global เมื่อจำ speaker ที่รู้จักได้ embedding ของพวกเขาอัปเดตในฐานข้อมูล — ประชุมครั้งหน้าจะจำได้เร็วขึ้น Vext ลบ WAV archive ชั่วคราวหลังจาก refinement เสร็จ

คุณเห็นผลลัพธ์จาก streaming ระหว่างประชุม คุณอ่านผลลัพธ์ที่ refined หลังจากนั้น สองอย่างนี้ไม่ใช่ artifact เดียวกัน — และนั่นตั้งใจแล้ว

---

## Chunk หลาย speaker: ตัดแทนการติดป้าย

อีกครึ่งของเรื่อง diarization เกิดขึ้นภายใน chunk เดียว

Sortformer ส่ง speaker timeline ต่อ frame ถ้ามี speaker index ต่างกันสองตัวขึ้นไปใน VAD chunk เดียว การถอดเสียงทั้งก้อนจะบังคับให้โมเดลโยน attribute ทุกอย่างให้เสียงเดียว chunk 30 วินาทีที่มีการโต้ตอบเร็ว ๆ กลายเป็นบล็อก transcript บล็อกเดียวกับป้าย speaker เดียว — สองเสียงรวมเป็นหนึ่ง

1.2.0 ตัดเสียงที่จุดเปลี่ยน speaker และถอดเสียงแต่ละรอบอย่างอิสระ หนึ่ง chunk เข้า N chunk ออก — แต่ละตัวมีป้าย speaker ของตัวเอง แต่ละตัวถอดเสียงเป็น utterance แยก

รายละเอียดที่ใช้เวลานานกว่าที่ควร: Sortformer ยิง flicker รบกวนต่ำกว่า 300ms — frame เดียวที่ attribute ให้ speaker อื่นกลางคำพูด การตัดทุก flicker ทำให้ transcript แตกกระจายและสร้าง turn ผีขึ้นมา ตอนนี้บริเวณต่ำกว่า 300ms ถูกดูดซับเข้าใน run ที่ยาวที่สุดที่อยู่ติดกันก่อนที่ slicer จะทำงาน ดังนั้นการตัดที่เราทำคือการตัดที่มีอยู่จริง

---

## Bug ทำให้ไมโครโฟนเป็นพิษ

`setVoiceProcessingEnabled` ของ Apple บน `AVAudioInputNode` ทำสิ่งที่บอกไว้: AGC, noise suppression, echo cancellation มันยังทำบางอย่างที่ docs ไม่เน้น — มัน mutate shared HAL state บน input device

เปิดใน Vext และทุกแอปที่อ่าน microphone เดียวกัน — Zoom, FaceTime, OBS, ทุก recorder — ก็เห็น AGC และ noise suppression ถูก apply กับ feed ของพวกเขาด้วย เสียงของผู้ใช้ฟังดูไกลและ gain ลดลงในการโทรที่พวกเขา in จริง ๆ ปิดมันและคุณได้ปัญหาเดียวกันในทางกลับกันเมื่อแอปอื่นเปิดมันครั้งถัดไป

สัญชาตญาณคือสู้กับ API — push off แล้ว on กลับ hold lock restore state คำตอบที่ถูกคือ "ไม่ใช้มันที่นี่เลย" Vext capture ผู้เข้าร่วมประชุมผ่าน system-audio process tap แยกต่างหาก ไม่ใช่ผ่าน microphone path mic stream และ system stream แยกจากกันทางกายภาพ Echo cancellation ระหว่างพวกมันไม่เคยจำเป็น มันแก้ปัญหาที่ไม่มีอยู่ใน architecture นี้

1.2.0 เอา call ออก Shared HAL state ไม่ถูกรบกวนอีกต่อไป

---

## Event tap ที่โกหก

Global keyboard event tap — สิ่งที่ทำให้กดค้าง hotkey แล้วพูดทำงานได้ — มี failure mode ที่ควรอธิบาย เพราะมันใช้เวลานานในการตามหา

หลังจาก display sleep, system sleep หรือ fast user switching mach port ที่รอง tap ไว้อาจกลายเป็น stale `CGEventTapIsEnabled` ยังคง return `true` Events ถูก drop อย่างเงียบ ผู้ใช้กด hotkey ค้างไว้ ไม่มีอะไรเกิดขึ้น รีสตาร์ทแอปแก้ได้ ไม่มีอะไรใน log อธิบาย

1.2.0 ซ่อมตัวเอง:

- เราตอนนี้ subscribe `NSWorkspace.didWakeNotification`, `screensDidWakeNotification` และ `sessionDidBecomeActiveNotification` แต่ละตัว trigger การติดตั้ง tap ใหม่ทั้งหมด — ไม่ใช่ re-enable แต่เป็น recreate
- เมื่อระบบ fire `tapDisabledByTimeout` เราตรวจสอบว่า re-enable ได้ผลจริง ถ้าไม่ได้ก็รัน full reinstall เดิม
- Health-check timer ย้ายไปยัง `.common` run-loop modes — เราย้ายมันเพราะก่อนหน้านี้มันถูก block ระหว่าง menu tracking และ drag operations ซึ่งเป็นช่วงที่ stale tap มีโอกาสสูงสุดที่จะเป็น interaction ถัดไปของผู้ใช้

มันไม่ใช่โค้ดหรูหรา แต่เป็นโค้ดที่ตัดสินว่าแอปน่าเชื่อถือพอที่จะอยู่ใน menu bar ของคุณหรือเปล่า

---

## Path เดียวสำหรับ trigger

Vext สามารถเริ่ม dictation, note หรือ meeting จาก keyboard hotkey หรือ menu bar ใน 1.1 มันเป็นสอง code path พวกมัน drift กัน

Hotkey path ผ่าน coordinator: cursor bubble แสดง, license ตรวจสอบ, state callback fire, paste serialization บังคับใช้ Menu bar path ข้ามส่วนใหญ่ไปและ call recording layer โดยตรง Bug เล็ก ๆ โผล่ขึ้นมา — session ที่เริ่มจากเมนูด้วย license state เก่า, cursor bubble หายไปทำให้ผู้ใช้ไม่แน่ใจว่ากำลัง record อยู่หรือเปล่า

1.2.0 route action ของเมนูผ่าน code path เดียวกับ hotkey โดยมีความแตกต่างหนึ่งที่ประกาศไว้: dictation ที่ขับเคลื่อนจากเมนูถือว่าเป็น hands-free (toggle เพื่อเริ่ม, toggle เพื่อหยุด) เพราะไม่มีคีย์จริงให้กดค้าง ที่เหลือเหมือนกันทุกอย่างเพราะมันเป็น function call เดียวกัน

---

## ป้าย Speaker ที่ไม่ drift

สอง path กำลังผลิตชื่อสำหรับ audio เดียวกัน per-meeting speaker snapshot — รายชื่อเสียงและชื่อที่กำหนดที่คุณเห็นใน meeting detail — ก่อนหน้านี้ถูก reconstruct จาก `DiarizerManager.getSpeakerList()` หลังประชุมเสร็จ แล้ว map ไปยัง display name แยกจาก chunk label

1.2.0 build snapshot แบบ progressive ระหว่างการบันทึกผ่าน `liveSnapshot` โดยใช้ `diarizeSpeaker()` call เดียวกับที่ label chunk แหล่งข้อมูลเดียว ตามการออกแบบ Speaker ที่อยู่ใน global `KnownSpeakerRepository` แล้วถูกยกเว้นจาก per-meeting snapshot เพราะ embedding ของพวกเขาอยู่ใน global และไม่จำเป็นต้อง list ใหม่ต่อ meeting

---

## ห้าภาษา หนึ่งตาราง

เรื่อง localization สั้น เพราะ implementation น่าเบื่อโดยตั้งใจ

ทุก string ที่ผู้ใช้มองเห็น — label sidebar, รายการเมนู, empty state, prompt onboarding, คำอธิบาย permission, tooltip toolbar, ข้อความ About, label model picker — ผ่านตารางแปลรวมศูนย์ตารางเดียว ห้าภาษา: อังกฤษ สเปน รัสเซีย ฮินดี ไทย key ที่หายไป fall back ไปอังกฤษอย่างเงียบ

Language picker (Settings → General และใน onboarding) มีตัวเลือก AUTO ที่ตาม locale ระบบของ macOS เลือกภาษาที่ต้องการแล้วสลับโดยไม่ต้องรีสตาร์ท — ไม่ relaunch แอป ไม่โหลดวิวใหม่ ไม่กะพริบ เป็นไปได้ด้วยเหตุผลเดียวกับที่ตารางขยายได้ราคาถูก: ทุก string ที่มองเห็นอ่านจากตารางตอน render ไม่ใช่ตอนแอปเปิด

ถ้าเราเพิ่มภาษาที่หก งานอยู่ที่การแปล ไม่ใช่วิศวกรรม

---

## อัปเกรดอย่างไร

ถ้าอยู่ที่ 1.1.0:

```bash
brew upgrade muvon/tap/vext
```

หรือเอา DMG จาก [getvext.app/download](https://getvext.app/download)

ถ้าเป็นผู้ใช้ใหม่:

```bash
brew install muvon/tap/vext
```

Speaker, dictation, note และ meeting ที่มีอยู่ถูกเก็บรักษาไว้ การประชุมครั้งแรกที่บันทึกภายใต้ 1.2.0 คือครั้งแรกที่มี two-pass diarization ใช้งานได้

[Vext 1.2.0 release notes เต็ม →](https://getvext.app/blog/vext-1-2-0-multilanguage-speakers)

— Don
