10 个版本 (5 个重大更新)

0.6.2 2024 年 8 月 8 日
0.6.1 2024 年 7 月 22 日
0.5.1 2024 年 4 月 25 日
0.5.0 2024 年 3 月 26 日
0.1.0 2023 年 3 月 17 日

#27网页开发 类别中

Download history 17838/week @ 2024-05-03 22623/week @ 2024-05-10 19394/week @ 2024-05-17 31678/week @ 2024-05-24 25579/week @ 2024-05-31 20811/week @ 2024-06-07 21009/week @ 2024-06-14 27339/week @ 2024-06-21 19640/week @ 2024-06-28 21674/week @ 2024-07-05 27474/week @ 2024-07-12 30862/week @ 2024-07-19 28255/week @ 2024-07-26 26703/week @ 2024-08-02 41399/week @ 2024-08-09 23514/week @ 2024-08-16

每月 124,362 次下载
106 库中使用 (直接使用 2 个)

MIT/Apache

1.5MB
30K SLoC

包含 (ZIP 文件, 51KB) logo/str0m logo.graffle

str0m

str0m logo

使用 Rust 实现的 Sans I/O WebRTC

这是一个 Sans I/O 实现,意味着 Rtc 实例本身不执行任何网络通信。此外,它没有内部线程或异步任务。所有操作都通过公共 API 的调用进行。

这故意不是标准的 RTCPeerConnection API,因为这并不适合 Rust。更多详细信息请见下文。

加入我们

我们在 Zulip 上讨论 str0m 相关事宜。使用此 邀请链接 加入我们。或者匿名浏览讨论 str0m.zulipchat.com

silly clip showing video playing

用法

chat 示例展示了如何将多个浏览器连接在一起并充当 SFU (选择性转发单元)。该示例将所有流量通过一个服务器的 UDP 套接字进行复用,并使用两个线程(一个用于 Web 服务器,另一个用于 SFU 循环)。

TLS

为了浏览器能够使用 WebRTC,所有流量都必须在 TLS 下进行。项目包含一个自签名的证书,用于示例。证书用于主机名 str0m.test,因为顶级域名 .test 永远不会解析到真实的 DNS 名称。

cargo run --example chat

日志应提示您连接浏览器到 https://10.0.0.103:3000 – 这很可能会引起安全警告,您需要让浏览器接受。

示例 http-post 大致说明了如何从浏览器客户端接收媒体数据。示例是单线程的,比聊天示例简单一些。它是理解API的好起点。

cargo run --example http-post

实际示例

要了解str0m在实际项目中的应用,请查看 BitWHIP – 使用Rust编写的CLI WebRTC代理。

被动

对于被动连接,即媒体和初始OFFER由远程对等方提供的情况,我们需要以下步骤来打开连接。

// Instantiate a new Rtc instance.
let mut rtc = Rtc::new();

//  Add some ICE candidate such as a locally bound UDP port.
let addr = "1.2.3.4:5000".parse().unwrap();
let candidate = Candidate::host(addr, "udp").unwrap();
rtc.add_local_candidate(candidate);

// Accept an incoming offer from the remote peer
// and get the corresponding answer.
let offer = todo!();
let answer = rtc.sdp_api().accept_offer(offer).unwrap();

// Forward the answer to the remote peer.

// Go to _run loop_

主动

主动连接意味着我们正在发出初始OFFER,并等待远程ANSWER以启动连接。

// Instantiate a new Rtc instance.
let mut rtc = Rtc::new();

// Add some ICE candidate such as a locally bound UDP port.
let addr = "1.2.3.4:5000".parse().unwrap();
let candidate = Candidate::host(addr, "udp").unwrap();
rtc.add_local_candidate(candidate);

// Create a `SdpApi`. The change lets us make multiple changes
// before sending the offer.
let mut change = rtc.sdp_api();

// Do some change. A valid OFFER needs at least one "m-line" (media).
let mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None);

// Get the offer.
let (offer, pending) = change.apply().unwrap();

// Forward the offer to the remote peer and await the answer.
// How to transfer this is outside the scope for this library.
let answer = todo!();

// Apply answer.
rtc.sdp_api().accept_answer(pending, answer).unwrap();

// Go to _run loop_

运行循环

驱动Rtc状态的运行循环,无论同步还是异步,看起来是这样的。

// Buffer for reading incoming UDP packets.
let mut buf = vec![0; 2000];

// A UdpSocket we obtained _somehow_.
let socket: UdpSocket = todo!();

