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

聲網發表於2023-03-28

前言

在之前的「基於聲網 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 分鐘使用額度。如在開發過程中遇到疑問,可在聲網開發者社群與官方工程師交流。

相關文章