我也是后知后觉发现 Paseo 的好处,已经有不少文章介绍过如何安装配对等,下面跳过 Paseo 的安装和基础设置,主要记录一下Relay和语音输入的配置,希望对大家有帮助:
Relay
Paseo 支持在手机上访问电脑端的 Paseo daemon,实现随时随地无缝vibe coding。但是 paseo.sh 的服务器在海外,利用 Cloudflare DO 进行中继,所以国内使用时,经常连接不上,或是断连。
所以,自建一个国内的 relay 能很好的解决这些问题,这里我使用了 paseo-relay 这个项目来搭建自己的relay服务器,详细步骤参考项目的说明。
在服务器上通过 Docker或配置成系统服务的方式运行在 8411 端口上,再通过 Caddy 自签域名证书支持HTTPS访问,假定我们这里配置好的中继服务器是 relay.abc.com, 通过访问 https://relay.abc.com/health 可以得到下面的返回,则说明服务正常运行了:
{"status":"ok","version":"v0.5.0"}
下一步修改本地的 Paseo 配置,无论是通过npm安装的,或是使用 Paseo软件启动的,配置文件目录默认在:
# MacOS
~/.paseo/
打开配置文件目录下的 config.json, 找到 daemon 下的 relay 进行修改或添加:
# 注意域名后面带端口: relay.abc.com:443
{
"daemon": {
...
"relay": {
"enabled": true,
"endpoint": "relay.abc.com:443",
"publicEndpoint": "relay.abc.com:443",
"useTls": true
}
...
},
"app": {
"baseUrl": "https://relay.abc.com"
}
}
保存后,重启 Paseo (命令或在Paseo软件中找到指定的主机 "概览“-> “重启 Daemon”),再次生成配对设备二维码时,已经是新的 Relay 服务器地址了,在“连接”里面看到的也是 Relay服务器。
语音输入
Paseo 已经支持了 Voice,支持两种语音识别文字方案:
- OpenAI格式的API方式
- 本地的 STT/TTS 模型
本地语音模型
在未进行配置时,在页面上点击麦克风图标,Paseo 将自动下载模型,模型下载完成后即可使用语音输入了。默认从 github 上下载,如果网速慢失败,则可以尝试下:
# 通过 gh-proxy, 注意先切到paseo 模型目录下, 建议使用 parakeet-tdt-0.6b-v3
cd ~/.paseo/models/local-speech
wget -c "https://v4.gh-proxy.org/https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2"
tar -xJf sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2
# wget -c "https://v4.gh-proxy.org/https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2"
# tar -xJf sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2
wget -c "https://v4.gh-proxy.org/https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2"
tar -xJf kokoro-en-v0_19.tar.bz2
如果在MacOS等环境上,本地的语音模型是比较合适的,截止到 26年8月底的版本,Paseo本地使用了 STT: sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8 和 TTS: kokoro-en-v0_19 两个模型。
不幸的是, STT: sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8 只支持英语和欧洲的几种语言,不支持中文。由于当下版本中,固定了 nemo 方式的语音识别,尚不支持 SenseVoice 格式的onnx,有几个 PR: 1634 #2044 #3689 官方合并后,我们可以使用上这些模型了:
- sense-voice-zh-en-ja-ko-yue-int8-2025-09-09
- paraformer-zh-2024-03-09-int8
文本转语音API
除了本地的模型, Voice 文档中更建议使用 API的方式做语音识别,如果使用 OpenAI 官方的只要按照文档配置即可。
本地自建的 FunASR 支持 OpenAI Compatible API,所以比较简单,只要配置好 baseUrl 和 model: paraformer 即可:
{
"features": {
"dictation": {
"stt": {
"provider": "openai"
}
},
"voiceMode": {
"stt": {
"provider": "openai",
"model": "paraformer"
}
}
},
"providers": {
"openai": {
"stt": {
"apiKey": "...",
"baseUrl": "http://localhost:8000/v1"
}
}
}
}
这里,千问AI平台也提供了语音识别的服务,调用按音频时长计费价格非常低,只有 0.00022/秒。
虽然千问提到了兼容 OpenAI 接口,但实际上 qwen3-asr-flash 这些模型请求的 endpoint是 /v1/chat/completions,而不是OpenAI 的 /v1/audio/transcriptions,而且发的请求体格式也不同。
这里通过 Cloudflare Worker 做了一个格式转换,达到了相同的效果,也可以类似的用其他云的edge runner 来实现。假设配置好后的Cloudflare Worker域名是 audio.abc.com,Paseo config.json 参考下面配置,并重启 Paseo Daemon后,即可在 Paseo 软件上使用语音输入了。
# 修改 ~/.paseo/config.json
{
"features": {
"dictation": {
"stt": {
"provider": "openai",
"model": "qwen3-asr-flash"
}
},
"voiceMode": {
"stt": {
"provider": "openai",
"model": "qwen3-asr-flash"
}
}
},
"providers": {
"openai": {
"stt": {
"apiKey": "千问AI平台的 api key",
"baseUrl": "https://audio.abc.com/v1"
}
}
}
}
Cloudflare Worker 代码
export default {
async fetch(request, env, ctx) {
// 1. 处理 CORS 预检请求 (Paseo 在浏览器环境下可能会发 OPTIONS)
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
const url = new URL(request.url);
// 2. 专门拦截 STT 语音转写请求
if (request.method === "POST" && url.pathname.endsWith("/v1/audio/transcriptions")) {
try {
const formData = await request.formData();
const file = formData.get("file");
if (!file) {
return new Response(JSON.stringify({ error: "No file provided" }), { status: 400 });
}
// 提取音频并转为 Base64
const arrayBuffer = await file.arrayBuffer();
const base64Audio = arrayBufferToBase64(arrayBuffer);
const mimeType = file.type || "audio/webm";
const audioDataUri = `data:${mimeType};base64,${base64Audio}`;
// 组装千问多模态对话请求
const payload = {
model: "qwen3-asr-flash",
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: {
data: audioDataUri,
},
},
],
},
],
stream: false,
};
// 发送给阿里云
const aliyunResponse = await fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": request.headers.get("Authorization"),
},
body: JSON.stringify(payload),
});
if (!aliyunResponse.ok) {
const errText = await aliyunResponse.text();
return new Response(errText, { status: aliyunResponse.status, headers: { "Content-Type": "application/json" } });
}
const data = await aliyunResponse.json();
const messageContent = data.choices?.[0]?.message?.content;
let transcribedText = "";
// 解析千问的返回体提取文本
if (typeof messageContent === "string") {
transcribedText = messageContent;
} else if (Array.isArray(messageContent)) {
for (const item of messageContent) {
if (item.type === "text") {
transcribedText += item.text || "";
}
}
}
// 返回标准 OpenAI STT 格式给 Paseo
return new Response(JSON.stringify({ text: transcribedText.trim() }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
}
}
// 3. 对其他请求(例如 Paseo 的大模型对话请求 /v1/chat/completions),直接代理转发
const targetUrl = new URL("https://dashscope.aliyuncs.com" + url.pathname + url.search);
const proxyRequest = new Request(targetUrl, request);
// 剔除可能会导致云端 WAF 拦截的头
proxyRequest.headers.delete("Host");
const response = await fetch(proxyRequest);
const newResponse = new Response(response.body, response);
newResponse.headers.set("Access-Control-Allow-Origin", "*");
return newResponse;
},
};
// 辅助函数:由于音频文件较大,分块转 Base64 避免内存溢出
function arrayBufferToBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000; // 32KB 分块
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}