『側に / Sobani』 —— 在你身旁

Sobani —— Fully self-hostable and customizable media entertaining stream service based on Node.js for couples, friends, collaboration teams, and even quarantined office workers.

在几周前的一个晚上,跟猫猫闲聊时,猫猫有提到说「要是可以一起听音乐 / 看电影就好了」。虽然 Team Viewer 的音频共享可以将被连接的电脑的音频发送过来,同时也会将屏幕共享过来,在网络不太好的情况下音频会断断续续,同时视频的质量也不太高,体验会比较差QwQ 另外 Team Viewer 在开始语音的时候,就不能 forward 电脑输出的音频给对方了,因此还需要别的软件来做语音通话Σ(・□・;)

在一边想象着跟猫猫听音乐 / 看电影的时候,一边就在跟猫猫开始计划着写这个项目。每次开始写项目时,猫猫和 Cocoa 一致认为日常最难问题就是叫什么名字喵,一开始猫猫提到可以用「接続 / Setsuzoku」抽取一个部分和 link 接起来,也就是「setsulink」这样的;又或者是「cosetsu」,即 coop 和 setsuzoku。在我们纠结一段时间名字之后,

猫猫提议:把自己的心愿写进去

Cocoa:Sobani そばに?

猫猫:诶 是什么呀

Cocoa:就是在身边的意思,要说心愿的话,大概就是这个 w 想要在身边,但是又只能暂时用软件来实现啦

在决定好用这样的心愿起名字之后,就开始了这个满怀爱(狗)意(粮)的项目啦/ 头一次用 VS Code 的 Live Share 一起写代码!

那么想到的连接办法当然就是 NAT 穿透 了!在最开始的时候,我们决定使用的语言是 Go,然后本来是想用 libp2p 来完成的,但是后来验证发现,libp2p 默认使用的是 TCP 连接,虽然 TCP 的 NAT 穿透是有方法的,但是似乎实现起来会太复杂了,以及对于 Go 上跨平台的、好看的 UI 还没有什么头绪(>﹏<)

项目的地址在 nekomeowww/sobani,最新的 build 都放在 release 里面啦~同时支持 macOS / Linux / Windows 的说(*^3^)

公开的 tracker 服务器的地址是 34.80.41.119:3000,这个 Sobani 的 tracker 服务器也是可以自己搭建的/

Sobani tracker 的项目地址是 nekomeowww/sobani-tracker,里面的 deploy.sh 脚本在 Debian / Ubuntu 上验证过~

关于 UDP NAT 穿透的话,可以简述为如下过程~

首先我们有两台电脑 A、B,以及一个在公网上的 tracker 服务器 S,假设它监听 UDP 端口 3000

A、B 在一开始的时候都会向 S 服务器 announce 自己,此时 S 服务器收到了 A、B 的消息之后,会记录下来它们的包在 NAT 之后的地址与端口号,并且发送确认的 UDP 回复。此外,为了可以实时更新 A、B 的信息,在收到 S 服务器的确认之后,A、B 会每隔一段时间继续发送 keep alive 的 UDP 包到 S 服务器

接下来,假设 A 希望跟 B 建立连接,那么此时 A 会先向 tracker 服务器,也就是 S,请求 B 的地址与端口号。S 服务器在收到 A 的请求之后,会将 B 的地址与端口号送回给 A;同时,S 服务器也会将 A 的地址与端口号发送给 B。

于是开始进行正式的 UDP NAT 穿透~

此时 A、B 都将拿到对方的地址与端口号,并且会在拿到之后就直接开始尝试 NAT 穿透,也即是(几乎同时)给对方发送一个包,我们将 A、B 发出的第一个 UDP 包称之为 knock 包。我们不妨假设, A 先开始发送 knock 包给 B,此时有

A -> NAT A -> NAT B -!> B

这个时候 NAT B 会丢弃这个包,而 NAT A 因为 A 发出去了给 B 的数据包,所以 NAT A 会在一段时间内等待 B 的回应。

在 A 开始发送 knock 包给 B的同时, B 也会给 A 发送 knock 包,此时有

A <- NAT A <- NAT B <- B

NAT B 因为 B 发出去了给 A 的数据包,所以 NAT B 会在一段时间内等待 A 的回应。同时, NAT A 会收到来自 B 的包,而前面一个状态里,NAT A 也在等待 B 的回应,因此 B 的包会被 NAT A 转发给 A。也即是此时 NAT A 上已经建立好 A <-> B 的连接,而 NAT B 还在等待 A 的回应。

那么现在收到 B 发来的 knock 包的 A,再次向 B 发送一个包

A -> NAT A -> NAT B -> B

这个时候 NAT B 等到了 A 的包,因此会转发给 B,同时也就建立好了 A <-> BNAT B 上的连接!

