专栏首页音视频技术​SoundCloud的web播放库Maestro演进之路

​SoundCloud的web播放库Maestro演进之路

Maestro是一款用于处理SoundCloud Web播放的库,它在soundcloud.com、SoundCloud移动网站、网页插件、Chromecast和Xbox应用中每天成功处理数千万次的播放。如今,我们正在考虑开源,这篇博文将介绍我们迄今为止在Maestro开发过程中所取得的技术成就。感谢前熊猫直播前端技术专家姜雨晴对本文的翻译和审校。

文 / Tom Jenkinson

译 & 技术审校 / 姜雨晴

支持性

在SoundCloud,我们希望可以支持所有现代网络浏览器、移动浏览器和IE 11。我们的目标是利用浏览器提供的功能提供最佳的播放体验。

媒体流

我们目前支持三个解码器的媒体流:

  • mp3
  • opus
  • aac

我们的主要协议是HLS(HTTP Live Streaming)。这意味着音频内容将被切割成片段,我们有一个单独的文件(播放列表),其中包含所有片段的URL,以及它们在音频内容中的相应时间。您可以在此处找到有关HLS的更多信息。

浏览器提供的内容

我们使用浏览器的audio标签,媒体源扩展(MSE)和 Web Audio API。

我们需要浏览器至少支持 audio 标签、拥有流媒体解码和播放能力。MSE和Web Audio API 是获得最佳体验所必需的。

当 Web Audio API 或 MSE 丢失或播放期间出现错误时,我们可以正常降级。

我们将稍微介绍一下我们使用MSE和Web Audio API的内容,但首先,让我们看看该audio 标签为我们做了些什么。

audio

如果浏览器支持解码,则可以获取音频文件的URL并进行播放。它会content-type在响应的标头中通知编解码器,它提供了一个API,可用于控制播放并确定浏览器是否支持解码:

const audio = document.createElement('audio');
audio.src = 'http://example.invalid/something.mp3';

audio.play();

媒体源扩展

仅使用audio 标签,浏览器就可以完成幕后的所有工作,但您无权访问它的底层缓冲区。

使用MSE,我们可以为浏览器支持的解码器创建缓冲区。然后我们可以处理自己下载媒体并将其附加到缓冲区。这意味着我们可以进行优化,如:预加载,这是我们在您单击播放按钮时,将其存储在内存中,预先下载我们认为您将播放的音频文件的前几秒。然后当您单击播放时,我们将此数据直接从内存中添加到缓冲区,而不必从网络获取:

const audio = document.createElement('audio');
const mse = new MediaSource()
const url = URL.createObjectURL(mse)
audio.src = url
audio.play()

mse.addEventListener('sourceopen', () => {
  // 'audio/mpeg' for mp3
  const buffer = mse.addSourceBuffer('audio/mpeg');
  buffer.mode = 'sequence';
  const request = new Request('http://example.invalid/segment0.mp3');
  fetch(request).then((response) => response.arrayBuffer()).then((data) => {
    buffer.appendBuffer(data);
  });
});

Web Audio API

Web Audio API是这里提到的最新的API。当您播放,暂停或搜索时,我们会使用此API的一小部分来快速淡入淡出。这使得播放体验更加的爽快、播放/暂停不那么突然:

const audio = document.createElement('audio');
const context = new AudioContext();
const sourceNode = context.createMediaElementSource(audio);
const gainNode = context.createGain();
sourceNode.connect(gainNode);
gainNode.connect(context.destination);

audio.src = 'http://example.invalid/something.mp3';
audio.play();

// Schedule fade out.
gainNode.gain.linearRampToValueAtTime(0, context.currentTime + 1);

Maestro的目标

  • 简单的API
  • 插件架构
  • 易于检测功能
  • 类型安全
  • 支持所有主流浏览器
  • 处理浏览器实现中的差异和错误
  • 优异的性能

能够预加载

尽可能地响应

  • 可配置的缓冲区长度和缓存大小
  • 能够在具有内存受限的设备上工作,如Chromecast
  • 检测

提供错误数据和性能数据,对其进行监控,以检测错误并进行改进

技术栈

  • TypeScript
  • Lerna
  • Yarn
  • WebPack

