@FradSer: 成功逆向 Wispr Flow不过关键时刻还是会被 Fable 5 的分类器拦截。开源一下: ## 任务 我的 Mac 装了 `/Applications/Wispr http://Flow.app` 且我是 Pro 会员。请构建一个**…

X AI KOLs Timeline 工具

摘要

成功逆向 Wispr Flow 的转录协议并开源了一个独立 Node 客户端,复用其登录态和后端直接调用 gRPC 转录服务。

成功逆向 Wispr Flow不过关键时刻还是会被 Fable 5 的分类器拦截。开源一下: ## 任务 我的 Mac 装了 `/Applications/Wispr http://Flow.app` 且我是 Pro 会员。请构建一个**独立的 Node 客户端**, 它复用 Wispr Flow 的登录态和后端:读取本地已登录的 token,直接调用 Wispr 的 gRPC 转录服务, 把一段语音(文件或麦克风)转成文本。不要改动或依赖原应用运行,只借用它的 session 和后端。 以下是已经逆向确认的事实(Wispr Flow v1.5.980,`com.electron.wispr-flow`),当作给定条件使用。 ## 1. 登录态(认证) - Wispr Flow 是 Electron 应用,把 Supabase 会话**明文** JSON 存在: `~/Library/Application Support/Wispr Flow/session.json` - 该文件是 `{ "sb-dodjkfqhwrzqjwkfnthl-auth-token": "<内层 JSON 字符串>" }`。 键名后缀固定是 `-auth-token`;`dodjkfqhwrzqjwkfnthl` 是 Supabase project ref。 - 内层 JSON 解析后含 `access_token`(JWT)、`refresh_token`、`expires_at`、`user`。 从 `http://user.id` 取 userId、`http://user.email` 取邮箱、`user.user_metadata.full_name` 拆出姓名。 - **access_token 是 JWT**,`base64url` 解第二段可读 `sub/email/exp` 做校验。 - token 约每周过期。过期后打开一次 Wispr Flow 会自动刷新 session;程序化刷新可走 Supabase `POST /auth/v1/token?grant_type=refresh_token`(需 anon apikey,未实现亦可)。 ## 2. 认证怎么带(两个端点前缀相反,务必逐字照做) - **REST**(`https://api.wisprflow.ai`):header `Authorization: <access_token>`, **不加 `Bearer ` 前缀**(加了反而 401)。用于验证登录态,例如 `GET /api/v1/dictionary/personal` 返回你的个人字典即表示认证成功。 - **gRPC 转录**:metadata header `authorization: Bearer <access_token>`(**要带 Bearer**)。 ## 3. 转录协议(核心) - 传输:gRPC over TLS 到 `http://inference.wisprflow.com`(可被 `https://inference-info.wisprflow.com/dictation_url.txt…` 重定向,但默认直连即可)。 - 服务/方法:`flow_api.v1.TranscriptionService`,**双向流** `TranscribeStream` (方法路径 `/flow_api.v1.TranscriptionService/TranscribeStream`);另有一元 `Transcribe` 作 fallback。 - gRPC clientOptions(照抄原应用): `keepalive_time_ms=4000`、`keepalive_timeout_ms=2000`、`keepalive_permit_without_calls=1`、 `http2.max_pings_without_data=0`、`http2.min_time_between_pings_ms=4000`、 `http2.min_ping_interval_without_data_ms=4000`。 - gRPC metadata(除 authorization 外,原应用还带以下,直连 inference host 时 JWT 已足够授权, 但可原样带上): - `baseten-authorization: Api-Key aEXAlxkF.cIvt1vqaijttubIVIWqr8T7npyYUXBOp` - `baseten-model-id: model-`、`x-baseten-environment: `(直连时为空串) - `flow-debug: false`、`disable-formatting: false` **时序**: 1. 打开 TranscribeStream。 2. 第一条 `Request` 内联 `init`(见下),并可携带首批音频 `payload`,`commit = COMMIT_FALSE`。 3. 后续 `Request` 持续发 `http://payload.audio_packets.items`(每批最多约 28 秒音频)。 4. 结束发一条 `commit = COMMIT_TRUE` 然后关闭客户端写端(`call.end()`);服务端继续回消息直到最终 result。 5. 服务端流回:`state`(含中间 `raw_text` / `formatted_text`)、`heartbeat`、最终 `result` (`result.output.plaintext` / `.html`,`result.status` 为 `RESULT_STATUS_FORMATTED` 时即最终格式化文本)。 **init 内容**(字段值参照原应用的 getMetadata/getPreferences/getClientInfo): ``` init.metadata = { user_id: <http://session.user.id>, session_id: <随机 UUID>, request_id: <随机 UUID>, audio_encoding: AUDIO_ENCODING_WAV, // 只实现 WAV 即可 environment: ENVIRONMENT_PRODUCTION, client: { name: "Wispr Flow", platform: PLATFORM_MACOS, version: { major:1, minor:5, patch:980 } }, debug: false, referral_code: "", always_wait_scribe: false } init.preferences = { user: { first_name, last_name, email }, // 来自 session language: [] // 留空 => 服务端自动检测语言 } ``` ## 4. 音频格式 - **16 kHz 单声道 signed-16-bit PCM(s16le)**。 - 分包:每包 640 个样本 = 40 ms = 1280 字节裸 PCM(无 WAV/RIFF 头)。 - `http://payload.audio_packets.items` 是 `repeated bytes`,每个元素就是一个 1280 字节的包。 - 用 ffmpeg:解码任意文件 `ffmpeg -i in -f s16le -acodec pcm_s16le -ac 1 -ar 16000 -`; 采集麦克风(macOS)`ffmpeg -f avfoundation -i :default -f s16le -acodec pcm_s16le -ac 1 -ar 16000 -`。 - (原应用另支持 64 kbps / 20 ms 帧的 Opus,本客户端不需要。) ## 5. Protobuf schema(重建 .proto,供 @grpc/proto-loader 加载) `package flow_api.v1;` `import "google/protobuf/duration.proto";` 关键枚举(proto-loader 以字符串名传值): - `AudioEncoding`: UNSPECIFIED=0, WAV=1, OPUS=2 - `Environment`: UNSPECIFIED=0, PRODUCTION=1, STAGING=2, DEVELOPMENT=3, TEST=4 - `Platform`: UNSPECIFIED=0, MACOS=1, WIN32=2, IOS=3, ANDROID=4, WEB=5, API=6 - `Commit`: UNSPECIFIED=0, TRUE=1, FALSE=2 - `ResultStatus`: UNSPECIFIED=0, FORMATTED=1, ERROR=2, RAW_TRANSCRIPT=3 - `AppType`: UNSPECIFIED=0, OTHER=1, BROWSER=2, PERSONAL_MESSAGING=3, WORK_MESSAGING=4, EMAIL=5, CHATBOT=6, DEVELOPER=7 - `SignatureStatus`: UNSPECIFIED=0, ELIGIBLE=1, ADDED=2 - `Confidence`: UNSPECIFIED=0, NONE=1, LOW=2, MEDIUM=3, HIGH=4 - `Origin`: UNSPECIFIED=0, MIXTURE=1, WHISPER=2, SCRIBE=3, VOXTRAL=4, QWEN=5 - `EditingStrength`: UNSPECIFIED=0, VERBATIM=1, LIGHT=2, MEDIUM=3, HEAVY=4 消息(字段号: 名称: 类型;从 bundle 内联描述符提取): - `Request` { 1:init:Init, 2:context:Context, 3:payload:Payload, 4:commit:Commit } - `Init` { 1:metadata:Metadata, 2:preferences:Preferences, 3:state:State } - `Metadata` { 1:user_id:string, 2:session_id:string, 3:request_id:string, 4:audio_encoding:AudioEncoding, 5:environment:Environment, 6:client:Client, 7:debug:bool, 8:referral_code:string, 9:always_wait_scribe:bool } - `Client` { 1:name:string, 2:platform:Platform, 3:version:Version } - `Version` { 1:major:uint32, 2:minor:uint32, 3:patch:uint32 } - `User` { 1:first_name:string, 2:last_name:string, 3:email:string } - `Preferences` { 1:user:User, 2:language:repeated Language, 3:vocabulary:StaticVocabulary, 4:replacements:Replacements, 5:style_config:StyleConfig } - `StaticVocabulary` { 1:dictionary_personal:repeated string, 2:dictionary_team:repeated string, 3:dictionary_personal_starred:repeated string, 4:dictionary_team_starred:repeated string } - `DynamicVocabulary` { 1:screen_content_ax:repeated string, 2:screen_content_ocr:repeated string, 3:variable_names:repeated string, 4:file_names:repeated string } - `Replacements` { 1:replacements_personal:map<string,string>, 2:replacements_team:map<string,string>, 3:snippets_personal:map<string,string>, 4:snippets_team:map<string,string> } - `StyleConfig` { 1:general_style:GeneralWritingStyle, 2:override_style:WritingStyle, 5:editing_strength:EditingStrength }(tagging/signature 可略) - `GeneralWritingStyle` { 1:other:WritingStyle, 2:personal:WritingStyle, 3:work:WritingStyle, 4:email:WritingStyle } - `Context` { 1:app:App, 2:textbox:Textbox, 3:vocabulary:DynamicVocabulary, 4:user_identifier:string, 5:content_text:string, 6:content_html:string, 7:session_dictation_apps:repeated App, 8:screenshot:bytes, 9:conversation:Conversation } - `App` { 1:name:string, 2:bundle_id:string, 3:url:string, 4:type:AppType } - `Textbox` { 1:contents:optional string, 2:before_text:optional string, 3:selected_text:optional string, 4:after_text:optional string } - `Conversation` { 1:id:string, 2:participants:repeated string, 3:messages:repeated ChatMessage } - `ChatMessage` { 1:role:ChatRole, 2:content:string, 3:sender_id:string }; `ChatRole`: UNSPECIFIED=0, USER=1, HUMAN=2, ASSISTANT=3 - `Payload` { oneof audio { 1:audio_packets:AudioPackets, 2:audio_file:AudioFile } } - `AudioPackets` { 1:items:repeated bytes } - `AudioFile` { 1:data:bytes } - `Response` { 1:result:Result, 2:state:State, 3:all_states:repeated State, 4:heartbeat:Heartbeat } - `Heartbeat` { 1:audio_received_seconds:float, 2:commit_ack:bool } - `Result` { 1:output:Transcription, 2:audio_duration:Duration, 3:response_time:ResponseTime, 4:signature_status:SignatureStatus, 5:status:ResultStatus, 6:stats:TranscriptionStats, 7:transcript_origin:string, 8:server_address:string } - `Transcription` { 1:html:string, 2:plaintext:string, 3:num_tokens:uint32, 4:languages:repeated Language, 5:confidence:float, 6:language_detection_low_confidence:bool } - `State` { 1:progress:Progress, 2:raw_text:Text, 3:formatted_text:Text, 4:confidence:Confidence, 5:user_actions:UserActions, 6:candidate_beams:repeated CandidateBeam, 7:audio_snr:optional float, 8:audio_volume:optional float, 9:mean_alignment_score:optional float } - `Progress` { 1:transcriber:Duration, 2:transcriber_finished:bool, 3:formatter:Duration, 4:formatter_finished:bool, 5:post_processing_finished:bool } - `Text` { 1:content:string, 2:num_tokens:uint32, 3:languages:repeated Language } - `UserActions` { 1:check_mic:bool, 2:check_language:Language } - `CandidateBeam` { 1:text:Text, 2:score:float, 3:confidence:Confidence, 4:origin:Origin, 5:alignment_score:optional float, 6:original_text:optional string, 7:word_alignments:repeated WordAlignment } - `WordAlignment` { 1:word:string, 2:score:float } - `ResponseTime` { 1..10 全部为 google.protobuf.Duration:transcribe, transcribe_overhead, format, format_overhead, post_process, total, external_asr_time, external_llm_time, generate_queue_duration, generate_work_duration } - `TranscriptionStats` { 1:word_deletions:uint32, 2:context_word_count:uint32, 3:word_substitutions:uint32 } - `Language` 是大枚举,只放 `LANGUAGE_UNSPECIFIED=0` 即可(language 传空数组即自动检测); `WritingStyle` 只放 `WRITING_STYLE_UNSPECIFIED=0`。 ## 6. 实现要求 - Node ESM,依赖 `@grpc/grpc-js` + `@grpc/proto-loader`;ffmpeg 用于音频。 - 分层文件:`session.js`(读 token)、`config.js`(上面的常量)、`client.js` (TranscribeStream 封装,用 EventEmitter 抛 partial/formatted/result)、`audio.js` (ffmpeg 解码/采集/分包)、`transcribe-file.js`、`transcribe-mic.js`、`whoami.js`。 - **关键坑(必须照做)**:`@grpc/proto-loader` 用 **`keepCase: true`**。 默认 `keepCase:false` 会把 `audio_packets` 等字段名转成 camelCase,导致音频 oneof 被静默丢弃, 服务端收到空音频、返回 0 秒空文本。设 `keepCase:true` 后请求/响应字段名与 .proto 一致。 loader 其余选项:`longs:String, enums:String, defaults:true, oneofs:true`。 - TLS 用 `grpc.credentials.createSsl()`(系统信任库)。 - 验证顺序:先跑通「读 session → 打 REST 确认 200」,再跑「文件转录」,最后「麦克风实时」。 文件路径是最干净的 smoke test。 ## 7. 验证标准 用 `say -o test.aiff "Hello, this is a test."` 生成音频,经客户端应得到带标点、大小写的 `RESULT_STATUS_FORMATTED` 文本。(实测原应用会用你的个人字典自动纠正专有名词,如把 "whisper flow" 纠正成 "Wispr Flow"。) ## 免责 依赖 Wispr 私有未公开 API,随时可能失效;仅限本人账号自用,勿分发、勿滥用配额、勿泄露 token。
查看原文
查看缓存全文

