🇬🇧 English
You say 搜索成都美食 and a real browser searches for Chengdu food. No extension, no cloud agent driving your screen.
The model never writes the search text. Code pulls candidate spans out of the transcript and Jev only picks one, which is copied verbatim.
That one design choice is why Chinese needed real code, and why the ASR is swappable.
🇹🇭 ไทย
คุณพูดว่า 搜索成都美食 แล้วเบราว์เซอร์จริงก็ค้นหาอาหารเฉิงตู ไม่ต้องมีส่วนขยาย ไม่ต้องมีเอเจนต์บนคลาวด์มาคุมจอคุณ
โมเดลไม่เคยเขียนข้อความค้นหาเอง โค้ดดึงช่วงคำที่เป็นตัวเลือกออกมาจากคำถอดเสียง แล้ว Jev แค่ เลือก อันหนึ่ง ซึ่งถูกคัดลอกไปตรง ๆ
ทางเลือกในการออกแบบข้อเดียวนี้คือเหตุผลที่ภาษาจีนต้องเขียนโค้ดจริง และเหตุผลที่สลับ ASR ได้
🇨🇳 中文
你说一句搜索成都美食,一个真的浏览器就去搜成都美食。不用插件,也没有云端 agent 来接管你的屏幕。
模型从不自己写搜索词。代码从转写文本里抽出候选片段,Jev 只负责挑一个,然后原样复制。
正是这一个设计选择,让中文必须真写代码,也让 ASR 可以随便换。
| Upstream (moritzkremb)ต้นน้ำ (moritzkremb)上游 (moritzkremb) | The fork (winter-loo)fork (winter-loo)这个 fork (winter-loo) | |
|---|---|---|
| Languageภาษา语言 | en-US, hardcoded in one lineen-US ฝังตายไว้บรรทัดเดียวen-US,一行写死 | English and Chineseอังกฤษและจีน英文和中文 |
| Where speech comes fromเสียงมาจากไหน语音从哪来 | Web Speech API, so audio goes to GoogleWeb Speech API เสียงจึงไปที่ GoogleWeb Speech API,所以音频发给 Google | 48kHz PCM over two plain socketsPCM 48kHz ผ่านสองซ็อกเก็ตธรรมดา48kHz PCM,走两个普通套接字 |
| Browser controlการคุมเบราว์เซอร์浏览器控制 | Playwright launches ChromiumPlaywright เปิด ChromiumPlaywright 启动 Chromium | native CDP on port 9229CDP ตรง ๆ ที่พอร์ต 9229原生 CDP,端口 9229 |
| Swap the ASR?สลับ ASR ได้ไหม能换 ASR 吗 | no, it belongs to the browserไม่ได้ มันเป็นของเบราว์เซอร์不能,那是浏览器的 | yes — two sockets, any engineได้ สองซ็อกเก็ต เอนจินอะไรก็ได้能 —— 两个套接字,任意引擎 |
| Speaks Chinese out loud?พูดจีนออกเสียงได้ไหม会用中文出声吗 | no TTS at allไม่มี TTS เลย完全没有 TTS | no TTS at allไม่มี TTS เลย完全没有 TTS |
speechSynthesis call anywhere in the tree. Every reply to you is visual — overlays, toasts, numbered badges on clickable things. If you want it to answer in Mandarin out loud, that is Tutorial 3, and it is code you write, not a flag you set.
ทั้งโปรเจกต์ต้นน้ำและ fork ของมันไม่มีตัวไหนทำ text-to-speech ไม่มีการเรียก speechSynthesis อยู่ที่ไหนเลยในโค้ด คำตอบทุกอย่างที่ส่งกลับมาหาคุณเป็นภาพ ทั้งเลเยอร์ทับ ข้อความเด้ง และป้ายตัวเลขบนสิ่งที่คลิกได้ ถ้าคุณอยากให้มันตอบเป็นภาษาจีนกลางออกเสียงจริง นั่นคือบทที่ 3 และเป็นโค้ดที่คุณเขียนเอง ไม่ใช่สวิตช์ที่เปิดได้
上游项目和它的任何 fork 都不做文字转语音。整个代码树里没有一处 speechSynthesis 调用。它给你的所有反馈都是视觉的——浮层、提示条、可点元素上的编号标记。如果你想让它用普通话出声回话,那是教程 3,而且那是你写的代码,不是一个能打开的开关。
🧰 Step 0 — Everything you need before any of this runs · ขั้นที่ 0 — ทุกอย่างที่ต้องมีก่อนที่อะไรจะรันได้ · 第 0 步 —— 在这一切能跑起来之前你需要的东西
💻 CHECK WHAT YOU ALREADY HAVE
node --version # must be v20 or newer; the fork declares "node": ">=20"
npm --version
python --version # 3.9+ for the bridge in Step 4
git --version
💻 INSTALL NODE 20+ IF THAT FAILED
# Windows
winget install OpenJS.NodeJS.LTS
# macOS
brew install node
# Debian / Ubuntu — the distro package is usually too old, so use NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
💻 A PYTHON ENVIRONMENT FOR STEP 4
# Keep the path SHORT. See the warning below.
python -m venv C:\asr\venv # Windows
C:\asr\venv\Scripts\activate
python3 -m venv ~/asr/venv # macOS / Linux
source ~/asr/venv/bin/activate
pip install --upgrade pip
reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled; 0x0 means disabled.
ถ้าพาธยาวถูกปิดไว้ ซึ่งเป็นค่าเริ่มต้น pip อาจทิ้งไฟล์บางส่วนแบบเงียบ ๆ ตอนติดตั้งลงใน virtualenv ที่ซ้อนลึก ไม่มีอะไรฟ้องดัง ๆ คุณจะไปเจอ import error ทีหลังจากแพ็กเกจที่รายงานว่าติดตั้งสำเร็จ ให้สร้าง venv ใกล้ ๆ รากไดรฟ์ ไม่ใช่ในพาธโปรเจกต์หรือ temp ที่ยาว เช็กด้วย reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled ค่า 0x0 แปลว่าปิดอยู่
如果长路径被禁用(这是默认值),pip 在往层级很深的 virtualenv 里安装时会悄悄漏掉文件。没有任何明显报错;你会在之后从一个「安装成功」的包那里收到 import error。把 venv 建在靠近盘根的位置,不要放在很长的项目路径或临时目录里。用 reg query "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled 检查,0x0 就是禁用。
💻 GET A JEV KEY — EVERY PATH ON THIS PAGE NEEDS ONE
# Create a key at https://console.typesafe.ai/keys, then either put it in .env:
# TYPESAFE_API_KEY=sk-...
# or export it. The legacy name JEV_API_KEY is still accepted.
export TYPESAFE_API_KEY=sk-... # macOS / Linux
$env:TYPESAFE_API_KEY = "sk-..." # Windows PowerShell
setx TYPESAFE_API_KEY "sk-..." # Windows, persistent (reopen the shell)
run.sh refuses to start without a key rather than failing later. There is no offline mode.
ขั้นที่ 3, 4 และ 5 ล้วนว่าด้วยการได้ข้อความภาษาจีนมาฟรี ๆ แล้วทั้งสามทางก็ส่งคำถอดเสียงบางส่วนทุกครั้งไปให้โมเดลที่ต้องจ่ายเงิน ราวครั้งละ $0.0002 run.sh จะไม่ยอมเริ่มถ้าไม่มีคีย์ แทนที่จะไปพังทีหลัง ไม่มีโหมดออฟไลน์
第 3、4、5 步讲的都是怎么免费拿到中文文本,而这三条路随后都会把每一条部分转写交给一个收费模型,每次调用大约 $0.0002。run.sh 没有密钥就直接拒绝启动,而不是等到后面才失败。没有离线模式。
💻 START CHROME WITH CDP ON PORT 9229
# Windows
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--remote-debugging-port=9229 --user-data-dir=C:\chrome-cdp
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9229 --user-data-dir=/tmp/chrome-cdp
# Linux
google-chrome --remote-debugging-port=9229 --user-data-dir=/tmp/chrome-cdp
# Prove CDP is actually listening before you go any further:
curl http://127.0.0.1:9229/json/version
--user-data-dir forces a genuinely new instance. Quit Chrome entirely first if you would rather use your normal profile.
ทั้งสองโปรเจกต์เขียนคำสั่ง Chrome ไว้โดยไม่มีตัวนี้ ถ้า Chrome เปิดอยู่แล้ว คำสั่งนั้นจะไม่ได้เปิด Chrome ที่ดีบักได้ แต่จะส่ง URL ไปให้โปรเซสเดิมแล้วจบการทำงาน หน้าต่างใหม่ดูปกติทุกอย่างในขณะที่พอร์ต 9229 ยังปิดอยู่ จากนั้นคุณจะได้ connection refused จากเซิร์ฟเวอร์ และไม่มีอะไรบนหน้าจออธิบายว่าทำไม การใส่ --user-data-dir แยกต่างหากบังคับให้เกิดอินสแตนซ์ใหม่จริง ๆ ถ้าอยากใช้โปรไฟล์ปกติของคุณ ให้ปิด Chrome ให้หมดก่อน
两个项目写的 Chrome 命令里都没有这个参数。如果 Chrome 已经在运行,那条命令并不会启动一个可调试的 Chrome——它只是把网址交给已有进程然后退出,新窗口看起来完全正常,而 9229 端口一直是关的。然后你会从服务器那边收到 connection refused,而页面上没有任何东西解释为什么。单独传一个 --user-data-dir 才能强制开出一个真正的新实例。如果你更想用自己平时的配置文件,就先把 Chrome 完全退出。
🔤 Tutorial 1 — What the fork changed · บทที่ 1 — fork เปลี่ยนอะไรไป · 教程 1 —— 这个 fork 改了什么
🇬🇧 English
Two things, and the first one is the trap. If you only change the language code, you get a Chinese transcript that the rest of the program cannot read.
🇹🇭 ไทย
สองอย่าง และอย่างแรกคือกับดัก ถ้าคุณเปลี่ยนแค่รหัสภาษา คุณจะได้คำถอดเสียงภาษาจีนที่ส่วนที่เหลือของโปรแกรมอ่านไม่ออก
🇨🇳 中文
两件事,而第一件是个坑。如果你只改语言代码,你会得到一段中文转写,而程序的其余部分根本读不懂它。
1 One line of English, hardcoded · อังกฤษบรรทัดเดียว ฝังตายไว้ · 一行写死的英文
🔎 UPSTREAM, src/public/index.html LINE 297
rec.continuous = true; rec.interimResults = true; rec.lang = "en-US"; rec.maxAlternatives = 1;
| The codeโค้ด代码 | What it means for Chineseมันหมายถึงอะไรกับภาษาจีน对中文意味着什么 |
|---|---|
rec.lang = "en-US" | A string literal, not a setting. No config file, no environment variable, no UI. Chinese speech reaches this recogniser and comes back as English-sounding nonsense.เป็นค่าคงที่ในโค้ด ไม่ใช่การตั้งค่า ไม่มีไฟล์คอนฟิก ไม่มีตัวแปรสภาพแวดล้อม ไม่มีหน้าจอให้เลือก เสียงภาษาจีนเข้าไปที่ตัวถอดเสียงนี้แล้วออกมาเป็นคำอังกฤษเพี้ยน ๆ这是写在代码里的字符串,不是设置项。没有配置文件,没有环境变量,也没有界面可选。中文语音进到这个识别器,出来是一串像英文的乱码。 |
rec.interimResults = true | This is the good part, and it is language-neutral. Partial transcripts arrive while you are still talking, which is what lets it act before you finish the sentence.นี่คือส่วนที่ดี และไม่ผูกกับภาษา คำถอดเสียงบางส่วนมาถึงตอนที่คุณยังพูดอยู่ ซึ่งเป็นสิ่งที่ทำให้มันลงมือได้ก่อนคุณพูดจบ这是好东西,而且与语言无关。你还在说的时候部分转写就到了,这正是它能在你说完之前就动手的原因。 |
rec.maxAlternatives = 1 | One hypothesis only. For Mandarin, where homophones are everywhere, more alternatives would give Jev more to choose between. Nobody has tried it.สมมติฐานเดียวเท่านั้น สำหรับภาษาจีนกลางที่คำพ้องเสียงมีอยู่ทั่วไป การมีตัวเลือกมากขึ้นจะให้ Jev มีอะไรให้เลือกมากขึ้น ยังไม่มีใครลอง只要一个假设。普通话里同音词满地跑,给更多候选本来能让 Jev 有更多可挑的。还没人试过。 |
rec.lang = "zh-CN" in upstream and the recogniser starts working properly — and the program stops working. Everything downstream that reads the transcript is English regex: /\bsearch\s+(?:for|up)\s+/i, /\btype\s+/i. Those match nothing in 搜索成都美食, so no payload is ever extracted, so Jev is handed no candidates to pick from. You will see a clean transcript in the log and a browser that does nothing at all.
ตั้ง rec.lang = "zh-CN" ในต้นน้ำแล้วตัวถอดเสียงจะเริ่มทำงานถูกต้อง และโปรแกรมจะหยุดทำงาน ทุกอย่างที่อยู่ถัดไปซึ่งอ่านคำถอดเสียงนั้นเป็น regex ภาษาอังกฤษ เช่น /\bsearch\s+(?:for|up)\s+/i และ /\btype\s+/i พวกนั้นไม่แมตช์อะไรเลยใน 搜索成都美食 จึงไม่มีเนื้อหาถูกดึงออกมา จึงไม่มีตัวเลือกส่งให้ Jev เลือก คุณจะเห็นคำถอดเสียงสวย ๆ ในล็อก และเบราว์เซอร์ที่ไม่ทำอะไรเลย
在上游把 rec.lang 改成 "zh-CN",识别器开始正常工作了——而程序停止工作了。下游所有读这段转写的东西都是英文正则:/\bsearch\s+(?:for|up)\s+/i、/\btype\s+/i。它们在 搜索成都美食 里什么都匹配不到,于是抽不出任何内容,于是 Jev 拿不到任何候选。你会在日志里看到一段漂亮的转写,和一个完全不动的浏览器。
💻 SEE IT FOR YOURSELF
git clone https://github.com/moritzkremb/jev-voice-browser
cd jev-voice-browser
npm install
cp .env.example .env # paste your key from Step 0
sed -i.bak 's/rec.lang = "en-US"/rec.lang = "zh-CN"/' src/public/index.html # -i.bak works on GNU and BSD sed
./run.sh # Windows: set the key, then npm start
# open http://localhost:8787, click Start Mic Stream, say 搜索成都美食
- Clone upstream, change that one string to
zh-CN, and run it. Say 搜索成都美食. - Watch the transcript panel fill correctly and the browser sit still. That is the whole lesson of Step 2 in one run.
- โคลนต้นน้ำมา เปลี่ยนสตริงนั้นเป็น
zh-CNแล้วรัน พูดว่า 搜索成都美食 - ดูแผงคำถอดเสียงขึ้นมาถูกต้อง และเบราว์เซอร์นิ่งสนิท นั่นคือบทเรียนทั้งหมดของขั้นที่ 2 ในการรันครั้งเดียว
- 克隆上游,把那个字符串改成
zh-CN,跑起来。说一句 搜索成都美食。 - 看转写面板正确地填满,而浏览器一动不动。这一次运行就是第 2 步的全部教训。
2 Chinese has no spaces to split on · จีนไม่มีช่องว่างให้ตัด · 中文没有空格可切
🔎 THE FORK, src/spans.js — CHINESE PAYLOAD VERBS
// Chinese payload verbs with destination prefix (must come before generic verbs)
/(?:在|到)?(?:搜索框|输入框|评论框|文本框)(?:中|里|内)?\s*(?:输入|键入|打入|填写|写)\s*/i,
/(?:在|到)?(?:搜索框|输入框|评论框|文本框)(?:中|里|内)?\s*(?:搜索|搜一下|搜|查找|查询)\s*(?:关于)?\s*/i,
/(?:在|去)?(?:谷歌|百度|必应|维基百科|youtube|b站|github|知乎)\s*(?:上|里)?(?:搜索|搜一下|搜|查找)\s*(?:关于)?\s*/i,
/(?:搜索|搜一下|搜|查找|查询)\s*(?:关于)?\s*/i,
/(?:输入|键入|打入|填写|写)\s*/i,
| The patternแพตเทิร์น这段模式 | Why it is shaped like thatทำไมหน้าตาเป็นแบบนั้น为什么长成这样 |
|---|---|
no \b anywhere | Word boundaries are defined by spaces and punctuation. Mandarin written text has neither between words, so \b is useless here. The patterns match on characters instead.ขอบเขตคำถูกกำหนดด้วยช่องว่างและเครื่องหมายวรรคตอน ภาษาจีนเขียนไม่มีทั้งสองอย่างระหว่างคำ ดังนั้น \b ใช้ไม่ได้ที่นี่ แพตเทิร์นจึงแมตช์ที่ตัวอักษรแทน词边界是由空格和标点定义的。中文书面语在词之间两样都没有,所以 \b 在这里没用。于是这些模式改成直接匹配汉字。 |
(?:谷歌|百度|必应|知乎|b站) | Not a translation of the English list. Baidu, Bing, Zhihu and Bilibili are the destinations a Mandarin speaker actually names, and Google often is not reachable where they are.ไม่ใช่การแปลรายการภาษาอังกฤษ Baidu, Bing, Zhihu และ Bilibili คือปลายทางที่คนพูดจีนกลางเรียกจริง ๆ และ Google มักเข้าไม่ได้ในที่ที่พวกเขาอยู่这不是英文那份列表的翻译。百度、必应、知乎、B站才是说普通话的人真会说出口的去处,而在他们那边谷歌常常根本上不去。 |
(?:搜索|搜一下|搜|查找|查询) | Ordered longest first. Put 搜 before 搜索 and every 搜索 command matches the short one, leaving 索 stuck at the front of your search text.เรียงจากยาวไปสั้น ถ้าเอา 搜 ไว้ก่อน 搜索 ทุกคำสั่ง 搜索 จะไปแมตช์ตัวสั้น แล้วเหลือ 索 ค้างอยู่หน้าข้อความค้นหาของคุณ按从长到短排。把 搜 放在 搜索 前面,每条 搜索 命令都会匹配到短的那个,于是 索 就卡在你搜索词的最前面。 |
(?:在|到|去)?…(?:中|里|内)? | Mandarin marks the destination with a particle before and often after the noun. Both are optional in speech, so both are optional here. This is grammar, not vocabulary — which is exactly why no flag could do it.ภาษาจีนกลางทำเครื่องหมายปลายทางด้วยคำช่วยที่วางหน้าและบ่อยครั้งหลังคำนาม ทั้งสองตัวเป็นตัวเลือกในการพูด จึงเป็นตัวเลือกที่นี่ด้วย นี่คือไวยากรณ์ ไม่ใช่คำศัพท์ และนั่นคือเหตุผลที่สวิตช์ใด ๆ ทำแทนไม่ได้普通话用名词前、常常还有名词后的虚词来标记处所。说话时两个都可以省,所以这里两个都是可选的。这是语法,不是词汇——正因如此,任何开关都替代不了。 |
🎙️ Tutorial 2 — Setting up the ASR · บทที่ 2 — ติดตั้ง ASR · 教程 2 —— 把 ASR 装起来
🇬🇧 English
The fork ships a client for its recogniser, not the recogniser. src/doubao.js is 200 lines of socket code and its header comment points at a path on the author's own Mac.
So this is the step where people stop. Three ways through it, cheapest last.
🇹🇭 ไทย
fork นี้ให้มาแค่ ไคลเอนต์ ของตัวถอดเสียง ไม่ใช่ตัวถอดเสียง src/doubao.js คือโค้ดซ็อกเก็ต 200 บรรทัด และคอมเมนต์หัวไฟล์ชี้ไปที่พาธบนเครื่อง Mac ของผู้เขียนเอง
ตรงนี้คือขั้นที่คนส่วนใหญ่หยุด มีสามทางให้ผ่านไป ถูกที่สุดอยู่ท้ายสุด
🇨🇳 中文
这个 fork 带的是识别器的客户端,不是识别器本身。src/doubao.js 是 200 行套接字代码,而它的文件头注释指向作者自己 Mac 上的一个路径。
所以这一步就是大家卡住的地方。三条路过去,最便宜的放最后。
| Pathทาง路线 | Needsต้องมี需要 | Costs / catchesราคา / ข้อติด代价 / 坑 |
|---|---|---|
| A — Doubao bridgeA — สะพาน DoubaoA —— 豆包桥 | a Mac, BlackHole, Doubao IMEเครื่อง Mac, BlackHole, Doubao IME一台 Mac、BlackHole、豆包输入法 | free, best Mandarin, macOS onlyฟรี จีนกลางดีที่สุด แต่ใช้ได้แค่ macOS免费,普通话最好,只能在 macOS 上跑 |
| B — your own recogniserB — ตัวถอดเสียงของคุณเองB —— 你自己的识别器 | Python, a GPU helpsPython มี GPU จะช่วยPython,有 GPU 更好 | free, private, runs on Linuxฟรี เป็นส่วนตัว รันบน Linux ได้免费、私密,能在 Linux 上跑 |
| C — the browserC — เบราว์เซอร์C —— 浏览器 | Chrome, one edited lineChrome กับการแก้หนึ่งบรรทัดChrome,改一行 | free, five minutes, audio goes to Googleฟรี ห้านาที แต่เสียงไปที่ Google免费,五分钟,音频发给 Google |
3 Path A — the Doubao bridge · ทาง A — สะพาน Doubao · 路线 A —— 豆包桥
🇬🇧 English
The trick is wonderful and slightly deranged. It has no ASR of its own. It writes your microphone audio into BlackHole, a virtual audio device, so that the consumer Doubao input method thinks you are dictating into it — then reads the recognised text back out through the macOS Accessibility API.
You are using a free phone-keyboard-grade Mandarin recogniser as a server. It is very good. It is also macOS only, forever.
🇹🇭 ไทย
กลไกนี้ทั้งสวยงามและเพี้ยนเล็กน้อย มันไม่มี ASR ของตัวเองเลย มันเขียนเสียงไมโครโฟนของคุณลงไปใน BlackHole ซึ่งเป็นอุปกรณ์เสียงเสมือน เพื่อให้ Doubao input method ที่เป็นแอปผู้บริโภคคิดว่าคุณกำลังพูดป้อนข้อความเข้าไป แล้วอ่านข้อความที่ถอดได้กลับออกมาผ่าน Accessibility API ของ macOS
คุณกำลังใช้ตัวถอดเสียงจีนกลางระดับคีย์บอร์ดมือถือแบบฟรีเป็นเซิร์ฟเวอร์ มันดีมาก และมันก็ใช้ได้แค่บน macOS ตลอดไป
🇨🇳 中文
这个招数既漂亮又有点疯。它自己完全没有 ASR。它把你的麦克风音频写进 BlackHole 这个虚拟声卡,让豆包输入法这个消费级应用以为你在对它语音输入——然后通过 macOS 的辅助功能 API 把识别出来的文字读回来。
你是在把一个免费的、手机键盘级别的普通话识别器当服务器用。它非常好用。它也永远只能跑在 macOS 上。
💻 ON THE MAC
brew install blackhole-2ch # the virtual audio device the trick needs
# Install the Doubao IME (豆包输入法) by hand, then in System Settings grant:
# Privacy & Security -> Microphone -> Doubao
# Privacy & Security -> Accessibility -> the bridge app (or your Terminal)
git clone https://github.com/winter-loo/doubao-voice-bridge
cd doubao-voice-bridge
swift build -c release
scripts/build-mac-app.sh
dist/DoubaoVoiceBridge.app/Contents/MacOS/doubao-bridge-mac
# now listening: TCP 4387 control, TCP 5004 audio
💻 ON THE MACHINE RUNNING THE FORK (SAME BOX, OR ANY BOX ON THE LAN)
git clone https://github.com/winter-loo/jev-voice-browser
cd jev-voice-browser
npm install
cp .env.example .env # put your Jev key in it
# Chrome must already be running with CDP open:
# chrome --remote-debugging-port=9229
node src/server.js --port 8787 \
--cdp http://127.0.0.1:9229 \
--doubao-host 192.168.1.50 \
--doubao-port 4387 --doubao-audio-port 5004
# then open http://localhost:8787 and click Start Mic Stream
--doubao-host. It exists — src/server.js parses it, and honours DOUBAO_HOST too. But the usage line at the top of run.sh lists only --port, --host, --headless, --cdp and --start-url, so if you read the wrapper instead of the server you will conclude the bridge has to be local and give up. Call node src/server.js directly, or export DOUBAO_HOST before run.sh.
ถ้าเครื่อง Mac กับเบราว์เซอร์ของคุณอยู่ต่างเครื่องกัน คุณต้องใช้ --doubao-host มันมีอยู่จริง src/server.js อ่านค่านี้ และรับ DOUBAO_HOST ด้วย แต่บรรทัดวิธีใช้ที่หัวไฟล์ run.sh ระบุไว้แค่ --port, --host, --headless, --cdp และ --start-url ดังนั้นถ้าคุณอ่านตัวห่อแทนที่จะอ่านเซิร์ฟเวอร์ คุณจะสรุปว่าสะพานต้องอยู่บนเครื่องเดียวกันแล้วเลิกล้มไป ให้เรียก node src/server.js ตรง ๆ หรือ export DOUBAO_HOST ก่อนรัน run.sh
如果你的 Mac 和浏览器不在同一台机器上,你需要 --doubao-host。它是真实存在的——src/server.js 会解析它,也认 DOUBAO_HOST。但 run.sh 顶部那行用法说明只列了 --port、--host、--headless、--cdp 和 --start-url,所以如果你读的是包装脚本而不是服务器代码,你会得出「桥必须在本机」的结论然后放弃。直接调 node src/server.js,或者在跑 run.sh 之前 export DOUBAO_HOST。
4 Path B — your own recogniser, same two sockets · ทาง B — ตัวถอดเสียงของคุณเอง สองซ็อกเก็ตเดิม · 路线 B —— 你自己的识别器,同样两个套接字
🇬🇧 English
Because the fork talks to its recogniser over sockets rather than linking a library, you can put anything behind them. Here that is FunASR's paraformer-zh-streaming — Mandarin-first, streams partials, runs on CPU or CUDA.
Run it with --engine stub first. It emits a fixed Chinese command, which tells you whether your problem is the sockets or the model.
🇹🇭 ไทย
เพราะ fork คุยกับตัวถอดเสียงผ่านซ็อกเก็ตแทนการลิงก์ไลบรารี คุณจึงเอาอะไรไปวางไว้ข้างหลังก็ได้ ที่นี่คือ paraformer-zh-streaming ของ FunASR ซึ่งเน้นจีนกลาง ส่งผลบางส่วนแบบสตรีม และรันบน CPU หรือ CUDA ได้
ให้รันด้วย --engine stub ก่อน มันจะส่งคำสั่งภาษาจีนแบบคงที่ออกมา ซึ่งบอกคุณได้ว่าปัญหาอยู่ที่ซ็อกเก็ตหรือที่โมเดล
🇨🇳 中文
因为这个 fork 是通过套接字跟识别器说话,而不是链接一个库,所以你可以在后面放任何东西。这里放的是 FunASR 的 paraformer-zh-streaming——以普通话为主,能流式给出部分结果,CPU 或 CUDA 都能跑。
先用 --engine stub 跑一次。它会吐出一条固定的中文命令,这能告诉你问题出在套接字还是模型上。
💻 INSTALL
pip install numpy # the stub engine needs only this
pip install funasr torch torchaudio # and this, for the real one
# The model downloads itself on first run, about 900 MB:
# paraformer-zh-streaming
# Use --device cpu if you have no CUDA.
📄 asr_bridge.py
"""Stand in for Doubao Voice Bridge with any Chinese ASR you can run yourself.
The fork only needs two sockets: line-delimited JSON out on the control port,
and raw 48kHz s16le PCM in on the audio port. Speak that and it cannot tell
the difference. --engine stub proves the wiring without downloading a model.
"""
import argparse, json, socket, threading, time
import numpy as np
CHUNK_MS = 200 # how often we look at the audio
SILENCE_DBFS = -42.0 # below this counts as silence
SILENCE_MS = 700 # this much silence ends the utterance
class Stub:
"""Emits a fixed Chinese command so you can test the socket contract alone."""
def __init__(self): self.n = 0
def feed(self, pcm16k, is_final):
self.n += 1
text = "在谷歌搜索成都美食"
return text[: min(len(text), self.n * 3)] if not is_final else text
def reset(self): self.n = 0
class FunASR:
"""paraformer-zh-streaming. Chinese-first, streams, runs on CPU or CUDA."""
def __init__(self, model, device):
from funasr import AutoModel
self.model = AutoModel(model=model, device=device, disable_update=True)
self.cache, self.text = {}, ""
def feed(self, pcm16k, is_final):
res = self.model.generate(input=pcm16k, cache=self.cache, is_final=is_final,
chunk_size=[0, 10, 5], encoder_chunk_look_back=4,
decoder_chunk_look_back=1)
for r in res:
self.text += r.get("text", "")
return self.text
def reset(self): self.cache, self.text = {}, ""
def dbfs(x):
if not len(x): return -120.0
rms = float(np.sqrt(np.mean(x.astype(np.float64) ** 2)))
return 20 * np.log10(max(rms, 1e-9) / 32768.0)
class Bridge:
def __init__(self, engine):
self.engine = engine
self.ctrl = None
self.pcm = bytearray()
self.running = False
self.session = 0
self.active = False
self.lock = threading.Lock()
def emit(self, obj):
if not self.ctrl: return
try:
self.ctrl.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode())
except OSError:
self.ctrl = None
def control(self, conn):
self.ctrl = conn
self.emit({"type": "hello", "bridge": "asr_bridge.py"})
buf = b""
while True:
try:
data = conn.recv(4096)
except OSError:
break
if not data: break
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
cmd = line.decode(errors="ignore").strip()
if cmd == "start":
self.session += 1
self.running = True
self.active = False
self.engine.reset()
with self.lock: self.pcm.clear()
self.emit({"type": "status", "phase": "recording", "recording": True})
elif cmd == "stop":
self.running = False
self.emit({"type": "status", "phase": "idle", "recording": False})
self.ctrl = None
def audio(self, conn):
while True:
try:
data = conn.recv(16384)
except OSError:
break
if not data: break
if self.running:
with self.lock: self.pcm.extend(data)
def recognize(self):
"""48k -> 16k by averaging every 3 samples, then feed the engine."""
need = int(48000 * 2 * CHUNK_MS / 1000)
quiet = 0
while True:
time.sleep(CHUNK_MS / 1000)
if not self.running: continue
with self.lock:
if len(self.pcm) < need: continue
take = bytes(self.pcm[:need]); del self.pcm[:need]
x = np.frombuffer(take, dtype="<i2")
x16 = x[: len(x) // 3 * 3].reshape(-1, 3).mean(axis=1).astype(np.int16)
level = dbfs(x16)
self.emit({"type": "audio_level", "rmsDBFS": round(level, 1)})
if level >= SILENCE_DBFS:
self.active, quiet = True, 0
else:
quiet += CHUNK_MS
if not self.active:
continue # silence between utterances: nothing to do
done = quiet >= SILENCE_MS
text = self.engine.feed(x16.astype(np.float32) / 32768.0, done)
if not text: continue
if done:
self.emit({"type": "final", "text": text, "session_id": self.session})
self.engine.reset()
self.active, quiet = False, 0
else:
self.emit({"type": "partial", "text": text, "session_id": self.session})
def serve(port, handler):
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port)); s.listen(1)
while True:
conn, _ = s.accept()
threading.Thread(target=handler, args=(conn,), daemon=True).start()
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=4387)
p.add_argument("--audio-port", type=int, default=5004)
p.add_argument("--engine", choices=["stub", "funasr"], default="funasr")
p.add_argument("--model", default="paraformer-zh-streaming")
p.add_argument("--device", default="cuda")
a = p.parse_args()
engine = Stub() if a.engine == "stub" else FunASR(a.model, a.device)
b = Bridge(engine)
threading.Thread(target=b.recognize, daemon=True).start()
threading.Thread(target=serve, args=(a.audio_port, b.audio), daemon=True).start()
print(f"control :{a.port} audio :{a.audio_port} engine {a.engine}", flush=True)
serve(a.port, b.control)
📄 probe.py — pretends to be the fork, so you can test without a browser
"""Pretend to be the fork: send 'start', stream PCM, print what comes back."""
import json, socket, sys, threading, time
sys.stdout.reconfigure(encoding='utf-8')
import numpy as np
events = []
ctrl = socket.create_connection(("127.0.0.1", 4387))
def reader():
buf = b""
while True:
d = ctrl.recv(4096)
if not d: return
buf += d
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if line.strip():
e = json.loads(line)
events.append(e)
if e["type"] != "audio_level":
print(json.dumps(e, ensure_ascii=False))
threading.Thread(target=reader, daemon=True).start()
time.sleep(0.3)
ctrl.sendall(b"start\n")
audio = socket.create_connection(("127.0.0.1", 5004))
t = np.arange(48000) / 48000.0 # 1.0 s of speech-ish tone
loud = (np.sin(2 * np.pi * 220 * t) * 8000).astype("<i2")
quiet = np.zeros(48000, dtype="<i2") # then 1.0 s of silence
for block in (loud, quiet):
for i in range(0, len(block), 4800): # 100 ms at a time
audio.sendall(block[i:i + 4800].tobytes())
time.sleep(0.05)
time.sleep(1.5)
ctrl.sendall(b"stop\n")
time.sleep(0.3)
kinds = [e["type"] for e in events]
print("---")
print("event types seen:", sorted(set(kinds)))
print("partials:", kinds.count("partial"), " finals:", kinds.count("final"))
fin = [e for e in events if e["type"] == "final"]
print("final text:", fin[-1]["text"] if fin else "NONE")
💻 RUN IT
# terminal 1 — the bridge, with the fake recogniser first
C:\asr\venv\Scripts\activate # Windows
source ~/asr/venv/bin/activate # macOS / Linux
python asr_bridge.py --engine stub
# terminal 2 — prove the contract without a browser
python probe.py
# then swap in the real one, and point the fork at this machine
python asr_bridge.py --engine funasr --device cuda # or --device cpu
node src/server.js --port 8787 --cdp http://127.0.0.1:9229 \
--doubao-host 127.0.0.1 --doubao-port 4387 --doubao-audio-port 5004
📋 WHAT CAME BACK — REAL OUTPUT, 26 SEPTEMBER 2026
{"type": "hello", "bridge": "asr_bridge.py"}
{"type": "status", "phase": "recording", "recording": true}
{"type": "partial", "text": "在谷歌", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "partial", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "final", "text": "在谷歌搜索成都美食", "session_id": 1}
{"type": "status", "phase": "idle", "recording": false}
---
event types seen: ['audio_level', 'final', 'hello', 'partial', 'status']
partials: 8 finals: 1
final text: 在谷歌搜索成都美食
🇬🇧 English
Eight partials, one final, and the Chinese survived the socket intact. That is the contract satisfied. Swap --engine funasr and point the fork at this host.
🇹🇭 ไทย
ผลบางส่วนแปดครั้ง ผลสุดท้ายหนึ่งครั้ง และภาษาจีนรอดผ่านซ็อกเก็ตมาครบถ้วน นั่นคือสัญญาที่ทำสำเร็จ เปลี่ยนเป็น --engine funasr แล้วชี้ fork มาที่โฮสต์นี้
🇨🇳 中文
八条部分结果、一条最终结果,中文完整地穿过了套接字。契约达成。换成 --engine funasr,再把 fork 指到这台主机上。
self.active flag — recognise only once audio has crossed the threshold, and stop at the final. Second: the fork reconnects every three seconds, so an unhandled ConnectionResetError when a client drops will kill your control thread on the first disconnect. Both are in the code above.
ข้อแรก การป้อนเสียงเงียบให้เอนจินทำให้เกิดผลบางส่วนที่ค้างอยู่ออกมา หลัง ผลสุดท้าย fork จึงเห็นข้อความจากประโยคที่จบไปแล้วและอาจลงมือทำซ้ำ ทางแก้คือแฟล็ก self.active ให้ถอดเสียงเฉพาะเมื่อเสียงข้ามเกณฑ์แล้ว และหยุดที่ผลสุดท้าย ข้อสอง fork เชื่อมต่อใหม่ทุกสามวินาที ดังนั้น ConnectionResetError ที่ไม่ถูกจับตอนไคลเอนต์หลุดจะฆ่าเธรดควบคุมของคุณตั้งแต่การหลุดครั้งแรก ทั้งสองข้ออยู่ในโค้ดข้างบนแล้ว
第一,在静音期间继续喂引擎,会在最终结果之后吐出一条过期的部分结果,于是 fork 看到一句已经结束的话的文本,可能重复执行一次。修法是 self.active 这个标志——只在音频越过阈值之后才识别,并在最终结果处停下。第二,fork 每三秒重连一次,所以客户端掉线时一个没被捕获的 ConnectionResetError 会在第一次断开就杀掉你的控制线程。两处都已经写进上面的代码里了。
- Run the stub and the probe. Confirm you see
finalwith Chinese in it. - Kill the probe mid-run with Ctrl-C and check the bridge is still alive. That is the reconnect bug, felt rather than read.
- Install FunASR, switch to
--engine funasr, and say 搜索成都美食 into your microphone.
- รันตัวหลอกกับตัวทดสอบ ยืนยันว่าคุณเห็น
finalที่มีภาษาจีนอยู่ข้างใน - กด Ctrl-C ฆ่าตัวทดสอบกลางทาง แล้วเช็กว่าสะพานยังไม่ตาย นั่นคือบั๊กเรื่องการเชื่อมต่อใหม่ แบบที่ได้สัมผัสเองไม่ใช่แค่อ่าน
- ติดตั้ง FunASR เปลี่ยนไปใช้
--engine funasrแล้วพูดว่า 搜索成都美食 เข้าไมโครโฟน
- 把假引擎和探针都跑起来。确认你看到了带中文的
final。 - 中途用 Ctrl-C 杀掉探针,检查桥还活着。那就是重连那个 bug,亲手摸到而不是读到。
- 装上 FunASR,切到
--engine funasr,然后对着麦克风说 搜索成都美食。
FunASR class was not: the streaming call and its chunk_size=[0, 10, 5] parameters follow FunASR's own streaming example, and no model was downloaded on this machine. Treat that class as the shape of the integration, and check its numbers against FunASR's docs before you trust its latency.
สัญญาซ็อกเก็ตข้างบนถูกรันจริงและผลลัพธ์คัดลอกมาจากการรันนั้น ส่วนคลาส FunASR ไม่ได้ถูกรัน การเรียกแบบสตรีมและพารามิเตอร์ chunk_size=[0, 10, 5] ทำตามตัวอย่างสตรีมของ FunASR เอง และไม่มีการดาวน์โหลดโมเดลบนเครื่องนี้ ให้ถือว่าคลาสนั้นเป็นรูปร่างของการเชื่อมต่อ และไปตรวจตัวเลขกับเอกสารของ FunASR ก่อนจะเชื่อเรื่องความหน่วงของมัน
上面那份套接字契约是真跑过的,输出就是从那次运行里复制来的。FunASR 这个类没有:它的流式调用和 chunk_size=[0, 10, 5] 参数照的是 FunASR 自己的流式示例,这台机器上没有下载过模型。把那个类当作集成的形状来看,在你相信它的延迟数字之前,先去对一遍 FunASR 的文档。
5 Path C — the browser already has a Chinese recogniser · ทาง C — เบราว์เซอร์มีตัวถอดเสียงจีนอยู่แล้ว · 路线 C —— 浏览器本来就有中文识别器
🇬🇧 English
Upstream still has the Web Speech API wired up, and Chrome's recogniser handles Mandarin well. The fork threw that path away in favour of PCM, so neither repo gives you this combination — you assemble it.
Take upstream. Change the language. Then copy the fork's Chinese verb patterns into its spans.js, because Step 1 is still true.
🇹🇭 ไทย
ต้นน้ำยังต่อ Web Speech API ไว้อยู่ และตัวถอดเสียงของ Chrome รองรับจีนกลางได้ดี ส่วน fork ทิ้งทางนั้นไปแล้วหันไปใช้ PCM ดังนั้นไม่มีรีโพไหนให้ชุดผสมนี้กับคุณ คุณต้องประกอบเอง
เอาต้นน้ำมา เปลี่ยนภาษา แล้วคัดลอกแพตเทิร์นคำกริยาภาษาจีนของ fork ใส่ลงใน spans.js ของมัน เพราะขั้นที่ 1 ยังเป็นจริงอยู่
🇨🇳 中文
上游还接着 Web Speech API,而 Chrome 的识别器处理普通话是够好的。这个 fork 把那条路扔了改用 PCM,所以两个仓库都不会直接给你这个组合——得你自己拼。
拿上游过来,把语言改掉。然后把 fork 里那些中文动词模式抄进它的 spans.js,因为第 1 步说的事情依然成立。
💻 THE WHOLE PATCH
git clone https://github.com/moritzkremb/jev-voice-browser
cd jev-voice-browser && npm install
# 1. the language, in src/public/index.html around line 297
sed -i.bak 's/rec.lang = "en-US"/rec.lang = "zh-CN"/' src/public/index.html # -i.bak works on GNU and BSD sed
# 2. the grammar. Copy the Chinese TEXT_VERBS block out of the fork's
# src/spans.js and paste it into yours, ABOVE the generic verbs:
# https://github.com/winter-loo/jev-voice-browser/blob/main/src/spans.js
cp .env.example .env # the key from Step 0 is still required
./run.sh # Windows: npm start, with the key exported
# Chrome must already be listening on 9229 — see Step 0.
curl http://127.0.0.1:9229/json/version
🔊 Tutorial 3 — Making it talk back · บทที่ 3 — ให้มันพูดตอบ · 教程 3 —— 让它开口回话
🇬🇧 English
Everything above is ears. If you came here wanting a browser that answers you in Mandarin, this is the part nobody has written, and it is smaller than you think.
🇹🇭 ไทย
ทุกอย่างข้างบนคือหู ถ้าคุณมาที่นี่เพราะอยากได้เบราว์เซอร์ที่ตอบคุณเป็นภาษาจีนกลาง นี่คือส่วนที่ยังไม่มีใครเขียน และมันเล็กกว่าที่คุณคิด
🇨🇳 中文
上面那些全是耳朵。如果你是冲着「一个会用普通话回答你的浏览器」来的,这就是还没人写的那部分,而且它比你想的要小。
6 Nothing here speaks. Thirty lines that do. · ไม่มีอะไรพูดได้ สามสิบบรรทัดที่พูดได้ · 这里没有会说话的。三十行让它会说。
📄 speak.js — drop it into src/public/index.html
// Say a short Mandarin confirmation when the fork acts. The page already
// knows what it did; this only gives that a voice.
let zhVoice = null;
function pickChineseVoice() {
const zh = speechSynthesis.getVoices().filter(v => /^zh/i.test(v.lang));
// Rank, never take the first one. Chrome's "Google ..." voices are far
// better than the OS defaults, so they win even though they are remote.
const score = v => (/^google/i.test(v.name) ? 100 : 0)
+ (/natural|neural/i.test(v.name) ? 20 : 0)
+ (v.lang === "zh-CN" ? 10 : 0);
zhVoice = zh.sort((a, b) => score(b) - score(a))[0] || null;
// No Chinese voice at all is a real outcome, not an error. Stay quiet
// rather than reading 已点击 aloud in an English voice.
if (!zhVoice) console.warn("[speak] no Chinese voice installed; staying silent");
}
pickChineseVoice();
speechSynthesis.onvoiceschanged = pickChineseVoice; // voices load late
function say(text) {
if (!zhVoice) return;
speechSynthesis.cancel(); // never queue: the last action is the only one that matters
const u = new SpeechSynthesisUtterance(text);
u.voice = zhVoice;
u.lang = zhVoice.lang;
u.rate = 1.05;
speechSynthesis.speak(u);
}
// Wire it to whatever the page already logs. Keep them short — a spoken
// sentence outlasts the action and you end up talking over yourself.
function onAction(kind, target) {
if (kind === "click") say("已点击 " + target);
if (kind === "search") say("正在搜索");
if (kind === "nav") say("已打开");
if (kind === "unsure") say("没听清");
}
📋 WHAT CHROME ACTUALLY HAS — MEASURED 26 SEPTEMBER 2026, WINDOWS 11
Chrome (normal window) 22 voices, 3 Chinese:
Google 普通话(中国大陆) [zh-CN] localService = false
Google 粤語(香港) [zh-HK] localService = false
Google 國語(臺灣) [zh-TW] localService = false
Chromium bundled with Playwright 0 voices, 0 Chinese
localService = false. They are Google's network voices: they need a connection, and the text you speak leaves the machine. A voice browser chosen for privacy that then reads its confirmations aloud through a cloud TTS has given the game away at the last step. And upstream drives its browser with Playwright, whose bundled Chromium had no voices at all — so speechSynthesis.speak() there is a silent no-op with no error. Test in the window you will actually use.
เสียงภาษาจีนทุกตัวที่ Chrome มีให้ที่นี่รายงานว่า localService = false พวกมันคือเสียงผ่านเครือข่ายของ Google ต้องมีอินเทอร์เน็ต และข้อความที่พูดออกไปนั้นออกจากเครื่องคุณ เบราว์เซอร์เสียงที่เลือกใช้เพราะความเป็นส่วนตัว แล้วไปอ่านคำยืนยันผ่าน TTS บนคลาวด์ ก็เท่ากับยอมแพ้ในขั้นตอนสุดท้าย และต้นน้ำขับเบราว์เซอร์ด้วย Playwright ซึ่ง Chromium ที่มากับมันไม่มีเสียงเลยสักตัว ดังนั้น speechSynthesis.speak() ที่นั่นคือคำสั่งที่เงียบสนิทและไม่มีข้อผิดพลาดแจ้ง ให้ทดสอบในหน้าต่างที่คุณจะใช้จริง
Chrome 在这里提供的每一个中文语音都报告 localService = false。它们是 Google 的网络语音:需要联网,而且你要念的文字会离开这台机器。一个因为隐私才选的语音浏览器,最后却通过云端 TTS 把确认念出来,等于在最后一步前功尽弃。而且上游是用 Playwright 驱动浏览器的,它自带的 Chromium 一个语音都没有——所以 speechSynthesis.speak() 在那里是一个静默的空操作,连报错都没有。请在你真正会用的那个窗口里测。
zh_CN-huayan-medium, serve the resulting wav files from the same Node server, and play them with new Audio(). Fixed phrases make this easy: no streaming, no latency, no network, and the voice never changes between runs.
ข้ามตัวสังเคราะห์เสียงของเบราว์เซอร์ไป แล้วเรนเดอร์วลีไม่กี่คำที่คุณต้องใช้จริงเอาไว้ล่วงหน้า มันมีราวหกวลี คือ 已点击, 正在搜索, 已打开, 没听清 และอีกสองคำ ดังนั้นสร้างมันครั้งเดียวด้วย TTS จีนกลางแบบโลคัลอย่าง zh_CN-huayan-medium ของ Piper แล้วเสิร์ฟไฟล์ wav ที่ได้จากเซิร์ฟเวอร์ Node ตัวเดียวกัน และเล่นด้วย new Audio() การที่วลีตายตัวทำให้เรื่องนี้ง่าย ไม่ต้องสตรีม ไม่มีความหน่วง ไม่ต้องใช้เน็ต และเสียงก็ไม่เปลี่ยนไปมาระหว่างการรัน
跳过浏览器的合成器,把你真正需要的那几句话预先渲染好。大概就六句——已点击、正在搜索、已打开、没听清,再加两句——所以用一个本地普通话 TTS(比如 Piper 的 zh_CN-huayan-medium)生成一次,把得到的 wav 文件用同一个 Node 服务器发出去,再用 new Audio() 播放。固定短语让这件事变得很简单:不用流式、没有延迟、不联网,而且每次运行声音都一样。
💻 RENDER THE PHRASES ONCE, WITH PIPER
pip install piper-tts
mkdir -p voices src/public/audio
cd voices
curl -LO https://huggingface.co/rhasspy/piper-voices/resolve/main/zh/zh_CN/huayan/medium/zh_CN-huayan-medium.onnx
curl -LO https://huggingface.co/rhasspy/piper-voices/resolve/main/zh/zh_CN/huayan/medium/zh_CN-huayan-medium.onnx.json
cd ..
# ASCII filenames keep the JavaScript simple.
M=voices/zh_CN-huayan-medium.onnx
echo 已点击 | piper -m $M -f src/public/audio/clicked.wav
echo 正在搜索 | piper -m $M -f src/public/audio/searching.wav
echo 已打开 | piper -m $M -f src/public/audio/opened.wav
echo 没听清 | piper -m $M -f src/public/audio/unsure.wav
echo 已确认 | piper -m $M -f src/public/audio/confirmed.wav
echo 已取消 | piper -m $M -f src/public/audio/cancelled.wav
📄 the offline replacement for say()
// The Node server already serves src/public, so the wavs are just there.
// No voice list, no onvoiceschanged, and it works in Playwright's Chromium.
const CLIPS = { click: "clicked", search: "searching",
nav: "opened", unsure: "unsure" };
function say(kind) {
const name = CLIPS[kind];
if (!name) return;
const a = new Audio("audio/" + name + ".wav");
a.play().catch(() => {}); // autoplay can refuse before the first click
}
🩺 When it does not work — the commands that tell you why · เมื่อมันไม่ทำงาน — คำสั่งที่บอกคุณว่าทำไม · 跑不通的时候 —— 告诉你原因的那些命令
🇬🇧 English
Four processes have to find each other: Chrome on 9229, the Node server on 8787, and the recogniser on 4387 and 5004. Almost every failure is one of them not listening where the others are looking.
🇹🇭 ไทย
มีสี่โปรเซสที่ต้องหากันให้เจอ คือ Chrome ที่ 9229 เซิร์ฟเวอร์ Node ที่ 8787 และตัวถอดเสียงที่ 4387 กับ 5004 ความล้มเหลวเกือบทุกแบบคือตัวใดตัวหนึ่งไม่ได้ฟังอยู่ตรงที่ตัวอื่นกำลังมองหา
🇨🇳 中文
有四个进程必须互相找到:9229 上的 Chrome、8787 上的 Node 服务器,以及 4387 和 5004 上的识别器。几乎所有故障都是其中某一个没有在别人正在找的地方监听。
💻 IS ANYONE LISTENING?
# Windows
netstat -ano | findstr "9229 8787 4387 5004"
# Linux
ss -ltnp | grep -E '9229|8787|4387|5004'
# macOS
lsof -nP -iTCP:9229 -iTCP:8787 -iTCP:4387 -iTCP:5004 -sTCP:LISTEN
# Chrome's CDP endpoint should answer with JSON, not a refusal:
curl http://127.0.0.1:9229/json/version
# And the control page should answer 200:
curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8787/
| Symptomอาการ症状 | What to runให้รันอะไร跑什么 |
|---|---|
| Server cannot reach the browserเซิร์ฟเวอร์ติดต่อเบราว์เซอร์ไม่ได้服务器连不上浏览器 | curl http://127.0.0.1:9229/json/versionRefused means Chrome ignored your flags. Quit Chrome completely and relaunch it with --user-data-dir, as in Step 0.ถ้าถูกปฏิเสธแปลว่า Chrome ไม่สนใจแฟล็กของคุณ ให้ปิด Chrome ให้หมดแล้วเปิดใหม่พร้อม --user-data-dir ตามขั้นที่ 0被拒绝就说明 Chrome 忽略了你的参数。完全退出 Chrome,按第 0 步带 --user-data-dir 重新启动。 |
| No API keyไม่มีคีย์ API没有 API 密钥 | cat .env · echo $TYPESAFE_API_KEYrun.sh prints the console URL and exits 1 rather than starting.run.sh จะพิมพ์ URL ของคอนโซลแล้วออกด้วยโค้ด 1 แทนที่จะเริ่มทำงานrun.sh 会打印控制台网址然后以 1 退出,而不是启动。 |
| Transcript looks right, browser does nothingคำถอดเสียงดูถูกต้อง แต่เบราว์เซอร์ไม่ทำอะไร转写看着没问题,浏览器不动 | This is Step 1. Bypass the microphone and type the same command into the control page's text box — if typing works and speech does not, it is the recogniser; if neither works, it is the grammar.นี่คือขั้นที่ 1 ให้ข้ามไมโครโฟนไป แล้วพิมพ์คำสั่งเดียวกันลงในกล่องข้อความของหน้าควบคุม ถ้าพิมพ์แล้วได้แต่พูดแล้วไม่ได้ ปัญหาอยู่ที่ตัวถอดเสียง ถ้าไม่ได้ทั้งคู่ ปัญหาอยู่ที่ไวยากรณ์这就是第 1 步。绕开麦克风,把同一条命令打进控制页的文本框——如果打字能用而说话不行,问题在识别器;两个都不行,问题在语法。 |
| Bridge never connectsสะพานไม่เชื่อมต่อสักที桥一直连不上 | node src/server.js --doubao-host <ip>It retries every 3 s forever and logs each attempt, so watch the server output rather than guessing.มันจะลองใหม่ทุก 3 วินาทีไปเรื่อย ๆ และบันทึกทุกครั้งที่ลอง ให้ดูเอาต์พุตของเซิร์ฟเวอร์แทนการเดา它会每 3 秒无限重试并记录每一次尝试,所以去看服务器输出,别猜。 |
| Is the fork itself healthy?ตัว fork เองปกติดีไหมfork 本身是好的吗 | npm test · npm run demoThe demo replays a recorded session with no microphone and no bridge, which separates your setup from their code.เดโมจะเล่นเซสชันที่บันทึกไว้ซ้ำโดยไม่ต้องใช้ไมโครโฟนและไม่ต้องใช้สะพาน ซึ่งช่วยแยกว่าปัญหาอยู่ที่การติดตั้งของคุณหรือโค้ดของเขาdemo 会重放一段录好的会话,不需要麦克风也不需要桥,这能把你的环境问题和他们的代码问题分开。 |
| Reaching it from another machineเข้าถึงจากอีกเครื่อง从另一台机器访问 | node src/server.js --host 0.0.0.0Only on a network you trust: anyone who can reach that port can drive your browser and spend your credits.เฉพาะบนเครือข่ายที่คุณไว้ใจเท่านั้น ใครก็ตามที่เข้าถึงพอร์ตนั้นได้ สามารถสั่งเบราว์เซอร์ของคุณและใช้เครดิตของคุณได้只在你信任的网络上这么做:任何能连到那个端口的人都能操控你的浏览器并花掉你的额度。 |
💻 STOP EVERYTHING
# Windows
taskkill /F /IM node.exe
taskkill /F /IM python.exe
# macOS / Linux
pkill -f "src/server.js"
pkill -f asr_bridge.py
🪤 Where this falls over · มันพังตรงไหน · 它在哪里翻车
| Problemปัญหา问题 | What actually happensเกิดอะไรขึ้นจริง实际会怎样 |
|---|---|
| Homophonesคำพ้องเสียง同音词 | The recogniser commits to one set of characters and maxAlternatives = 1 throws the rest away. Jev picks from what it is given, so a misheard 食 is typed verbatim into the search box.ตัวถอดเสียงเลือกชุดตัวอักษรชุดเดียว และ maxAlternatives = 1 ก็ทิ้งที่เหลือไป Jev เลือกจากสิ่งที่ได้รับมา ดังนั้น 食 ที่ฟังผิดก็ถูกพิมพ์ลงช่องค้นหาตรง ๆ识别器一口咬定一组汉字,而 maxAlternatives = 1 把其余的都扔了。Jev 只能从给它的东西里挑,所以一个听错的 食 会被原样打进搜索框。 |
| Code-switchingการสลับภาษา中英夹杂 | "搜索 machine learning" is how people really talk and it is the worst case here: one recogniser locale, two grammars, and the English span patterns are not consulted for a Chinese transcript."搜索 machine learning" คือวิธีที่คนพูดกันจริง ๆ และเป็นกรณีที่แย่ที่สุดตรงนี้ คือมี locale ตัวถอดเสียงเดียว ไวยากรณ์สองชุด และแพตเทิร์นภาษาอังกฤษไม่ถูกเรียกใช้กับคำถอดเสียงภาษาจีน「搜索 machine learning」才是人真正的说法,而这在这里是最糟的情况:一个识别器 locale、两套语法,而中文转写不会去走英文那套片段模式。 |
| Cantonese, Taiwaneseกวางตุ้ง ไต้หวัน粤语、台湾 | The verb list is Mandarin and mostly Simplified. 搜尋 and 影片 are not in it, so a Taiwanese user gets a clean transcript and no action, exactly like Step 1.รายการคำกริยาเป็นจีนกลางและส่วนใหญ่เป็นตัวย่อ 搜尋 และ 影片 ไม่ได้อยู่ในนั้น ผู้ใช้ชาวไต้หวันจึงได้คำถอดเสียงที่สะอาดและไม่มีการลงมือใด ๆ เหมือนขั้นที่ 1 เป๊ะ这份动词表是普通话的,而且基本是简体。搜尋 和 影片 不在里面,所以台湾用户会拿到一段干净的转写和零个动作,跟第 1 步一模一样。 |
| The bridge is one person's Macสะพานคือ Mac ของคนคนเดียว那个桥是一个人的 Mac | Path A depends on a consumer IME app, an unofficial Accessibility scrape and a virtual audio device. Any macOS or Doubao update can break it, and nothing about it is a supported interface.ทาง A พึ่งพาแอป IME สำหรับผู้บริโภค การดูดข้อมูลผ่าน Accessibility แบบไม่เป็นทางการ และอุปกรณ์เสียงเสมือน การอัปเดต macOS หรือ Doubao ครั้งไหนก็ทำให้พังได้ และไม่มีส่วนไหนของมันเป็นอินเทอร์เฟซที่รองรับอย่างเป็นทางการ路线 A 依赖一个消费级输入法应用、一次非官方的辅助功能抓取,和一个虚拟声卡。macOS 或豆包的任何一次更新都可能让它失效,而且它里头没有一处是受支持的接口。 |
| It still costs money per wordมันยังเสียเงินต่อคำอยู่ดี它依然按词花钱 | Local ASR does not make this local. Every partial transcript is a Jev call at roughly $0.0002, and partials arrive several times a second while you speak.ASR ในเครื่องไม่ได้ทำให้ทั้งระบบอยู่ในเครื่อง คำถอดเสียงบางส่วนทุกครั้งคือการเรียก Jev ราว $0.0002 และผลบางส่วนมาหลายครั้งต่อวินาทีขณะคุณพูด本地 ASR 不等于整套都在本地。每一条部分转写都是一次大约 $0.0002 的 Jev 调用,而你说话时部分结果每秒会来好几条。 |
🇬🇧 English
Next: Jev itself, where the typed-answer idea and its measured failures live, or what an embedding is.
🇹🇭 ไทย
ต่อไป: ตัว Jev เอง ที่ซึ่งแนวคิดคำตอบแบบมีชนิดและความล้มเหลวที่วัดได้ของมันอยู่ หรือ เอมเบดดิงคืออะไร