loop {
    // Poll output until we get a timeout. The timeout means we
    // are either awaiting UDP socket input or the timeout to happen.
    let timeout = match rtc.poll_output().unwrap() {
        // Stop polling when we get the timeout.
        Output::Timeout(v) => v,

        // Transmit this data to the remote peer. Typically via
        // a UDP socket. The destination IP comes from the ICE
        // agent. It might change during the session.
        Output::Transmit(v) => {
            socket.send_to(&v.contents, v.destination).unwrap();
            continue;
        }

        // Events are mainly incoming media data from the remote
        // peer, but also data channel data and statistics.
        Output::Event(v) => {

            // Abort if we disconnect.
            if v == Event::IceConnectionStateChange(IceConnectionState::Disconnected) {
                return;
            }

            // TODO: handle more cases of v here, such as incoming media data.

            continue;
        }
    };

    // Duration until timeout.
    let duration = timeout - Instant::now();

    // socket.set_read_timeout(Some(0)) is not ok
    if duration.is_zero() {
        // Drive time forwards in rtc straight away.
        rtc.handle_input(Input::Timeout(Instant::now())).unwrap();
        continue;
    }

    socket.set_read_timeout(Some(duration)).unwrap();

    // Scale up buffer to receive an entire UDP packet.
    buf.resize(2000, 0);

    // Try to receive. Because we have a timeout on the socket,
    // we will either receive a packet, or timeout.
    // This is where having an async loop shines. We can await multiple things to
    // happen such as outgoing media data, the timeout and incoming network traffic.
    // When using async there is no need to set timeout on the socket.
    let input = match socket.recv_from(&mut buf) {
        Ok((n, source)) => {
            // UDP data received.
            buf.truncate(n);
            Input::Receive(
                Instant::now(),
                Receive {
                    proto: Protocol::Udp,
                    source,
                    destination: socket.local_addr().unwrap(),
                    contents: buf.as_slice().try_into().unwrap(),
                },
            )
        }

        Err(e) => match e.kind() {
            // Expected error for set_read_timeout().
            // One for windows, one for the rest.
            ErrorKind::WouldBlock
                | ErrorKind::TimedOut => Input::Timeout(Instant::now()),

            e => {
                eprintln!("Error: {:?}", e);
                return; // abort
            }
        },
    };

    // Input is either a Timeout or Receive of data. Both drive the state forward.
    rtc.handle_input(input).unwrap();
}

发送媒体数据

在创建媒体时,我们可以决定支持哪些编解码器,并且它们将与远程端协商。每个编解码器对应一个“有效负载类型”(PT)。为了发送媒体数据,我们需要确定在发送时使用哪个PT。

// Obtain mid from Event::MediaAdded
let mid: Mid = todo!();

// Create a media writer for the mid.
let writer = rtc.writer(mid).unwrap();

// Get the payload type (pt) for the wanted codec.
let pt = writer.payload_params().nth(0).unwrap().pt();

// Write the data
let wallclock = todo!();   // Absolute time of the data
let media_time = todo!();  // Media time, in RTP time
let data: &[u8] = todo!(); // Actual data
writer.write(pt, wallclock, media_time, data).unwrap();

媒体时间、系统时间和本地时间

str0m有三个主要的时间概念:“现在”、媒体时间和系统时间。

现在

str0m中的一些调用,例如 Rtc::handle_input,接受一个now参数,该参数是一个std::time::Instant。这些调用“推动内部状态中的时间前进”。这用于决定何时向远程对等方发送各种反馈报告(RTCP)、带宽估计(BWE)和统计信息等。

str0m没有内部时钟调用。即str0m自身永远不会调用Instant::now()。所有时间都是外部输入。这意味着可以构建测试用例,使Rtc实例的运行速度比实时快(请参阅集成测试)。

媒体时间

每个RTP头都有一个32位数字,str0m将其称为“媒体时间”。媒体时间基于某种时间基,这取决于编解码器,但str0m中的所有编解码器都使用90_000Hz用于视频和48_000Hz用于音频。

对于视频,MediaTime类型是<timestamp>/90_000。str0m将RTP头中的32位数字扩展到64位,考虑到“回滚”。64位是一个如此大的数字,用户无需考虑回滚。

系统时间

str0m中的“系统时间”指的是媒体样本在源头产生的时刻。例如,如果我们正在对着麦克风说话,系统时间就是声音被采样的NTP时间。

由于并非每个设备都与NTP同步,我们无法从远程对等方得知媒体的确切系统时间。每个发送方都会定期产生一个发送者报告(SR),其中包含对等方对其系统时间的看法,但这个数字可能与“真实”的NTP时间相差很大。

此外,并非所有远程设备都会有一个与本地时间完全一致的线性时间流逝观念。远程对等方的一分钟可能不等于本地的一分钟。

这些时间戳在处理来自多个对等方的同时音频时变得很重要。