缓存时间: 2026/07/02 02:16

成功逆向 Wispr Flow不过关键时刻还是会被 Fable 5 的分类器拦截。开源一下:

任务

我的 Mac 装了 /Applications/Wispr http://Flow.app 且我是 Pro 会员。请构建一个独立的 Node 客户端, 它复用 Wispr Flow 的登录态和后端:读取本地已登录的 token,直接调用 Wispr 的 gRPC 转录服务, 把一段语音(文件或麦克风)转成文本。不要改动或依赖原应用运行,只借用它的 session 和后端。

以下是已经逆向确认的事实(Wispr Flow v1.5.980,com.electron.wispr-flow),当作给定条件使用。

1. 登录态(认证)

  • Wispr Flow 是 Electron 应用,把 Supabase 会话明文 JSON 存在: ~/Library/Application Support/Wispr Flow/session.json
  • 该文件是 { "sb-dodjkfqhwrzqjwkfnthl-auth-token": "<内层 JSON 字符串>" }。 键名后缀固定是 -auth-tokendodjkfqhwrzqjwkfnthl 是 Supabase project ref。
  • 内层 JSON 解析后含 access_token(JWT)、refresh_tokenexpires_atuser。 从 http://user.id 取 userId、http://user.email 取邮箱、user.user_metadata.full_name 拆出姓名。
  • access_token 是 JWTbase64url 解第二段可读 sub/email/exp 做校验。
  • token 约每周过期。过期后打开一次 Wispr Flow 会自动刷新 session;程序化刷新可走 Supabase POST /auth/v1/token?grant_type=refresh_token(需 anon apikey,未实现亦可)。