在使用 Go 尝试了一段时间之后,我们决定使用 nodejs 来完成这个项目。对于 JavaScript 这样弱类型、无强制类型检查的语言来说,前期实现项目原型可以说是十分便捷,虽然这在一定程度上增加了一些后来 debug 与验证用户输入时的困扰。在取舍之间,目前还是觉得用 nodejs 作为 alpha 版本的语言是最为合适的。既然选用了 nodejs 的话,那么跨平台的 UI 就选择 Electron 了喵~

下一步则是使用 nodejs 来捕获音频~

这里猫猫找到了 naudiodon,它以 Port Audio 作为 backend,实现了 nodejs 上的 binding。不过 naudiodon 输出的是原始的 PCM 流,这里我们需要对它进行编码压缩再传输~ 因此 Cocoa 叼来了 prism-media@discordjs/opus,将 PCM 流编码成 opus 流,经由刚才建立好的 UDP 隧道发送给对方~对方在收到音频流之后,只需要先用 Opus Decoder 解码,然后再 pipe 给用户选择好的 naudiodon 的输出设备就行~

在期间被各种奇怪的问题折腾之后,比如我们发现 Windows 上的音频设备,如果使用 Windows MultiMedia Extension 这个 API 的话,其设备名称居然最大只有 32 个字符?!

而且居然还是写死在 Windows SDK 里面的?!

/* general constants */
#define MAXPNAMELEN      32     /* max product name length (including NULL) */
#define MAXERRORLENGTH   256    /* max error text length (including NULL) */
#define MAX_JOYSTICKOEMVXDNAME 260 /* max oem vxd name length (including NULL) */

typedef struct tagWAVEINCAPSW {
    WORD    wMid;                    /* manufacturer ID */
    WORD    wPid;                    /* product ID */
    MMVERSION vDriverVersion;        /* version of the driver */
    WCHAR   szPname[MAXPNAMELEN];    /* product name (NULL terminated string) */
    DWORD   dwFormats;               /* formats supported */
    WORD    wChannels;               /* number of channels supported */
    WORD    wReserved1;              /* structure packing */
} WAVEINCAPSW, *PWAVEINCAPSW, *NPWAVEINCAPSW, *LPWAVEINCAPSW;

Windows 果然神奇,救不了救不了(

还别说光是思考 Windows 上的 C Compiler 叫什么以及那些神奇的 Windows 风格的参数就折腾了猫猫和 Cocoa 好久(

不过最后总算是知道了输入设备的名称显示不完整并不是我们的锅,于是就猫猫那边就又开始写 UI 了~ 在写 UI 之前,Cocoa 跟猫猫已经完成了一个 cli 版本的 Sobani,于是在那之后的绝大部分结对编程的语音通话里,都是使用的 Sobani 来完成\(//∇//)\ 实际测试的时候发现语音的音质非常棒~同时也不需要占用过多带宽,还可以在一边断线之后,另一边单独重连~

不过 Cocoa 是头一次使用 Electron 来做 UI,所以绝大部分的 UI 代码都是猫猫完成的!是又厉害又可爱的猫猫!(扑倒!

UI 开发的时候也遇到了各种神奇的问题,感觉几乎可以另外单独写成 post 了www 比如

jQuery 设置不了 HTML 元素的 CSS,于是猫猫只好使用原始的 document

Electron 后端跟前端通信,原来需要写一个 preload.js,在那里面去用 contextBridge 来连接前后端

系统托盘 (tray) 的图标设置,原来不是因为原始图片不够清晰,而是因为曲线的原因,会略微显得有锯齿感

CSS 设置某些元素将鼠标事件转发给它的子元素,原来只需要写上 pointer-events: none

各种各样的问题接踵而来,于是 Cocoa 跟猫猫各种 Google、找文档、Debug (>﹏<) 总之最后的界面猫猫做得非常的漂亮喵!而且 Sobani 的图标也超棒的说!是猫猫非常用心做出来的成果(^з^)-☆

最后的话~欢迎大家去使用的说喵,也欢迎各种 Pull Request~

以及想要特别对猫猫说,这个项目是 Cocoa 头一次合作完成的呢,猫猫在开发的时候辛苦了呢>< 好喜欢猫猫,轻轻趴到猫猫的身边////////

声明: 本文为 Cocoa 原创, 转载注明出处喵~

2 thoughts on “『側に / Sobani』 —— 在你身旁”

  1. 好好玩的说(❤ ω ❤)

    NAT 穿透咱用过 frp 的 xtcp/stcp ,不过穿透成功率较低 ? ,虽然流量不经过 frps 服务器,但需要 frps 来建立双方的连接唉。

    1. UDP 这边的话,Cocoa 跟 Ayaka 测试的时候几乎都可以成功穿透的说~
      也许是木子那边出去的 NAT 的模式不太一样,所以比较难穿透?

Leave a Reply

Your email address will not be published. Required fields are marked *