在 Flutter 多人视频中实现虚拟背景、美颜与空间音效

前言

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。

本篇主要带你了解 SDK 里几个实用的 API 实现,相对简单。


01 虚拟背景

虚拟背景是视频会议里最常见的特效之一,在声网 SDK 里可以通过enableVirtualBackground方法启动虚拟背景支持。

首先,因为我们是在 Flutter 里使用,所以我们可以在 Flutter 里放一张assets/bg.jpg图片作为背景,这里有两个需要注意的点:

  • assets/bg.jpg图片需要在pubspec.yaml文件下的assets添加引用
  assets:
    - assets/bg.jpg
  • 需要在pubspec.yaml文件下添加path_provider: ^2.0.8和path: ^1.8.2依赖,因为我们需要把图片保存在 App 本地路径下

  • 如下代码所示,首先我们通过 Flutter 内的rootBundle读取到bg.jpg,然后将其转化为bytes, 之后调用getApplicationDocumentsDirectory获取路径,保存在的应用的/data"目录下,然后就可以把图片路径配置给enableVirtualBackground方法的source,从而加载虚拟背景。

    Future<void> _enableVirtualBackground() async {
      ByteData data = await rootBundle.load("assets/bg.jpg");
      List<int> bytes =
          data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
      Directory appDocDir = await getApplicationDocumentsDirectory();
      String p = path.join(appDocDir.path, 'bg.jpg');
      final file = File(p);
      if (!(await file.exists())) {
        await file.create();
        await file.writeAsBytes(bytes);
      }
    
      await _engine.enableVirtualBackground(
          enabled: true,
          backgroundSource: VirtualBackgroundSource(
              backgroundSourceType: BackgroundSourceType.backgroundImg,
              source: p),
          segproperty:
              const SegmentationProperty(modelType: SegModelType.segModelAi));
      setState(() {});
    }


    如下图所示是都开启虚拟背景图片之后的运行效果,当然,这里还有两个需要注意的参数:

    • BackgroundSourceType :可以配置backgroundColor(虚拟背景颜色)、backgroundImg(虚拟背景图片)、backgroundBlur (虚拟背景模糊) 这三种情况,基本可以覆盖视频会议里的所有场景
    • SegModelType :可以配置为segModelAi(智能算法)或segModelGreen(绿幕算法)两种不同场景下的抠图算法。

    这里需要注意的是,在官方的提示里,建议只在搭载如下芯片的设备上使用该功能(应该是对于 GPU 有要求):
    • 骁龙 700 系列 750G 及以上
    • 骁龙 800 系列 835 及以上
    • 天玑 700 系列 720 及以上
    • 麒麟 800 系列 810 及以上
    • 麒麟 900 系列 980 及以上

    另外需要注意的是,为了将自定义背景图的分辨率与 SDK 的视频采集分辨率适配,声网 SDK 会在保证自定义背景图不变形的前提下,对自定义背景图进行缩放和裁剪。


    02 美颜

    美颜作为视频会议里另外一个最常用的功能,声网也提供了setBeautyEffectOptions方法支持一些基础美颜效果调整。

    如下代码所示,setBeautyEffectOptions方法里主要是通过BeautyOptions来调整画面的美颜风格,参数的具体作用如下表格所示。

    这里的 .5 只是做了一个 Demo 效果,具体可以根据你的产品需求,配置出几种固定模版让用户选择。
    _engine.setBeautyEffectOptions(
      enabled: true,
      options: const BeautyOptions(
        lighteningContrastLevel:
            LighteningContrastLevel.lighteningContrastHigh,
        lighteningLevel: .5,
        smoothnessLevel: .5,
        rednessLevel: .5,
        sharpnessLevel: .5,
      ),
    );


    运行后效果如下图所示,开了 0.5 参数后的美颜整体画面更加白皙,同时唇色也更加明显。

    没开美颜开了美颜

    03 色彩增强

    接下来要介绍的一个 API 是色彩增强:setColorEnhanceOptions,如果是美颜还无法满足你的需求,那么色彩增强 API 可以提供更多参数来调整你的需要的画面风格。

    如下代码所示,色彩增强 API 很简单,主要是调整ColorEnhanceOptionsstrengthLevel和skinProtectLevel参数,也就是调整色彩强度和肤色保护的效果。

      _engine.setColorEnhanceOptions(
          enabled: true,
          options: const ColorEnhanceOptions(
              strengthLevel: 6.0, skinProtectLevel: 0.7));


    如下图所示,因为摄像头采集到的视频画面可能存在色彩失真的情况,而色彩增强功能可以通过智能调节饱和度和对比度等视频特性,提升视频色彩丰富度和色彩还原度,最终使视频画面更生动。

    开启增强之后画面更抢眼了。
    没开增强开了美颜+增强

    04 空间音效

    其实声音调教才是重头戏,声网既然叫声网,在音频处理上肯定不能落后,在声网 SDK 里就可以通过enableSpatialAudio打开空间音效的效果。

    _engine.enableSpatialAudio(true);


    什么是空间音效?简单说就是特殊的 3D 音效,它可以将音源虚拟成从三维空间特定位置发出,包括听者水平面的前后左右,以及垂直方向的上方或下方。

    本质上空间音效就是通过一些声学相关算法计算,模拟实现类似空间 3D 效果的音效实现。


    同时你还可以通过setRemoteUserSpatialAudioParams来配置空间音效的相关参数,如下表格所示,可以看到声网提供了非常丰富的参数来让我们可以自主调整空间音效,例如这里面的enable_blurenable_air_absorb效果就很有意思,十分推荐大家去试试。

    音频类的效果这里就无法展示了,强烈推荐大家自己动手去试试。

    05 人声音效

    另外一个推荐的 API 就是人声音效:setAudioEffectPreset, 调用该方法可以通过 SDK 预设的人声音效,在不会改变原声的性别特征的前提下,修改用户的人声效果,例如:

    _engine.setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);


    声网 SDK 里预设了非常丰富的AudioEffectPreset,如下表格所示,从场景效果如 KTV、录音棚,到男女变声,再到恶搞的音效猪八戒等,可以说是相当惊艳。


    PS:为获取更好的人声效果,需要在调用该方法前将setAudioProfile的 scenario 设为audioScenarioGameStreaming(3):

    _engine.setAudioProfile(
      profile: AudioProfileType.audioProfileDefault,
      scenario: AudioScenarioType.audioScenarioGameStreaming);


    当然,这里需要注意的是,这个方法只推荐用在对人声的处理上,不建议用于处理含音乐的音频数据。

    最后,完整代码如下所示:

    class VideoChatPage extends StatefulWidget {
      const VideoChatPage({Key? key}) : super(key: key);
    
      @override
      State<VideoChatPage> createState() => _VideoChatPageState();
    }
    
    class _VideoChatPageState extends State<VideoChatPage> {
      late final RtcEngine _engine;
    
      ///初始化状态
      late final Future<bool?> initStatus;
    
      ///当前 controller
      late VideoViewController currentController;
    
      ///是否加入聊天
      bool isJoined = false;
    
      /// 记录加入的用户id
      Map<int, VideoViewController> remoteControllers = {};
    
      @override
      void initState() {
        super.initState();
        initStatus = _requestPermissionIfNeed().then((value) async {
          await _initEngine();
    
          ///构建当前用户 currentController
          currentController = VideoViewController(
            rtcEngine: _engine,
            canvas: const VideoCanvas(uid: 0),
          );
          return true;
        }).whenComplete(() => setState(() {}));
      }
    
      Future<void> _requestPermissionIfNeed() async {
        if (Platform.isMacOS) {
          return;
        }
        await [Permission.microphone, Permission.camera].request();
      }
    
      Future<void> _initEngine() async {
        //创建 RtcEngine
        _engine = createAgoraRtcEngine();
        // 初始化 RtcEngine
        await _engine.initialize(const RtcEngineContext(
          appId: appId,
        ));
    
        _engine.registerEventHandler(RtcEngineEventHandler(
          // 遇到错误
          onError: (ErrorCodeType err, String msg) {
            if (kDebugMode) {
              print('[onError] err: $err, msg: $msg');
            }
          },
          onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
            // 加入频道成功
            setState(() {
              isJoined = true;
            });
          },
          onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
            // 有用户加入
            setState(() {
              remoteControllers[rUid] = VideoViewController.remote(
                rtcEngine: _engine,
                canvas: VideoCanvas(uid: rUid),
                connection: const RtcConnection(channelId: cid),
              );
            });
          },
          onUserOffline:
              (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
            // 有用户离线
            setState(() {
              remoteControllers.remove(rUid);
            });
          },
          onLeaveChannel: (RtcConnection connection, RtcStats stats) {
            // 离开频道
            setState(() {
              isJoined = false;
              remoteControllers.clear();
            });
          },
        ));
    
        // 打开视频模块支持
        await _engine.enableVideo();
        // 配置视频编码器,编码视频的尺寸(像素),帧率
        await _engine.setVideoEncoderConfiguration(
          const VideoEncoderConfiguration(
            dimensions: VideoDimensions(width: 640, height: 360),
            frameRate: 15,
          ),
        );
    
        await _engine.startPreview();
      }
    
      @override
      void dispose() {
        _engine.leaveChannel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(),
            body: Stack(
              children: [
                FutureBuilder<bool?>(
                    future: initStatus,
                    builder: (context, snap) {
                      if (snap.data != true) {
                        return const Center(
                          child: Text(
                            "初始化ing",
                            style: TextStyle(fontSize: 30),
                          ),
                        );
                      }
                      return AgoraVideoView(
                        controller: currentController,
                      );
                    }),
                Align(
                  alignment: Alignment.topLeft,
                  child: SingleChildScrollView(
                    scrollDirection: Axis.horizontal,
                    child: Row(
                      ///增加点击切换
                      children: List.of(remoteControllers.entries.map(
                        (e) => InkWell(
                          onTap: () {
                            setState(() {
                              remoteControllers[e.key] = currentController;
                              currentController = e.value;
                            });
                          },
                          child: SizedBox(
                            width: 120,
                            height: 120,
                            child: AgoraVideoView(
                              controller: e.value,
                            ),
                          ),
                        ),
                      )),
                    ),
                  ),
                )
              ],
            ),
            floatingActionButton: FloatingActionButton(
              onPressed: () async {
                // 加入频道
                _engine.joinChannel(
                  token: token,
                  channelId: cid,
                  uid: 0,
                  options: const ChannelMediaOptions(
                    channelProfile:
                        ChannelProfileType.channelProfileLiveBroadcasting,
                    clientRoleType: ClientRoleType.clientRoleBroadcaster,
                  ),
                );
              },
            ),
            persistentFooterButtons: [
              ElevatedButton.icon(
                  onPressed: () {
                    _enableVirtualBackground();
                  },
                  icon: const Icon(Icons.accessibility_rounded),
                  label: const Text("虚拟背景")),
              ElevatedButton.icon(
                  onPressed: () {
                    _engine.setBeautyEffectOptions(
                      enabled: true,
                      options: const BeautyOptions(
                        lighteningContrastLevel:
                            LighteningContrastLevel.lighteningContrastHigh,
                        lighteningLevel: .5,
                        smoothnessLevel: .5,
                        rednessLevel: .5,
                        sharpnessLevel: .5,
                      ),
                    );
                    //_engine.setRemoteUserSpatialAudioParams();
                  },
                  icon: const Icon(Icons.face),
                  label: const Text("美颜")),
              ElevatedButton.icon(
                  onPressed: () {
                    _engine.setColorEnhanceOptions(
                        enabled: true,
                        options: const ColorEnhanceOptions(
                            strengthLevel: 6.0, skinProtectLevel: 0.7));
                  },
                  icon: const Icon(Icons.color_lens),
                  label: const Text("增强色彩")),
              ElevatedButton.icon(
                  onPressed: () {
                    _engine.enableSpatialAudio(true);
                  },
                  icon: const Icon(Icons.surround_sound),
                  label: const Text("空间音效")),
              ElevatedButton.icon(
                  onPressed: () {                
                    _engine.setAudioProfile(
                        profile: AudioProfileType.audioProfileDefault,
                        scenario: AudioScenarioType.audioScenarioGameStreaming);
                    _engine
                        .setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);
                  },
                  icon: const Icon(Icons.surround_sound),
                  label: const Text("人声音效")),
            ]);
      }
    
      Future<void> _enableVirtualBackground() async {
        ByteData data = await rootBundle.load("assets/bg.jpg");
        List<int> bytes =
            data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
        Directory appDocDir = await getApplicationDocumentsDirectory();
        String p = path.join(appDocDir.path, 'bg.jpg');
        final file = File(p);
        if (!(await file.exists())) {
          await file.create();
          await file.writeAsBytes(bytes);
        }
    
        await _engine.enableVirtualBackground(
            enabled: true,
            backgroundSource: VirtualBackgroundSource(
                backgroundSourceType: BackgroundSourceType.backgroundImg,
                source: p),
            segproperty:
                const SegmentationProperty(modelType: SegModelType.segModelAi));
        setState(() {});
      }
    }

    06 最后

    本篇的内容作为「基于声网 Flutter SDK 实现多人视频通话」的补充,相对来说内容还是比较简单,不过可以看到不管是在画面处理还是在声音处理上,声网 SDK 都提供了非常便捷的 API 实现,特别在声音处理上,因为文章限制这里只展示了简单的 API 介绍,所以强烈建议大家自己尝试下这些音频 API ,真的非常有趣。除此之外,还有许多场景与玩法,可以点击此处访问官网了解。



    推荐阅读
    相关专栏
    SDK 教程
    167 文章
    本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。