Skip to content

Commit bf80a9c

Browse files
authored
Merge pull request #298 from shiguredo/feature/video-mute
setVideoSoftMute / setVideoHardMute を追加
2 parents efba15c + fdb6980 commit bf80a9c

File tree

5 files changed

+257
-13
lines changed

5 files changed

+257
-13
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ jobs:
1212
build:
1313
runs-on: macos-15
1414
env:
15-
XCODE: /Applications/Xcode_16.3.app
16-
XCODE_SDK: iphoneos18.4
15+
XCODE: /Applications/Xcode_16.4.app
16+
XCODE_SDK: iphoneos18.5
1717
steps:
1818
- uses: actions/checkout@v6
1919
- name: Select Xcode Version

CHANGES.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,19 @@
1919
- [UPDATE] Configuration.simulcastRid を非推奨にする
2020
- 移行先は `Configuration.simulcastRequestRid`
2121
- @zztkm
22-
- [ADD] MediaChannel に `setAudioSoftMute(_:)` を追加する
23-
- 音声ソフトミュート機能のシンタックスシュガー
22+
- [ADD] MediaChannel に音声ソフトミュートを設定する `setAudioSoftMute(_:)` を追加する
2423
- 送信ストリームの AudioTrack を取得し、MediaStream.audioEnabled を切り替える
25-
- AudioTrack の有無判定を行うため、 MediaStream に `hasAudioTrack` を追加する
24+
- デジタルサイレンスパケットが送られる状態となり、マイクからの音声は送出されない
25+
- MediaChannel から AudioTrack の有無判定を行うため、 MediaStream に `hasAudioTrack` を追加する
26+
- @t-miya
27+
- [ADD] MediaChannel に映像ソフトミュートを設定する `setVideoSoftMute(_:)` を追加する
28+
- 送信ストリームの VideoTrack を取得し、MediaStream.videoEnabled を切り替える
29+
- MediaChannel から VideoTrack の有無判定を行うため、 MediaStream に `hasVideoTrack` を追加する
30+
- @t-miya
31+
- [ADD] MediaChannel に映像ハードミュートを設定する `setVideoHardMute(_:)` を追加する
32+
- CameraVideoCapturer の `stop``restart` のラッパー
33+
- ハードミュートの複数同時実行を防ぐための Actor `VideoHardMuteActor` を追加する
34+
- 映像ソフトミュートも併用し、黒塗りフレームの状態で停止させる
2635
- @t-miya
2736
- [ADD] 音声のハードミュート有効化/無効化機能を追加する
2837
- iOS 端末のマイクインジケーターを消灯させる
@@ -88,6 +97,11 @@
8897
- [ADD] `Package.swift``testTarget` を追加する
8998
- xcodebuild で test を実行するために target を追加
9099
- @zztkm
100+
- [FIX] GitHub Actions のビルド環境を更新する
101+
- GitHub Actions でのビルドが通らなくなったため
102+
- Xcode の version を 16.4 に変更
103+
- SDK を iOS 18.5 に変更
104+
- @t-miya
91105

92106
## 2025.2.0
93107

