@Lenciel

夜归

天色已晚,冯璐的车开得越来越慢。

虽然绕城高速车流量不大,但她很少在夜里开这么远车。

暮春三月,月光穿过氤氲的雾气,让道路两旁的稻田,平添几分素净。

第一次开车上绕城,谢威才四岁多,她想。那时候还没有还耕,绿道边的公园收拾得很漂亮。十多年过去了,已经有点儿忘了当时开车去哪儿,但肯定比今天轻松。

今天是去都江堰的疗养院接谢威回家。

谢威十六岁的时候,患上了重度抑郁。医生宣布时的神态,让她觉得好像这是她的过错。几年过去了,各种治疗方法断断续续用了不少,谢威的状态反而变得更差了,只好送去疗养院呆了六个月。但看起来,效果不大:谢威见到她之后,招呼也不打,就钻进后座,靠着车窗,一支接一支地抽起了烟。

她有些拿不准,他这是因为自己被送去疗养院还在生气,还是经过治疗之后和人保持距离感到更自在。

谢威曾经是个挺爱说话的孩子。他是她和谢高的独子,来得很晚,过程也艰辛,所以他们在他身上花了很多时间。每天她一下班回家,他就笑嘻嘻地跟在她后面问这问那。再长大些,到家变得比她还晚,但也每天找时间跟她说学校里的各种事情和烦恼。可自从十五六岁起,这孩子好像变了一个人,阴郁易怒,再也不跟她好好说话。

车里无尽的沉默让冯璐感到焦虑,她决定无论如何也要打破它。

「你爸很想你,威威」,她说。

没有回应。

「他今天有点忙,所以没有来接你」,她接着说。但声音微弱,透露着不熟练的骗子才有的心虚。

还是没人回应,只有开窗扔烟头时的呼呼风声。

车已经开上了下高速的匝道。对面车道有很多车开着远光灯,冯璐觉得眼花缭乱。她尽量什么都不去想,将注意力集中在驾驶上。出了收费处,前面不远就是天府一街。沿着天府大道继续往南,很快就可以到家了。这个时候,还要再控制自己什么都不想是不可能的了。

因为冯璐这辈子几乎都在这个区域里度过。

Holiday Inn 是她办婚宴的地方,她现在还能想起那天的场景。父亲牵着自己的手递给谢高,然后回到台下,仿佛活生生掏出了自己还在跳动的心脏一般,瘫在座位里。几年前,他真的走了,照片挂在老家无人再住的旧房子里。

前面不远是谢威的小学。她记得第一次参观的时候,那校园感觉真大,跟她印象中的小学比起来,简直有点空空荡荡。谢威走累了,就骑在他爸爸脖子上,这一幕她记得特别清楚,因为他们俩这样亲密的时候不多。

谢威在后座又点了一支烟。

「你到前面来坐会儿吧?」,她继续努力。

谢威沉默了好一会儿,才下定决心般地闷声回答道:「谢了,不用。」

她把车靠在了路边,停了下来,自己拉开车门坐在了他身旁。

后排烟雾缭绕,几乎看不清谢威的样子。但他遗传了她的皮肤,白净细腻,在有限的光亮下也闪闪发光。

「你回来之后怎么打算?想要做点儿什么吗?」

「我不知道,别问问题了行吗?」

「你可以试试找个在家里就可以做的事情。」

「我说了,我不知道」,他把抽了一半的烟扔出窗外,然后转过身去避开跟她四目相接的尴尬。

「本来想一路开回家,但我觉得有点累了,只能停下来歇会儿。毕竟妈妈还是老了」,她笑起来。

他转过头打量了一下她,也努力地笑了一下,表示回应。

「我们还是赶紧吧」,这转瞬即逝的笑容让她觉得鼓舞,「你爸爸还在家里等着我们。」

但在心里面,她知道,谢高会躺在卧室的床上打游戏,她们母子俩进门的时候他也不会出来看一眼。很长时间以来,不管家里发生什么,他都懒得看上一眼。所以她希望谢威呆在家里,不管他多么沉默,不管他是不是经常会锁上门不出来,不管他要把家里多少东西扔到地上,好歹,她会有个伴儿。

车开过五街路口,天府大桥边上很多快餐店还开着。父亲上来帮忙带小孩的时候,在那里弄过一家米粉店,做软件园里上班的人的生意。店不大,但很温馨,她有空也喜欢去帮忙干点儿活。如果父亲没有突然去世,那家店应该就还在,谢威也可以去帮帮忙,说不定会很喜欢。