2. 认证怎么带(两个端点前缀相反,务必逐字照做)

  • RESThttps://api.wisprflow.ai):header Authorization: <access_token>不加 Bearer 前缀(加了反而 401)。用于验证登录态,例如 GET /api/v1/dictionary/personal 返回你的个人字典即表示认证成功。
  • gRPC 转录:metadata header authorization: Bearer <access_token>要带 Bearer)。

3. 转录协议(核心)

  • 传输:gRPC over TLS 到 http://inference.wisprflow.com(可被 https://inference-info.wisprflow.com/dictation_url.txt… 重定向,但默认直连即可)。
  • 服务/方法:flow_api.v1.TranscriptionService双向流 TranscribeStream (方法路径 /flow_api.v1.TranscriptionService/TranscribeStream);另有一元 Transcribe 作 fallback。
  • gRPC clientOptions(照抄原应用): keepalive_time_ms=4000keepalive_timeout_ms=2000keepalive_permit_without_calls=1http2.max_pings_without_data=0http2.min_time_between_pings_ms=4000http2.min_ping_interval_without_data_ms=4000
  • gRPC metadata(除 authorization 外,原应用还带以下,直连 inference host 时 JWT 已足够授权, 但可原样带上):
    • baseten-authorization: Api-Key aEXAlxkF.cIvt1vqaijttubIVIWqr8T7npyYUXBOp
    • baseten-model-id: model-x-baseten-environment: (直连时为空串)
    • flow-debug: falsedisable-formatting: false

