ざっくり概要
前の記事で偉そうにオレオレチュートリアル書いて、次はバーチャル背景とかミュートとか実装すると言ってしまったので今回はそんな内容を。
あ、あとは通話アプリってスピーカー切り替えとかあるものが殆どだと思うので、それも。
通話開始までの流れは↑の前回の記事に書いてあるのでそちらをお読みください。
今回こんなUIにしました。
左から
・退室
・ミュート
・スピーカーモード
・背景有無
まずは簡単なスピーカー on / off から
// ストアドプロパティ作って画像差し替えて、Managerクラスの処理を呼ぶだけ
private var useSpeaker: Bool {
get {
skywayManager.useSpeaker
}
set {
skywayManager.useSpeaker = newValue
if newValue {
speakerButton.setImage(UIImage(named: "speaker_on"),
for: .normal)
} else {
speakerButton.setImage(UIImage(named: "speaker_off"),
for: .normal)
}
}
}
// ボタンタップ時にuseSpeakerをトグルするだけ
@IBAction func didSelectSpeaker(_ sender: Any) {
useSpeaker.toggle()
}
// AVAudioSessionのoverrideOutputAudioPort呼ぶだけ
var useSpeaker: Bool = false {
didSet {
do {
if useSpeaker {
try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
} else {
try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none)
}
} catch {
print("Failed to switch speaker.")
}
}
}
特に語るところないですね。次。
ミュート on / off
ViewController側は同じようにproperty作ってManager側に命令送ってボタン変えるだけです。省略
// Roomにjoinする際発行されるmemberを取っておく
me = try await room.join(with: memberInit)
// memberからpublicationが取れるので、その中の音声の物だけ取得し
private var audioPublication: SkyWayRoom.RoomPublication? {
me?.publications.first {
$0.contentType == .audio
}
}
// enable(), disable()するだけ
var isMute: Bool = false {
didSet {
Task {
do {
if isMute {
try await audioPublication?.disable()
} else {
try await audioPublication?.enable()
}
} catch {
print("Failed to switch mute.")
}
}
}
}
ちなみに今回は実装しませんでしたが、他の人が今ミュートなのかそうなのか知りたければ
Roomへの参加者は自分含め全員Memberであり、↑と同じようにpublicationまで辿れるのでそいつのstateを見てあげれば良さそうです。
構造がシンプルでいいですね。次。
バーチャル背景 on / off
前回の記事で書いたのもそうでしたが、普通に実装するとカメラの起動から勝手にSDKがやってくれちゃうのですが、
ビデオの入力ソースを自分でカスタマイズできるようにもなっているのでそれを使用します。
// CustomFrameVideoSourceを使う
let frameSource: CustomFrameVideoSource = .init()
// そこから生成できるLocalVideoStreamにViewをAttachして自分の顔を投影する
let localVideoStream: LocalVideoStream = frameSource.createStream()
// Publishまでやっておく
let _ = try await member.publish(localVideoStream, options: nil)
あとは自分で頑張ってAVCaptureSessionとか何やらで撮影し
// 頑張って背景を合成する
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
if #available(iOS 15.0, *), useBackground {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let originalImage = CIImage(cvImageBuffer: imageBuffer)
guard let pixelBuffer = originalImage.pixelBuffer else {
return
}
let backgroundImage = CIImage(cgImage: UIImage(named: "background")!.cgImage!)
lazy var personSegmentationRequest: VNGeneratePersonSegmentationRequest = {
let request = VNGeneratePersonSegmentationRequest()
request.qualityLevel = .fast
request.outputPixelFormat = kCVPixelFormatType_OneComponent8
return request
}()
let sequenceRequestHandler = VNSequenceRequestHandler()
try? sequenceRequestHandler.perform([personSegmentationRequest],
on: pixelBuffer,
orientation: .up)
guard let resultPixelBuffer = personSegmentationRequest.results?.first?.pixelBuffer else { return }
var maskImage = CIImage(cvPixelBuffer: resultPixelBuffer)
// 結果のマスクイメージはサイズが変わっているので、オリジナルか背景のサイズに合わせる
let scaleX = originalImage.extent.width / backgroundImage.extent.width
let scaleY = originalImage.extent.height / backgroundImage.extent.height
let resizedBackgroundImage = backgroundImage.transformed(by: .init(scaleX: scaleX, y: scaleY))
let scaleXForMask = originalImage.extent.width / maskImage.extent.width
let scaleYForMask = originalImage.extent.height / maskImage.extent.height
maskImage = maskImage.transformed(by: .init(scaleX: scaleXForMask, y: scaleYForMask))
let filter = CIFilter(name: "CIBlendWithMask", parameters: [
kCIInputImageKey: originalImage,
kCIInputBackgroundImageKey: resizedBackgroundImage,
kCIInputMaskImageKey: maskImage])
if let outputImage = filter?.outputImage {
CIContext(options: [.useSoftwareRenderer:false])
.render(outputImage,
to: imageBuffer,
bounds: outputImage.extent,
colorSpace: CGColorSpace(name: CGColorSpace.extendedSRGB))
// 最後にこれを実行すると画面に反映される
frameSource?.updateFrame(with: sampleBuffer)
}
} else {
frameSource?.updateFrame(with: sampleBuffer)
}
}
超余談
WebRTCを使って通話時、自分がどのipでどのポートなのか。通信先はどのipでどのポートなのか。
を取得するっていう調査をする必要があり調べていたのですが
// これで取得できました。スゲー簡単
audioPublication?.getStats(memberId: member.id)?.reports.forEach {
print($0.params)
}
感想
sampleBuffer, pixelBuffer の扱いに慣れていないのでそこでハマりました。
それ以外の実装はスラスラ書けました。SDKとドキュメントがしっかりしているので、よくある普通の通話アプリを作る上で最低限必要そうな機能の実装に詰まるようなことはなさそうです。
SDKの中身みてもコメントとかが日本語なのがいいっすよね。
最後に
作成したプロジェクトをgithubに公開しました。
興味ある方は覗いてみてください。
※Firebaseだったりの設定しないといけないのでそのままでは動きません