API

Maestro包含许多包。核心包提供了一个抽象BasePlayer类,它提供了播放器API。它将任务委派给特定的实现,外部通信通过BasePlayer。可以通过player 方法检索最新状态,并且在有任何更改时通知用户。

例如,该play()方法返回Promise可以解析或拒绝。这BasePlayer将告知是县城何时应该播放或暂停,实现层将告知BasePlayer实际播放的时间。每个播放器实现都与实际play()方法分离。这也意味着isPlaying()可以完全处理方法和相应的更新BasePlayer。另一个例子是getPosition(),除了通知实现层播放时间,除非正在seek,在这种情况下BasePlayer将返回请求的时间点。这意味着时间getPosition()总是有意义的,用户在seek时可以保证它不会跳转,并覆盖它。

播放器实现包含在单独的包中,并且它们都扩展BasePlayer。我们目前有以下播放器:

  • HTML5Player - 这是最简单的播放器。它采用URL和MIME类型,它们直接传递给媒体元素。
  • HLSMSEPlayer- 这扩展了HTML5Player,它需要一个Playlist对象来供段数据。该播放器使用MSE。
  • ChromecastPlayer - 此播放器是一个控制Chromecast的代理。
  • ProxyPlayer - 此播放器可以控制另一个播放器以便随时切换。它还具有一些提供新播放器同步相关的配置。该播放器的一个好处是,它可以在真正的播放器还没有的时候同步提供给应用程序。然后,一旦真实播放器可用,其状态将被同步以匹配代理。其他一些用例是在Chromecast上播放和本地播放,或切换质量。该应用程序只需与一个播放器进行交互,切换可以在幕后进行。

状态管理和事件

在Maestro中,有很多播放状态需要管理,它们大部分都包含在内部BasePlayer。用户还想知道某些部分的状态何时发生变化,有时会通过执行其他播放器操作来对变化作出反应。当我们在单个线程上运行时,这会带来一些复杂性。有时我们还会以原子方式(跨多个函数)更新状态的几个部分。例如:如果用户跳转到媒体的结尾,我们也想要将ended标志更新为true。更新ended标志有关的逻辑,与代码中的查找逻辑无关,但跳转状态和结束状态的更新应该在API中一起发生。

为实现这一目标,我们构建了一个名为的组件StateManager,它使我们能够:

  • 在调用之前更新函数的多个部分,以通知用户更改。
  • 在播放器调用堆栈的末尾通知用户状态更改,以便他们与播放器的任何交互不会因此而在调用堆栈中交错。(例如,执行工作然后触发事件,而不是触发事件然后执行工作。)

StateManager

StateManager维护一个状态对象。对该对象的所有更改都是使用update()方法进行的,并且可以提供回调,然后在update()最后通知回调发生的任何状态更改。这些调用可以嵌套:

type ChangesCallback<State> = (changes: Readonly<Partial<State>>, state: Readonly<State>) => void;
type Control = {
  remove: () => boolean;
};
type Subscriber<State> = {
  callback: ChangesCallback<State>,
  localState: State
};

class StateManager<State extends { [key: string]: Object | null }> {

  private _state: State;
  private _subscribers: Array<Subscriber<State>> = [];
  private _updating = false;

  constructor(initialState: State) {
    this._state = clone(initialState);
    // ...
  }

  public update(callback: (state: State) => void): void {
    const wasUpdating = this._updating;
    this._updating = true;

    try {
      callback(this._state);
    } catch(e) {
      // error handling...
    }

    if (!wasUpdating) {
      this._updating = false;
      this._afterUpdate();
    }
  }

  public subscribe(callback: ChangesCallback<State>, skipPast = true): Control {
    // ...
  }

  private _afterUpdate(): void {
    this._subscribers.slice().forEach((subscriber) => {
      const diff = this._calculateDiff(subscriber.localState);
      // We always recalculate the diff just before calling a subscriber,
      // which means that the state is always up to date at the point when
      // the subscriber is called.
      if (Object.keys(diff).length) {
        subscriber.localState = clone(this._state);
        deferException(() => subscriber.callback(diff, subscriber.localState));
      }
    });
  }