时序

  1. 打开 TranscribeStream。
  2. 第一条 Request 内联 init(见下),并可携带首批音频 payloadcommit = COMMIT_FALSE
  3. 后续 Request 持续发 http://payload.audio_packets.items(每批最多约 28 秒音频)。
  4. 结束发一条 commit = COMMIT_TRUE 然后关闭客户端写端(call.end());服务端继续回消息直到最终 result。
  5. 服务端流回:state(含中间 raw_text / formatted_text)、heartbeat、最终 resultresult.output.plaintext / .htmlresult.statusRESULT_STATUS_FORMATTED 时即最终格式化文本)。

init 内容(字段值参照原应用的 getMetadata/getPreferences/getClientInfo):

init.metadata = {
  user_id:  <http://session.user.id>,
  session_id: <随机 UUID>,
  request_id: <随机 UUID>,
  audio_encoding: AUDIO_ENCODING_WAV,   // 只实现 WAV 即可
  environment: ENVIRONMENT_PRODUCTION,
  client: { name: "Wispr Flow", platform: PLATFORM_MACOS,
            version: { major:1, minor:5, patch:980 } },
  debug: false, referral_code: "", always_wait_scribe: false
}
init.preferences = {
  user: { first_name, last_name, email },  // 来自 session
  language: []                             // 留空 => 服务端自动检测语言
}

