likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 10

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

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

虚拟背景

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

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

  • assets/bg.jpg 图片需要在 pubspec.yaml 文件下的 assets 添加引用

      assets:
        - assets/bg.jpg
    
  • 需要在 pubspec.yaml 文件下添加 path_provider: ^2.0.8path: ^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(绿幕算法)两种不同场景下的抠图算法。

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

这里需要注意的是,在官方的提示里,建议只在搭载如下芯片的设备上使用该功能(应该是对于 GPU 有要求):

  • 骁龙 700 系列 750G 及以上
  • 骁龙 800 系列 835 及以上
  • 天玑 700 系列 720 及以上
  • 麒麟 800 系列 810 及以上
  • 麒麟 900 系列 980 及以上

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

美颜

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

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

这里的 .5 只是做了一个 Demo 效果,具体可以根据你的产品需求,配置出几种固定模版让用户选择。

_engine.setBeautyEffectOptions(
  enabled: true,
  options: const BeautyOptions(
    lighteningContrastLevel:
        LighteningContrastLevel.lighteningContrastHigh,
    lighteningLevel: .5,
    smoothnessLevel: .5,
    rednessLevel: .5,
    sharpnessLevel: .5,
  ),
);
属性作用
lighteningContrastLevel对比度,常与 lighteningLevel 搭配使用。取值越大,明暗对比程度越大
lighteningLevel美白程度,取值范围为 [0.0,1.0],其中 0.0 表示原始亮度,默认值为 0.0。取值越大,美白程度越大
smoothnessLevel磨皮程度,取值范围为 [0.0,1.0],其中 0.0 表示原始磨皮程度,默认值为 0.0。取值越大,磨皮程度越大
rednessLevel红润度,取值范围为 [0.0,1.0],其中 0.0 表示原始红润度,默认值为 0.0。取值越大,红润程度越大
sharpnessLevel锐化程度,取值范围为 [0.0,1.0],其中 0.0 表示原始锐度,默认值为 0.0。取值越大,锐化程度越大

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

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

色彩增强

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

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

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

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

开启增强之后画面更抢眼了。

没开增加开了美颜+增强
在 Flutter 多人视频中实现虚拟背景、美颜与空间音效在 Flutter 多人视频中实现虚拟背景、美颜与空间音效
属性参数
strengthLevel色彩增强程度。取值范围为 [0.0,1.0]。0.0 表示不对视频进行色彩增强。取值越大,色彩增强的程度越大。默认值为 0.5。
skinProtectLevel肤色保护程度。取值范围为 [0.0,1.0]。0.0 表示不对肤色进行保护。取值越大,肤色保护的程度越大。默认值为 1.0。 当色彩增强程度较大时,人像肤色会明显失真,你需要设置肤色保护程度; 肤色保护程度较大时,色彩增强效果会略微降低。 因此,为获取最佳的色彩增强效果,建议动态调节 strengthLevel 和 skinProtectLevel 以实现最合适的效果。

空间音效

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

_engine.enableSpatialAudio(true);

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

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

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

属性作用
speaker_azimuth远端用户或媒体播放器相对于本地用户的水平角。 取值范围为 [0,360],单位为度,例如 (默认)0 度,表示水平面的正前方;90 度,表示水平面的正左方;180 度,表示水平面的正后方;270 度,表示水平面的正右方;360 度,表示水平面的正前方;
speaker_elevation远端用户或媒体播放器相对于本地用户的俯仰角。 取值范围为 [-90,90],单位为度。(默认)0 度,表示水平面无旋转;-90 度,表示水平面向下旋转 90 度;90 度,表示水平面向上旋转 90 度
speaker_distance远端用户或媒体播放器相对于本地用户的距离,取值范围为 [1,50],单位为米,默认值为 1 米。
speaker_orientation远端用户或媒体播放器相对于本地用户的朝向。 取值范围为 [0,180],单位为度。默认)0 度,表示声源和听者朝向同一方向;180: 180 度,表示声源和听者面对面
enable_blur是否开启声音模糊处理
enable_air_absorb是否开启空气衰减,即模拟声音在空气中传播的音色衰减效果:在一定的传输距离下,高频声音衰减速度快、低频声音衰减速度慢。
speaker_attenuation远端用户或媒体播放器的声音衰减系数,取值范围为[0,1]。 0:广播模式,即音量和音色均不随距离衰减;(0,0.5):弱衰减模式,即音量和音色在传播过程中仅发生微弱衰减;0.5:(默认)模拟音量在真实环境下的衰减,效果等同于不设置 speaker_attenuation 参数;(0.5,1]:强衰减模式,即音量和音色在传播过程中发生迅速衰减
enable_doppler是否开启多普勒音效:当声源与接收声源者之间产生相对位移时,接收方听到的音调会发生变化

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

人声音效

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

_engine.setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);

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

参数作用
audioEffectOff原声
roomAcousticsKtvKTV
roomAcousticsVocalConcert演唱会
roomAcousticsStudio录音棚
roomAcousticsPhonograph留声机
roomAcousticsVirtualStereo虚拟立体声
roomAcousticsSpacial空旷
roomAcousticsEthereal空灵
roomAcousticsVirtualSurroundSound虚拟环绕声
roomAcoustics3dVoice3D 人声
voiceChangerEffectUncle大叔
voiceChangerEffectOldman老年男性
voiceChangerEffectBoy男孩
voiceChangerEffectSister少女
voiceChangerEffectGirl女孩
voiceChangerEffectPigking猪八戒
voiceChangerEffectHulk绿巨人
styleTransformationRnbR&B
styleTransformationPopular流行
pitchCorrection电音

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(() {});
  }
}

最后

本篇的内容作为上一篇的补充,相对来说内容还是比较简单,不过可以看到不管是在画面处理还是在声音处理上,声网 SDK 都提供了非常便捷的 API 实现,特别在声音处理上,因为文章限制这里只展示了简单的 API 介绍,所以强烈建议大家自己尝试下这些音频 API ,真的非常有趣。