但那家店具体的装潢她已经忘了。现在想到父亲,她能记起的总是最后那几个月他住的那间病房。书或电影里的病房总是窗明几净,散发着洁白温馨的光。但真的在病房里照顾过爱的人,就知道病房很具体:里面飘荡着各种气味和可怕的消息,将其他美好的记忆一寸寸吞噬甚至直接埋葬。

谢威的病房倒是一点也不具体。送他去的时候,医院不让跟着进房间参观。今天去接的时候,他已经拿着行李在大厅等着了。那个送他出来的医生一直劝说冯璐不要把他带走,觉得他的情况应该继续留在那边治疗。除开聊这个,那医生不愿意再回答任何别的问题。

「那里面究竟怎么样?我们是因为那边环境好才送你去的。希望看着山山水水,你能振奋起来。我这么问没别的意思,威威,我只想知道你在里面好不好,我看不出来,你也从来不联系我们。」

「那里很糟」,她听到他重重地叹了口气,「每一天都很糟」。

「但我们能负担得起的最好的地方就是那里了。我和你爸爸也…」

「是啊,你们只能做到这些了,所以能不能不要再问了」,谢威打断了她,脸上泛起她熟悉的敌意。冯璐本来还想问点儿别的,也只好不再说话。车里恢复了冰冷的平静,她也就轻点油门,抓紧时间回家。

「爸他今天又在忙什么呢」,车开进小区倒入停车位时,谢威突然开口问她。

她不知道怎么回答,就只好撒娇般地说:「威威,我也想抽根烟。自从和你爸谈恋爱就没抽过了,他不喜欢。但今天我想抽一根再上去,可以吗?」

谢威点了根烟给她递过去,「妈,你总问我怎么样,你们怎么样?」

「我们?挺好的啊。以后你就知道了,像我和你爸这样已经挺好了。有过开心的时候,现在也彼此有关照,能妥善地参与彼此家庭的一些事务。生活嘛,大部分时候总是熬着的,虽然熬不过去可能也没啥关系。」

「熬不过去也没啥关系?」

「我们可能不该聊这个。但小时候我听你爷爷给我讲故事,说熬过冬天的蝴蝶会变黄,所以每次看到黄色的蝴蝶就觉得它们好幸运,好厉害。现在有时候看到黄色的蝴蝶,我会替它们感到有点难过,多辛苦啊。」

「生活这么辛苦,那如果让你随便许愿,你想过什么样的日子呢?」

「可以随便许愿不等于编故事了吗。那我希望我刚离开自己的老公,或者因为有点害怕婚姻还没结婚。但很有钱,所以有三四个有趣又很帅的情人。没有孩子」,冯璐说到这里停了一下,「反正我们这是编故事嘛。你也知道,和生活里的孩子不一样,故事里一开始就有孩子常常会破坏故事。」

「妈你继续,我不在意这个。」

「我每天打伞,因为自己很漂亮也很白净。我还经常搬家,这会儿正在欧洲的一个小镇,之前在北美,再之前在日本或者云南之类的吧。每个地方待的不久,但都收获过一点浪漫。又不太多,没有多到让我可以安定下来。最终,我会在一个可以看到海的地方,因为一个人再也不走了。我和他要了个孩子,像你一样好看。这个孩子带给我很多,但很快就长大并离开了我。最终,我开了一家书店,或者一个客栈,也可以是在海里把自己淹死了。」

「这是什么意思?」

「就是结局,可以很开心,也可以很悲伤,我不在乎,反正我努力生活过了。」

「看不出来,你很潮嘛,妈妈。」

她突然想起来一句很多年前《我的阿勒泰》里的台词,就有些忿忿地把烟掐了,往电梯间走去。

进了屋,谢高的声音从卧室里传来:「你们回来了吗?」

谢威没回答,拎着自己的包径直走进了房间。

她也没回答,走进卧室后,就坐在了梳妆镜前的凳子上。她从镜子里能看到他,也能看到自己保养得很好的头发和眼角嘴边的皱纹看起来很不协调。特别是眼睛下面的细纹,她想,妈妈有的最后我都会有,也许是时候长点儿白头发了,可能还更搭配一点。

突然,她发现谢高放下了手柄,在床上盯着她。他大概是对自己的问题没有人回答感到有些生气。他们目光相遇时,她愣了一下,还是觉得这个问题不用回答。