  private _calculateDiff(compare: State): Readonly<Partial<State>> {
    // ...
  }
}

示例用法

type OurState = { a: number, b: string, c: boolean, d: number };
const stateManager = new StateManager<OurState>({
  a: 1,
  b: 'something',
  c: true,
  d: 2
});

stateManager.subscribe(({ a, b, c, d }) => {
  // On first execution:
  // a === 2
  // b === 'something else'
  // c === false
  // d === undefined

  // On second execution:
  // a === undefined
  // b === undefined
  // c === undefined
  // d === 3
  updateD();
});

stateManager.subscribe(({ a, b, c, d }) => {
  // a === 2
  // b === 'something else'
  // c === false
  // d === 3
});

doSomething();

function doSomething() {
  stateManager.update((state) => {
    state.a = 2;
    updateB();
    state.c = false;
  });
}

function updateB() {
  stateManager.update((state) => {
    state.b = 'something else';
  });
}

function updateD() {
  stateManager.update((state) => {
    state.d = 3;
  });
}

请注意,第一个订阅回调将执行两次,第二个订阅也只执行一次,并且只执行最新状态(即d === 3)。

另请注意,我们不会获得嵌套调用堆栈,因为回调只在工作完成后才会执行。

浏览器限制

不幸的是,不同的浏览器具有不同的编解码器支持(也可能取决于操作系统)和不同的容器需求。

例如,Chrome支持MSE中的原始MP3文件,但Firefox要求MP3位于MP4容器中。这意味着在Firefox中,我们需要将我们下载的MP3打包到浏览器中的MP4中。其他编解码器具有类似的复杂性。

有bug也是不可避免的。为支持在安全的方式下,处理各种媒体的媒体处理管道,并且不破坏Web浏览器的向后兼容性,这是一项艰巨的任务!幸运的是,Maestro有能够处理不同浏览器中各种错误的变通方法,其中一些在版本之间有所不同。

浏览器之间的自动播放策略也不同,这意味着我们目前必须在播放器之间共享媒体元素。这增加了复杂性,因为当元素的源被更改时,仍然会在之后的短时间内为前一个源发出事件,这意味着我们必须在尝试使用它之前等待事件“清空”,并且我们必须保持跟踪同时请求的所有内容。Maestro的HTML5Player通过使用provideMediaElement(mediaEl)和revokeMediaElement()让这变得简单。这允许您在运行时在播放器之间移动媒体元素。当播放器没有媒体元素时,播放器就会暂停。

测试

在BasePlayer和播放器的实现是通过单元测试和集成测试覆盖:我们采用Mocha,Sinon,karma,以及mocha-screencast-reporter。后者非常适合远程查看测试的运行进度。

确保API的行为正确,该BasePlayer自身目前拥有超过700次测试。例如,测试检查play()实现是否正在播放时解析了promise。一个测试play()如果在播放请求完成之前播放器被释放,则另一个测试会被拒绝并返回正确的报错。还有一些测试可以检查播放器是否在检测到不一致时报错。 例如,一个播放器实现在BasePlayer从未请求过seek操作时,无法完成seek请求。

我们还使用SauceLabs在各种浏览器和浏览器版本(包括Chrome和Firefox beta)上运行所有测试。这需要几个小时才能完成,因此我们测试了各主流浏览器,我们在发布之前测试所有内容。我们还每周运行所有测试,以确保新浏览器版本不会出现任何问题。这样做,曾有一次高亮显示了Firefox beta中的Web Audio错误,这会导致播放在前几秒后停止。

渐进式流媒体(使用fetch()API)