4. 音频格式

  • 16 kHz 单声道 signed-16-bit PCM(s16le)
  • 分包:每包 640 个样本 = 40 ms = 1280 字节裸 PCM(无 WAV/RIFF 头)。
  • http://payload.audio_packets.itemsrepeated bytes,每个元素就是一个 1280 字节的包。
  • 用 ffmpeg:解码任意文件 ffmpeg -i in -f s16le -acodec pcm_s16le -ac 1 -ar 16000 -; 采集麦克风(macOS)ffmpeg -f avfoundation -i :default -f s16le -acodec pcm_s16le -ac 1 -ar 16000 -
  • (原应用另支持 64 kbps / 20 ms 帧的 Opus,本客户端不需要。)

5. Protobuf schema(重建 .proto,供 @grpc/proto-loader 加载)

package flow_api.v1; import "google/protobuf/duration.proto";

关键枚举(proto-loader 以字符串名传值):

  • AudioEncoding: UNSPECIFIED=0, WAV=1, OPUS=2
  • Environment: UNSPECIFIED=0, PRODUCTION=1, STAGING=2, DEVELOPMENT=3, TEST=4
  • Platform: UNSPECIFIED=0, MACOS=1, WIN32=2, IOS=3, ANDROID=4, WEB=5, API=6
  • Commit: UNSPECIFIED=0, TRUE=1, FALSE=2
  • ResultStatus: UNSPECIFIED=0, FORMATTED=1, ERROR=2, RAW_TRANSCRIPT=3
  • AppType: UNSPECIFIED=0, OTHER=1, BROWSER=2, PERSONAL_MESSAGING=3, WORK_MESSAGING=4, EMAIL=5, CHATBOT=6, DEVELOPER=7
  • SignatureStatus: UNSPECIFIED=0, ELIGIBLE=1, ADDED=2
  • Confidence: UNSPECIFIED=0, NONE=1, LOW=2, MEDIUM=3, HIGH=4
  • Origin: UNSPECIFIED=0, MIXTURE=1, WHISPER=2, SCRIBE=3, VOXTRAL=4, QWEN=5
  • EditingStrength: UNSPECIFIED=0, VERBATIM=1, LIGHT=2, MEDIUM=3, HEAVY=4