「他回来了吗?你问明白他接下来怎么打算了吗?」,谢高只好继续问道。

不花钱的同声传译

最近参加一个技术会议,我请大家举手简单统计了一下参会者在 ChatGPT、Kimi 等产品上的活跃度,发现每天都用的人大大减少了。

这跟我两年前的判断差不多: LLM 对搜索会有很大的冲击(因为用户脑子里有关键词,好写 prompt),但大部分时候,聊天仍然是个非常糟糕的用户界面(问什么怎么问,压力都在用户这边)。

如果让我总结使用 LLM 频度最高的场景,是嵌在飞书里的那些跟开会相关的功能:自动字幕,自动转文本,自动给总结等等。

如果让蒙爷总结,他的最核心场景会是 Youtube 上实时给视频的添加的中文字幕,一下子把他可以看懂的视频扩大了几个数量级。

这些功能都涉及语音转文本再进行处理(记录、总结或者翻译)。由于目前的解决方案基本都是跑在服务器端的,所以也有一些问题。

首先是不够安全。不管是私人的通话,还是工作的会议,把音频录制和处理交给第三方,特别是国内一些厂商,还是让人感觉有点害怕的。

其次是不够灵活。比如,YouTube 自动加字幕的功能,依赖 Google 的服务。本地下载了一部冷门的电影,就还得老老实实花时间去找字幕。

这些问题的解决,核心是下面两个方面:

  • 有没有办法很容易的提取音轨或者转发音频流
  • 有没有办法在本地对音轨或者音频流使用 LLM 进行处理

如果是有钱人,我会推荐 Rogue Amoeba 家的 Loopback 和 Audio Hijack。 Loopback 可以通过创建虚拟声卡对音频进行各种的控制和转发解决第一步。而 Audio Hijack 的新版本里内置了一个 transcribe 模块解决第二步实际上背后也是 OpenAI 的 Whisper ,精确版对应 large-v2 的模型。

advancedblocks-transcribe-frommic.png
图1. 从输入设备对音频流进行录制和转写

如果稍微愿意动动手,我会推荐 ffmpeg +VB-Cable 解决第一个问题,whisper-cpp 解决第二步:因为它们免费,并且开源,完全可控。

下面的步骤针对 Mac,但 Widnes 上相同的思路应该也是工作的。

安装 ffmpeg 和 VB-Cable

ffmpeg 主要用来处理音频文件,安装就直接用 Homebrew。

brew install ffmpeg

然后如果是现场的音频文件,需要处理成 16khz 的 WAV 文件:

ffmpeg -i audio.mp3 -acodec pcm_s16le -ac 1 -ar 16000 audio.wav

如果是视频,则需要抓取里面的音轨做转换:

ffmpeg -i vod.mp4 -hide_banner -vn -ar 16000 -ac 1 -c:a pcm_s16le -y vod-resampled.wav

VB-Cable 是一个安装文件,装好了之后可以看到在音频配置里面多了一块虚拟声卡:

vb-cable.jpg
图2. 输入输出里面都有,因此可以录也可以回放

这个主要是用来捕获音频流的。

安装 whisper-cpp

虽然 Homebrew 提供了 formula 但是我选择了 clone 项目来编译。因为有一些平台相关的优化我不知道 Homebrew 的版本是怎么处理的。

比如,把 Apple Neural Engine (ANE) 用起来 whisper-cpp 能够快最少三倍,但是它需要做一些设置更多关于 whisper-cpp 对 Core ML 的支持,可以看这里

pip install ane_transformers
pip install openai-whisper
pip install coremltools

然后生成模型的 Core ML 版本,比如我使用了 large-v3 版本的模型:

./models/generate-coreml-model.sh large-v3

最后需要打开编译开关进行编译:

# using Makefile
make clean
WHISPER_COREML=1 make -j

当跑起来的时候提示 Core ML 模型正确加载了,就说明成功了:

./main -m models/ggml-large-v3-q5_0.bin ./samples/jfk.wav

...
whisper_init_state: kv cross size =  245.76 MB
whisper_init_state: loading Core ML model from 'models/ggml-large-v3-encoder.mlmodelc'
whisper_init_state: first run on a device may take a while ...
whisper_init_state: Core ML model loaded
ggml_backend_metal_buffer_type_alloc_buffer: allocated buffer, size =     8.80 MiB, ( 1490.55 / 12288.02)

