在 Flutter 多人視頻中實現虛擬背景、美顏與空間音效

語言: CN / TW / HK

前言

在之前的「基於聲網 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.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(綠幕算法)兩種不同場景下的摳圖算法。

這裏需要注意的是,在官方的提示裏,建議只在搭載如下芯片的設備上使用該功能(應該是對於 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,實現實時音視頻互動場景。現註冊聲網賬號下載 SDK,可獲得每月免費 10000 分鐘使用額度。如在開發過程中遇到疑問,可在聲網開發者社區與官方工程師交流。