消息(字段号: 名称: 类型;从 bundle 内联描述符提取):

  • Request { 1:init:Init, 2:context:Context, 3:payload:Payload, 4:commit:Commit }
  • Init { 1:metadata:Metadata, 2:preferences:Preferences, 3:state:State }
  • Metadata { 1:user_id:string, 2:session_id:string, 3:request_id:string, 4:audio_encoding:AudioEncoding, 5:environment:Environment, 6:client:Client, 7:debug:bool, 8:referral_code:string, 9:always_wait_scribe:bool }
  • Client { 1:name:string, 2:platform:Platform, 3:version:Version }
  • Version { 1:major:uint32, 2:minor:uint32, 3:patch:uint32 }
  • User { 1:first_name:string, 2:last_name:string, 3:email:string }
  • Preferences { 1:user:User, 2:language:repeated Language, 3:vocabulary:StaticVocabulary, 4:replacements:Replacements, 5:style_config:StyleConfig }
  • StaticVocabulary { 1:dictionary_personal:repeated string, 2:dictionary_team:repeated string, 3:dictionary_personal_starred:repeated string, 4:dictionary_team_starred:repeated string }
  • DynamicVocabulary { 1:screen_content_ax:repeated string, 2:screen_content_ocr:repeated string, 3:variable_names:repeated string, 4:file_names:repeated string }
  • Replacements { 1:replacements_personal:map<string,string>, 2:replacements_team:map<string,string>, 3:snippets_personal:map<string,string>, 4:snippets_team:map<string,string> }
  • StyleConfig { 1:general_style:GeneralWritingStyle, 2:override_style:WritingStyle, 5:editing_strength:EditingStrength }(tagging/signature 可略)
  • GeneralWritingStyle { 1:other:WritingStyle, 2:personal:WritingStyle, 3:work:WritingStyle, 4:email:WritingStyle }
  • Context { 1:app:App, 2:textbox:Textbox, 3:vocabulary:DynamicVocabulary, 4:user_identifier:string, 5:content_text:string, 6:content_html:string, 7:session_dictation_apps:repeated App, 8:screenshot:bytes, 9:conversation:Conversation }
  • App { 1:name:string, 2:bundle_id:string, 3:url:string, 4:type:AppType }
  • Textbox { 1:contents:optional string, 2:before_text:optional string, 3:selected_text:optional string, 4:after_text:optional string }
  • Conversation { 1:id:string, 2:participants:repeated string, 3:messages:repeated ChatMessage }
  • ChatMessage { 1:role:ChatRole, 2:content:string, 3:sender_id:string }; ChatRole: UNSPECIFIED=0, USER=1, HUMAN=2, ASSISTANT=3
  • Payload { oneof audio { 1:audio_packets:AudioPackets, 2:audio_file:AudioFile } }
  • AudioPackets { 1:items:repeated bytes }
  • AudioFile { 1:data:bytes }
  • Response { 1:result:Result, 2:state:State, 3:all_states:repeated State, 4:heartbeat:Heartbeat }
  • Heartbeat { 1:audio_received_seconds:float, 2:commit_ack:bool }
  • Result { 1:output:Transcription, 2:audio_duration:Duration, 3:response_time:ResponseTime, 4:signature_status:SignatureStatus, 5:status:ResultStatus, 6:stats:TranscriptionStats, 7:transcript_origin:string, 8:server_address:string }
  • Transcription { 1:html:string, 2:plaintext:string, 3:num_tokens:uint32, 4:languages:repeated Language, 5:confidence:float, 6:language_detection_low_confidence:bool }
  • State { 1:progress:Progress, 2:raw_text:Text, 3:formatted_text:Text, 4:confidence:Confidence, 5:user_actions:UserActions, 6:candidate_beams:repeated CandidateBeam, 7:audio_snr:optional float, 8:audio_volume:optional float, 9:mean_alignment_score:optional float }
  • Progress { 1:transcriber:Duration, 2:transcriber_finished:bool, 3:formatter:Duration, 4:formatter_finished:bool, 5:post_processing_finished:bool }
  • Text { 1:content:string, 2:num_tokens:uint32, 3:languages:repeated Language }
  • UserActions { 1:check_mic:bool, 2:check_language:Language }
  • CandidateBeam { 1:text:Text, 2:score:float, 3:confidence:Confidence, 4:origin:Origin, 5:alignment_score:optional float, 6:original_text:optional string, 7:word_alignments:repeated WordAlignment }
  • WordAlignment { 1:word:string, 2:score:float }
  • ResponseTime { 1..10 全部为 google.protobuf.Duration:transcribe, transcribe_overhead, format, format_overhead, post_process, total, external_asr_time, external_llm_time, generate_queue_duration, generate_work_duration }
  • TranscriptionStats { 1:word_deletions:uint32, 2:context_word_count:uint32, 3:word_substitutions:uint32 }
  • Language 是大枚举,只放 LANGUAGE_UNSPECIFIED=0 即可(language 传空数组即自动检测); WritingStyle 只放 WRITING_STYLE_UNSPECIFIED=0