在编写媒体内容时,我们需要为str0m提供一个预估的墙上时间。最简单的策略是只信任本地时间,并使用传入UDP数据包的到达时间。另一种简单策略是在第一个UDP数据包时锁定时间T,然后使用MediaTime来偏移每个墙上时间,例如对于视频,我们可能有T + <媒体时间>/90_000

一个值得生产的SFU可能需要一个更复杂的策略,权衡所有可能的时间来源来对数据包的远程墙上时间进行良好估计。

项目状态

Str0m最初由Lookback的Martin Algesten开发。Lookback。我们使用str0m针对特定用例:str0m作为服务器SFU(与点对点相对)。这意味着我们对所需用例的部分进行了大量测试和开发。Str0m旨在成为一个通用的WebRTC库,这意味着它也适用于点对点,尽管这一方面得到了较少的测试。

性能非常好,已经进行了一些工作来发现和优化瓶颈。当然,这种努力永远不会结束,回报递减。虽然没有明显的性能瓶颈,但总是欢迎更多的工作——无论是算法上还是在热路径中的分配/克隆等。

设计

Rtc实例的输出可以分为三种类型。

  1. 事件(例如接收媒体或数据通道数据)。
  2. 网络输出。要发送的数据,通常来自UDP套接字。
  3. 超时。指示实例下次期望的时间输入。

Rtc实例的输入是

  1. 用户操作(例如发送媒体或数据通道数据)。
  2. 网络输入。通常从UDP套接字读取。
  3. 超时。如上所述获得。

正确的使用方法可以在上面的运行循环或示例中看到。

Sans I/O是一种模式,其中我们将网络输入/输出以及时间传递都转换为API的外部输入。这意味着str0m没有内部线程,只有一个由不同类型的输入驱动的巨大的状态机。

样本或RTP级别?

Str0m默认使用“样本级别”,将RTP视为内部细节。因此,用户将主要与

  1. Event::MediaData交互,以接收完整的“样本”(音频帧或视频帧)。
  2. Writer::write来写入完整的样本。
  3. Writer::request_keyframe来请求关键帧。

样本级别

所有编解码器,如h264、vp8、vp9和opus输出我们称之为“样本”。对于音频,样本有一个非常特定的含义,但本项目在更广泛的意义上使用它,其中样本是视频或音频时间戳的编码数据块,通常代表音频块,或视频的单帧

样本不适合直接用于UDP(RTP)数据包——一方面它们太大。因此,样本被编解码器特定的有效载荷传输器进一步分割成RTP数据包。

RTP模式

Str0m还提供RTP级别的API。这与其他许多RTP库类似,其中RTP数据包本身是用户API的表面(在构建SFU时,人们经常谈论“转发RTP数据包”,而与str0m一起,我们还可以“转发样本”)。使用此API需要更深入的了解RTP和WebRTC。

要启用RTP模式

let rtc = Rtc::builder()
    // Enable RTP mode for this Rtc instance.
    // This disables `MediaEvent` and the `Writer::write` API.
    .set_rtp_mode(true)
    .build();

RTP模式为我们提供了一些新的API点。

  1. Event::RtpPacket为每个接收到的RTP包发出。用于带宽估计的空包将被静默丢弃。
  2. StreamTx::write_rtp用于写入发出的RTP包。
  3. StreamRx::request_keyframe用于从远程请求关键帧。

NIC枚举和TURN(以及STUN)

ICE RFC中提到了“收集ICE候选者”。这意味着检查本地网络接口,并可能在每个可用的接口上绑定UDP套接字。由于str0m是无I/O的,这部分超出了str0m的功能范围。用户如何通过配置或查找本地NIC来确定本地IP地址,str0m并不关心。

TURN是在直接连接失败时用作后备的IP地址获取方式。我们认为TURN类似于枚举本地网络接口——它是获取套接字的方式。

所有发现的候选者,无论是本地(NIC)还是远程套接字(TURN),都将添加到str0m中,str0m将执行ICE代理的任务,形成“候选对”,同时确定最佳连接,而实际发送网络流量的任务留给用户。

&mut self的重要性

Rust在可以避免锁并高度依赖&mut进行数据写入访问时表现卓越。由于str0m没有内部线程,我们永远不需要处理共享数据。此外,库的内部组织方式使得我们不需要对同一实体有多个引用。在str0m中,没有RcMutexmpscArc(*),或其他锁。

这意味着库的所有输入都可以建模为handle_something(&mut self, something)

(*) 好吧,如果你使用Windows并且需要openssl,那么有一个Arc

不是标准的WebRTC "Peer Connection" API

该库故意偏离了JavaScript和/或webrtc-rs(或Go中的Pion)中看到的“标准”WebRTC API。这里有几个原因。