Sora/MediaChannel.swift

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ public final class MediaChannel {
224224

225225
private let manager: Sora
226226

227+
// 映像ハードミュートの同時呼び出しを直列化するための Actor です
228+
// MediaChannel 間の排他実行を保証するため static にしています
229+
private static let videoHardMuteActor = VideoHardMuteActor()
230+
227231
// MARK: - インスタンスの生成
228232

229233
/// 初期化します。
@@ -632,18 +636,27 @@ public final class MediaChannel {
632636
}
633637

634638
/// MediaChannel の接続中にマイクをハードミュート有効化/無効化します
639+
///
635640
/// - Parameter mute: `true` で有効化、`false` で無効化
636-
/// - Returns: 成功した場合は `nil`、失敗した場合は `Error` を返します
641+
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
637642
public func setAudioHardMute(_ mute: Bool) -> Error? {
643+
// 接続されていなければエラー
638644
guard state == .connected else {
639645
return SoraError.mediaChannelError(
640646
reason: "MediaChannel is not connected (state: \(state))")
641647
}
642648

649+
// 接続設定で音声が有効になっていなければエラー
643650
guard configuration.audioEnabled else {
644651
return SoraError.mediaChannelError(reason: "audioEnabled is false")
645652
}
646653

654+
// 接続設定で配信側ロールになっていなければエラー
655+
guard configuration.isSender else {
656+
return SoraError.mediaChannelError(reason: "role is not sender")
657+
}
658+
659+
// 音声ハードミュートを切り替えます
647660
if !NativePeerChannelFactory.default.audioDeviceModuleWrapper.setAudioHardMute(mute) {
648661
return SoraError.mediaChannelError(
649662
reason: "AudioDeviceModuleWrapper::setAudioHardMute failed")
@@ -653,30 +666,136 @@ public final class MediaChannel {
653666
}
654667

655668
/// MediaChannel の接続中にマイクをソフトミュート有効化 / 無効化します
669+
///
656670
/// - Parameter mute: `true` で有効化、`false` で無効化
657-
/// - Returns: 成功した場合は `nil`、失敗した場合は `Error` を返します
671+
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
658672
public func setAudioSoftMute(_ mute: Bool) -> Error? {
673+
// 接続されていなければエラー
659674
guard state == .connected else {
660675
return SoraError.mediaChannelError(
661676
reason: "MediaChannel is not connected (state: \(state))")
662677
}
663678

679+
// 接続設定で音声が有効になっていなければエラー
664680
guard configuration.audioEnabled else {
665681
return SoraError.mediaChannelError(reason: "audioEnabled is false")
666682
}
667683

684+
// 接続設定で配信側ロールになっていなければエラー
685+
guard configuration.isSender else {
686+
return SoraError.mediaChannelError(reason: "role is not sender")
687+
}
688+
689+
// 送信ストリームが有効でなければエラー
668690
guard let senderStream else {
669691
return SoraError.mediaChannelError(reason: "senderStream is unavailable")
670692
}
671693

694+
// ローカル音声トラックが存在しなければエラー
672695
guard senderStream.hasAudioTrack else {
673696
return SoraError.mediaChannelError(reason: "senderStream has no AudioTrack")
674697
}
675698

699+
// ローカル音声トラックの有効/無効を切り替えます
676700
senderStream.audioEnabled = !mute
677701
Logger.debug(type: .mediaChannel, message: "setAudioSoftMute mute=\(mute)")
678702
return nil
679703
}
704+
705+
/// MediaChannel の接続中に映像をソフトミュート有効化 / 無効化します
706+
/// 黒塗りフレームが送信される状態になります
707+
///
708+
/// - Parameter mute: `true` で有効化、`false` で無効化
709+
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
710+
public func setVideoSoftMute(_ mute: Bool) -> Error? {
711+
let senderStream: MediaStream
712+
switch requireSenderStreamForVideoMute() {
713+
case .failure(let error):
714+
return error
715+
case .success(let stream):
716+
senderStream = stream
717+
}
718+
719+
// ローカル映像トラックの有効/無効を切り替えます
720+
senderStream.videoEnabled = !mute
721+
Logger.debug(type: .mediaChannel, message: "setVideoSoftMute mute=\(mute)")
722+
return nil
723+
}
724+
725+
/// MediaChannel の接続中に映像をハードミュート有効化 / 無効化します
726+
///
727+
/// 端末カメラ利用が有効になっている必要があります
728+
/// 外部入力や別キャプチャ経路には対応していません
729+
///
730+
/// 内部で Actor により、操作を排他実行します。
731+
/// 同時に呼び出された場合は Actor 側で `SoraError.mediaChannelError` がスローされます
732+
///
733+
/// 映像ハードミュートは、黒塗りフレーム状態で停止させるためローカルトラックの停止を含みます
734+
/// 事前に映像ソフトミュートを利用していた場合は状態が上書きされます
735+
/// ハードミュート解除時に直前のソフトミュートの状態を復元するようなことはしません
736+
///
737+
/// - Parameter mute: `true` で有効化、`false` で無効化
738+
/// - Throws: エラー時は `SoraError.cameraError` または `SoraError.mediaChannelError` がスローされます
739+
public func setVideoHardMute(_ mute: Bool) async throws {
740+
let senderStream: MediaStream
741+
switch requireSenderStreamForVideoMute() {
742+
case .failure(let error):
743+
throw error
744+
case .success(let stream):
745+
senderStream = stream
746+
}
747+
748+
// 接続設定でカメラ利用が有効になっているか
749+
// 端末カメラではなく別ソース(外部入力や別キャプチャ経路)の場合は false になることがあり、機能としては未対応
750+
guard configuration.cameraSettings.isEnabled else {
751+
throw SoraError.mediaChannelError(reason: "cameraSettings.isEnabled is false")
752+
}
753+
754+
if mute {
755+
// ソフトミュートによる黒塗りフレーム送出 -> ハードミュート有効化の順になるようにします
756+
senderStream.videoEnabled = false
757+
try await Self.videoHardMuteActor.setMute(mute: true, senderStream: senderStream)
758+
} else {
759+
// ハードミュート無効化 -> ソフトミュートによる黒塗りフレーム送出解除の順になるようにします
760+
try await Self.videoHardMuteActor.setMute(mute: false, senderStream: senderStream)
761+
senderStream.videoEnabled = true
762+
}
763+
Logger.debug(type: .mediaChannel, message: "setVideoHardMute mute=\(mute)")
764+
}
765+
766+
// 映像ミュートのための接続状況や接続設定のチェックを実行した上で送信ストリームを取得します
767+
//
768+
// チェックを全て通過した場合は .success で送信ストリームを返します
769+
// 問題があった場合は .failure で SoraError.mediaChannelError を返します
770+
private func requireSenderStreamForVideoMute() -> Result<MediaStream, Error> {
771+
// 接続されていなければエラー
772+
guard state == .connected else {
773+
return .failure(
774+
SoraError.mediaChannelError(reason: "MediaChannel is not connected (state: \(state))"))
775+
}
776+
777+
// 接続設定で映像が有効になっていなければエラー
778+
guard configuration.videoEnabled else {
779+
return .failure(SoraError.mediaChannelError(reason: "videoEnabled is false"))
780+
}
781+
782+
// 接続設定で配信側ロールになっていなければエラー
783+
guard configuration.isSender else {
784+
return .failure(SoraError.mediaChannelError(reason: "role is not sender"))
785+
}
786+
787+
// 送信ストリームが有効になっていなければエラー
788+
guard let senderStream else {
789+
return .failure(SoraError.mediaChannelError(reason: "senderStream is unavailable"))
790+
}
791+
792+
// 送信ストリームに映像トラックが含まれていなければエラー
793+
guard senderStream.hasVideoTrack else {
794+
return .failure(SoraError.mediaChannelError(reason: "senderStream has no VideoTrack"))
795+
}
796+
797+
return .success(senderStream)
798+
}
680799
}
681800

682801
extension MediaChannel: CustomStringConvertible {

Sora/MediaStream.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,29 @@ public protocol MediaStream: AnyObject {
4848
// MARK: - 映像と音声の可否
4949

5050
/// 映像の可否。
51-
/// ``false`` をセットすると、サーバーへの映像の送受信を停止します。
52-
/// ``true`` をセットすると送受信を再開します。
51+
/// `false` をセットすると、サーバーへの映像の送受信を停止します。
52+
/// `true` をセットすると送受信を再開します。
5353
var videoEnabled: Bool { get set }
5454

5555
/// 音声の可否。
56-
/// ``false`` をセットすると、サーバーへの音声の送受信を停止します。
57-
/// ``true`` をセットすると送受信を再開します。
56+
/// `false` をセットすると、サーバーへの音声の送受信を停止します。
57+
/// `true` をセットすると送受信を再開します。
5858
///
5959
/// サーバーへの送受信を停止しても、マイクはミュートされませんので注意してください。
6060
var audioEnabled: Bool { get set }
6161

62-
/// 音声トラックを保持している場合は ``true`` を返します。
62+
/// 映像トラックを保持している場合は `true` を返します。
6363
///
64-
/// SDK 内部で利用する判定用のプロパティです。
64+
/// 映像ミュート時に映像トラックが存在するかチェックするために使用されます。
65+
/// ミュート時に実行する videoEnabled setter は返り値やエラーを返さないため、
66+
/// 呼び出し側へエラーを通知するために必要となります。
67+
var hasVideoTrack: Bool { get }
68+
69+
/// 音声トラックを保持している場合は `true` を返します。
70+
///
71+
/// 音声ミュート時に音声トラックが存在するかチェックするために使用されます。
72+
/// ミュート時に実行する audioEnabled setter は返り値やエラーを返さないため、
73+
/// 呼び出し側へエラーを通知するために必要となります。
6574
var hasAudioTrack: Bool { get }
6675

6776
/// 受信した音声のボリューム。 0 から 10 (含む) までの値をセットします。
@@ -206,6 +215,10 @@ class BasicMediaStream: MediaStream {
206215
nativeAudioTrack != nil
207216
}
208217

218+
var hasVideoTrack: Bool {
219+
nativeVideoTrack != nil
220+
}
221+
209222
var remoteAudioVolume: Double? {
210223
get {
211224
nativeAudioTrack?.source.volume

Sora/VideoMute.swift

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Foundation
2+
3+
// 映像ハードミュートの同時呼び出しによるレースコンディション防止を目的とした Actor です
4+
// MediaChannel.setVideoHardMute(_:) での使用を想定しています
5+
actor VideoHardMuteActor {
6+
// 処理実行中フラグ
7+
private var isProcessing = false
8+
// カメラ操作のためのキャプチャラー
9+
private var capturer: CameraVideoCapturer?
10+
11+
/// ハードミュートを有効化/無効化します
12+
///
13+
/// - Parameters:
14+
/// - mute: `true` で有効化、`false` で無効化
15+
/// - senderStream: 送信ストリーム
16+
/// - Throws:
17+
/// - 既に処理実行中、またはカメラキャプチャラーが無効な場合は `SoraError.mediaChannelError`
18+
/// - カメラ操作の失敗時は `SoraError.cameraError`
19+
func setMute(mute: Bool, senderStream: MediaStream) async throws {
20+
guard !isProcessing else {
21+
throw SoraError.mediaChannelError(reason: "video hard mute operation is in progress")
22+
}
23+
isProcessing = true
24+
defer { isProcessing = false }
25+
26+
// ミュートを有効化します
27+
if mute {
28+
guard let currentCapturer = await currentCameraVideoCapturer() else {
29+
// 既にハードミュート済み(再開用キャプチャラーを保持)なら、冪等として何もしません
30+
if capturer != nil { return }
31+
throw SoraError.mediaChannelError(reason: "CameraVideoCapturer is unavailable")
32+
}
33+
try await stopCameraVideoCapture(currentCapturer)
34+
// ミュート無効化する際にキャプチャラーを使用するため保持しておきます
35+
capturer = currentCapturer
36+
return
37+
}
38+
39+
// ミュートを無効化します
40+
// 現在のキャプチャラーが取得できる場合は既に再開済みとして成功扱いにします
41+
let currentCapturer = await currentCameraVideoCapturer()
42+
if currentCapturer != nil { return }
43+
// 前回停止時のキャプチャラーが保持できていない場合エラー
44+
guard let stored = capturer else {
45+
throw SoraError.mediaChannelError(reason: "CameraVideoCapturer is unavailable")
46+
}
47+
try await restartCameraVideoCapture(stored, senderStream: senderStream)
48+
}
49+
50+
// 現在のカメラキャプチャラーを取得します
51+
private func currentCameraVideoCapturer() async -> CameraVideoCapturer? {
52+
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
53+
await withCheckedContinuation { continuation in
54+
SoraDispatcher.async(on: .camera) {
55+
continuation.resume(returning: CameraVideoCapturer.current)
56+
}
57+
}
58+
}
59+
60+
// カメラキャプチャを停止します
61+
private func stopCameraVideoCapture(_ capturer: CameraVideoCapturer) async throws {
62+
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
63+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
64+
SoraDispatcher.async(on: .camera) {
65+
// CameraVideoCapturer.stop はコールバック形式です
66+
capturer.stop { error in
67+
if let error {
68+
continuation.resume(throwing: error)
69+
} else {
70+
continuation.resume(returning: ())
71+
}
72+
}
73+
}
74+
}
75+
}
76+
77+
// カメラキャプチャを再開します
78+
private func restartCameraVideoCapture(
79+
_ capturer: CameraVideoCapturer,
80+
senderStream: MediaStream
81+
) async throws {
82+
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
83+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
84+
SoraDispatcher.async(on: .camera) {
85+
// マルチストリームの場合、停止時と現在の送信ストリームが異なることがあるので再設定します
86+
capturer.stream = senderStream
87+
// CameraVideoCapturer.restart はコールバック形式です
88+
capturer.restart { error in
89+
if let error {
90+
continuation.resume(throwing: error)
91+
} else {
92+
continuation.resume(returning: ())
93+
}
94+
}
95+
}
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)