6. 实现要求

  • Node ESM,依赖 @grpc/grpc-js + @grpc/proto-loader;ffmpeg 用于音频。
  • 分层文件:session.js(读 token)、config.js(上面的常量)、client.js (TranscribeStream 封装,用 EventEmitter 抛 partial/formatted/result)、audio.js (ffmpeg 解码/采集/分包)、transcribe-file.jstranscribe-mic.jswhoami.js
  • 关键坑(必须照做)@grpc/proto-loaderkeepCase: true。 默认 keepCase:false 会把 audio_packets 等字段名转成 camelCase,导致音频 oneof 被静默丢弃, 服务端收到空音频、返回 0 秒空文本。设 keepCase:true 后请求/响应字段名与 .proto 一致。 loader 其余选项:longs:String, enums:String, defaults:true, oneofs:true
  • TLS 用 grpc.credentials.createSsl()(系统信任库)。
  • 验证顺序:先跑通「读 session → 打 REST 确认 200」,再跑「文件转录」,最后「麦克风实时」。 文件路径是最干净的 smoke test。

7. 验证标准

say -o test.aiff "Hello, this is a test." 生成音频,经客户端应得到带标点、大小写的 RESULT_STATUS_FORMATTED 文本。(实测原应用会用你的个人字典自动纠正专有名词,如把 “whisper flow” 纠正成 “Wispr Flow”。)

免责

依赖 Wispr 私有未公开 API,随时可能失效;仅限本人账号自用,勿分发、勿滥用配额、勿泄露 token。


Flow | A Simple Pomodoro Timer to Organize Your Everyday Work

Source: https://www.flow.app/ Flow is simple, beautiful, and intuitive, making it easier than ever to manage your time with a proven work method. And even better – all the basic features are free. No ads, no sign-up, no credit card.

Focus Timer

Minimalistic

Ad-Free

iPhone

Mac

iPad

Watch

App Blocker

Web Blocker

Discover features

相似文章