首先,在标准API中,事件是回调,这并不适合Rust。回调需要某种类型的引用(所有者?)来控制回调被分发的实体。例如,如果我们在Rust中想要pc.addEventListener(x),则x必须完全由pc所有,或者有某种共享引用(如Arc)。共享引用意味着共享数据,而要获取可变共享数据,我们需要某种类型的锁。例如Arc<Mutex<EventListener>>或类似。

作为替代,我们可以将所有事件转换为mpsc通道,但没有异步的情况下监听多个通道是尴尬的。

其次,在标准API中,像RTCPeerConnectionRTCRtpTransceiver这样的实体很容易克隆,并且/或者具有较长的生命周期。也就是说,pc.getTransceivers()返回的对象可以被调用者保留和拥有。这种模式对于垃圾回收或引用计数的语言来说很好,但与Rust相比就不是很理想。

恐慌、错误和展开

Str0m遵循快速失败原则。这意味着我们不会将状态错误隐藏起来,而是直接触发恐慌。我们将错误和错误(bug)区分开来。

  • 错误是由于用户输入不正确或难以理解而产生的。
  • 错误是内部不变性(假设)的破坏。

如果你检查str0m的代码,你会找到一些unwrap()(或expect())。这些将(应该)总是伴随着一个代码注释,解释为什么展开是合理的。这是一个内部不变性,是一个状态假设,str0m负责维护。

我们不认为将每个unwrap()/expect()都改为unwrap_or_else()if let Some(x) = x { ... }等,是正确的,因为这样做会将一个实际问题(一个错误的假设)隐藏起来。试图带着错误的状态前进,至多会导致行为损坏,最坏的情况是存在安全风险!

恐慌是我们的朋友:恐慌意味着错误

还有:str0m 永远不应该在任何用户输入上恐慌。如果你遇到恐慌,请报告它!

捕获恐慌

恐慌应该非常罕见,否则我们作为项目会有严重问题。对于一个SFU来说,如果str0m遇到错误并导致整个服务器崩溃,这可能不是理想的情况。

对于那些想要额外安全级别的人来说,我们建议查看catch_unwind,以安全地丢弃一个有问题的Rtc实例。由于Rtc没有内部线程、锁或异步任务,丢弃实例永远不会风险破坏锁或其他在捕获恐慌时可能发生的问题。

常见问题解答

功能

以下是libWebRTC和str0m之间功能简短的比较,以帮助您确定str0m是否适合您的项目。

功能 str0m libWebRTC
Peer Connection API
SDP
ICE
数据通道
发送/接收报告
传输范围CC
带宽估计
Simulcast
NACK
数据包化
固定解包缓冲区
自适应抖动缓冲区
视频/音频捕获
视频/音频编码
视频/音频解码
音频渲染
Turn
网络接口枚举

平台支持

str0m编译和测试的平台

平台 编译 测试
x86_64-pc-windows-msvc
x86_64-unknown-linux-gnu
x86_64-apple-darwin

如果你的平台没有列出但由Rust支持,我们非常希望您尝试使用str0m,并分享您的使用体验。我们非常感谢您的反馈!

str0m支持IPv4、IPv6、UDP和TCP吗?

当然!str0m完全支持IPv4、IPv6、UDP和TCP协议。

我可以用str0m与任何Rust异步运行时一起使用吗?

绝对可以!str0m是完全同步的,确保它能无缝集成到您选择的任何Rust异步运行时。

我可以用str0m创建客户端吗?

当然可以!您有使用str0m创建客户端的自由。但是请注意,str0m不包括一些常见客户端功能,如媒体编码、解码和捕获。但这不应阻止您构建令人惊叹的应用程序!

我可以在媒体服务器中使用str0m吗?

是的!str0m作为服务器组件表现卓越,支持RTP API和Sample API。您可以使用Rust轻松构建您梦想中的录制服务器或SFU!

我可以将聊天示例部署到生产环境吗?

虽然聊天示例展示了如何使用str0m的API,但它不是为生产使用或重负载而设计的。编写一个功能齐全的SFU或MCU(多点控制单元)是一项重大任务,需要根据生产需求做出各种设计决策。

发现了一个bug?这是如何与我们分享的方法

我们非常乐意了解它!请提交一个问题,并考虑加入我们的Zulip社区进行进一步讨论。为了获得流畅的报告体验,请参考这个典型的bug报告:https://github.com/algesten/str0m/issues/382。我们感谢您的贡献,使str0m变得更好!

我对SDP过敏,你能帮帮我吗?

是的,使用直接API!


str0m的聊天在Zulip上运行,并由Zulip为开源项目提供赞助。

Zulip logo

许可证:MIT或Apache-2.0

依赖项

~4.5–9MB
~196K SLoC