处理音频文件

whisper-cpp 处理本地的音频文件就只需要组合使用参数,比如我要用 8 线程对一部法语电影的音轨做翻译并且生成字幕文件:

./main -m ./models/ggml-large-v3.bin -f samples/movie.wav -l fr -t 8 -osrt --translate

whisper-cpp 有很丰富的参数:

usage: whisper-cpp [options] file0.wav file1.wav ...

options:
  -h,        --help              [default] show this help message and exit
  -t N,      --threads N         [4      ] number of threads to use during computation
  -p N,      --processors N      [1      ] number of processors to use during computation
  -ot N,     --offset-t N        [0      ] time offset in milliseconds
  -on N,     --offset-n N        [0      ] segment index offset
  -d  N,     --duration N        [0      ] duration of audio to process in milliseconds
  -mc N,     --max-context N     [-1     ] maximum number of text context tokens to store
  -ml N,     --max-len N         [0      ] maximum segment length in characters
  -sow,      --split-on-word     [false  ] split on word rather than on token
  -bo N,     --best-of N         [5      ] number of best candidates to keep
  -bs N,     --beam-size N       [5      ] beam size for beam search
  -wt N,     --word-thold N      [0.01   ] word timestamp probability threshold
  -et N,     --entropy-thold N   [2.40   ] entropy threshold for decoder fail
  -lpt N,    --logprob-thold N   [-1.00  ] log probability threshold for decoder fail
  -debug,    --debug-mode        [false  ] enable debug mode (eg. dump log_mel)
  -tr,       --translate         [false  ] translate from source language to english
  -di,       --diarize           [false  ] stereo audio diarization
  -tdrz,     --tinydiarize       [false  ] enable tinydiarize (requires a tdrz model)
  -nf,       --no-fallback       [false  ] do not use temperature fallback while decoding
  -otxt,     --output-txt        [false  ] output result in a text file
  -ovtt,     --output-vtt        [false  ] output result in a vtt file
  -osrt,     --output-srt        [false  ] output result in a srt file
  -olrc,     --output-lrc        [false  ] output result in a lrc file
  -owts,     --output-words      [false  ] output script for generating karaoke video
  -fp,       --font-path         [/System/Library/Fonts/Supplemental/Courier New Bold.ttf] path to a monospace font for karaoke video
  -ocsv,     --output-csv        [false  ] output result in a CSV file
  -oj,       --output-json       [false  ] output result in a JSON file
  -ojf,      --output-json-full  [false  ] include more information in the JSON file
  -of FNAME, --output-file FNAME [       ] output file path (without file extension)
  -ps,       --print-special     [false  ] print special tokens
  -pc,       --print-colors      [false  ] print colors
  -pp,       --print-progress    [false  ] print progress
  -nt,       --no-timestamps     [false  ] do not print timestamps
  -l LANG,   --language LANG     [en     ] spoken language ('auto' for auto-detect)
  -dl,       --detect-language   [false  ] exit after automatically detecting language
             --prompt PROMPT     [       ] initial prompt
  -m FNAME,  --model FNAME       [models/ggml-base.en.bin] model path
  -f FNAME,  --file FNAME        [       ] input WAV file path
  -oved D,   --ov-e-device DNAME [CPU    ] the OpenVINO device used for encode inference
  -ls,       --log-score         [false  ] log best decoder scores of tokens
  -ng,       --no-gpu            [false  ] disable GPU

处理音频流

音频文件不能覆盖所有场景。如果想要在看各种网页或者视频的时候有一个「同声翻译」,或者是在开会的时候有「实时转录」,就涉及到处理音频流了。

把 VB-Cable 设置成输出之后,我们的所有音频就会转发到这个设备上(如果你同时还想听到,可以再甚至个输入)。然后编译 whisper-cpp 提供的 stream 进行监听:

brew install sdl2
make stream
./stream -m models/ggml-large-v3-q5_0.bin -t 8 --step 5000 --length 5000 -l zh

这里因为指定了输出语言是中文,所以如果你播放的是其他语言的内容,whisper-cpp 会尝试进行翻译:虽然效果一般,但是就像 YouTube 生成的自动字幕一样,能够帮助你大概看明白了。

TODO

从音频转文字之后,还可以做一些额外的处理,比如把一个会议的内容交给模型做提纯做归纳总结等等。

相信在各个活菩萨公司开源的大模型的帮助下,端侧的更多场景会从可玩过渡到可用。