我们最近添加了对渐进式流式传输的支持(在支持的浏 这意味着在我们处理它并将其附加到缓冲区之前不必等待整个段被下载,我们能够在数据到达时处理数据,这意味着我们能够在段下载之前开始播放已完成。

这是通过fetch()API(以及moz-chunked-arraybuffer在Firefox中)实现的,它在下载时仍提供小部分数据:

fetch(new Request(url)).then(({ body }) => {
  return body.pipeTo(new WritableStream({
    write: (chunk) => {
      console.log('Got part', chunk);
    },
    abort: () => {
      console.log('Aborted');
    },
    close: () => {
      console.log('Got everything');
    }
  }));
});

在我们添加渐进式流式传输之前,如果下载失败,我们只会重试它,这个逻辑非常独立。使用渐进式流式传输更为复杂,因为如果下载部分失败,整个管道已经开始处理数据。我们决定在错误时重试请求并丢弃我们已经看到的所有字节。如果重试失败,那么我们就能够在管道中产生报错。

这也带来了更多的复杂性。之前,我们知道每个段包含完整数量的有效音频单元,这意味着管道的不同部分可以做出某些响应。现在,每个数据部分都可以包含一小部分音频单元,因此我们需要能够检测到何时发生这种情况,并保留和等待一个完整单元到达的缓冲区。

下一步是什么?

我们自2017年6月开始运行Maestro,而且对播放问题的不良反馈很少。我们能够实时监控性能和错误,并且在发生错误的情况下,我们能够检索播放日志,这有助于调试。

我们正在寻找Maestro的下一个目标,那就是你的参与:让我们知道你将如何使用它,以及你想看到的功能:D

如果您对此帖有任何疑问,或者您发现soundcloud.com上有任何播放问题;),请与我们联系!

姜雨晴的补充资料

MSDN上关于 AudioContext的一些解释 https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext 有关AudioContext,会稍微吃性能。

渐进式流媒体,就是我们所说的直播流媒体,它会涉及到有时候一个片段并非完整的问题,必须要等待完整片段,之前写的代码中有做过处理。https://github.com/xiongmaotv/open-mccree/blob/f8491e33770c59fe6288f1a05daf8375d4f01820/packages/mccree-core-loaderbuffer/src/index.js#L46

文章中反复提到的状态处理,是因为媒体播放很多方法是异步,尤其在不同浏览器直接也有差异,比如停止下载的cancel在chrome中是Promise,异步的,FireFox中就不是。

本文分享自微信公众号 - LiveVideoStack(livevideostack)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-06-13

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • OpenCV中保存不同深度图像的技巧

    很多人开始学习OpenCV之后,接触前面几个API就包括imwrite函数,而且很快知道了如何去保存Mat对象为图像,常规代码如下:

    OpenCV学堂
  • Elasticsearch Document Update API详解、原理与示例

    从上述我们基本可以得知更新基本有3种方式,script、upsert、doc(普通更新)。

    丁威
  • GenZ,CXL,NVLINK,OpenCAPI,CCIX乱战!

    广告:经历了长达数个月的伟光正红的5审5校之后,《大话计算机》一书终于在2019年3月30日送厂印刷,1500页,分3卷,视网膜分辨率,全彩印刷,硬壳精装!这就...

    冬瓜哥
  • 源码分析Dubbo 泛化调用与泛化实现原理

    通常是服务调用方没有引入API包,也就不包含接口中的实体类,故服务调用方只能提供Map形式的数据,由服务提供者根据Map转化成对应的实体。

    丁威
  • Elasticsearch Search API 概述与URI Search

    注意:search_type,request_cache和allow_partial_search_results这三个参数,必须查询url级别的参数(quer...

    丁威
  • Elasticsearch Document Delete API详解、原理与示例

    本节将重点介绍ElasticSearch Doucment Delete API(根据ID删除文档)。

    丁威
  • Elasticsearch Document Get API详解、原理与示例

    本节将重点介绍ElasticSearch Doucment Get API(根据ID获取文档)。从《ElasticSearch Client详解》可知,Elas...

    丁威
  • 低代码开发平台,能让 CIO 们“快活”吗?

    自从 Forrester 创造了“低代码开发平台”术语以来,每年都会对低代码开发平台领域做出报告分析,根据 Forrester 的报告,低代码开发平台市场将从2...

    用户1564362
  • Elasticsearch Query DSL概述与查询、过滤上下文

    从本节开始,先详细介绍Elasticsearch Query DSL语法,该部分是SearchAPI的核心基础之一。

    丁威
  • 混合云/多云时代的网络监控

    目前,大多数企业使用两个或更多云计算服务提供商的云服务,35%的企业使用多达五个监控工具来密切关注混合云和多云环境。那么实现全面网络可视性的最佳方法是什么?

    静一

扫码关注云+社区

领取腾讯云代金券