Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:
build:
runs-on: macos-15
env:
XCODE: /Applications/Xcode_16.3.app
XCODE_SDK: iphoneos18.4
XCODE: /Applications/Xcode_16.4.app
XCODE_SDK: iphoneos18.5
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI が 18.4 では通らなくなってしまったため上げています

steps:
- uses: actions/checkout@v6
- name: Select Xcode Version
Expand Down
19 changes: 16 additions & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@
- [UPDATE] Configuration.simulcastRid を非推奨にする
- 移行先は `Configuration.simulcastRequestRid`
- @zztkm
- [ADD] MediaChannel に `setAudioSoftMute(_:)` を追加する
- 音声ソフトミュート機能のシンタックスシュガー
- [ADD] MediaChannel に音声ソフトミュートを設定する `setAudioSoftMute(_:)` を追加する
- 送信ストリームの AudioTrack を取得し、MediaStream.audioEnabled を切り替える
- AudioTrack の有無判定を行うため、 MediaStream に `hasAudioTrack` を追加する
- デジタルサイレンスパケットが送られる状態となり、マイクからの音声は送出されない
- MediaChannel から AudioTrack の有無判定を行うため、 MediaStream に `hasAudioTrack` を追加する
- @t-miya
- [ADD] MediaChannel に映像ソフトミュートを設定する `setVideoSoftMute(_:)` を追加する
- 送信ストリームの VideoTrack を取得し、MediaStream.videoEnabled を切り替える
- MediaChannel から VideoTrack の有無判定を行うため、 MediaStream に `hasVideoTrack` を追加する
- @t-miya
- [ADD] MediaChannel に映像ハードミュートを設定する `setVideoHardMute(_:)` を追加する
- CameraVideoCapturer の `stop``restart` のラッパー
- ハードミュートの複数同時実行を防ぐための Actor `VideoHardMuteActor` を追加する
- 映像ソフトミュートも併用し、黒塗りフレームの状態で停止させる
- @t-miya
- [ADD] 音声のハードミュート有効化/無効化機能を追加する
- iOS 端末のマイクインジケーターを消灯させる
Expand Down Expand Up @@ -80,6 +89,10 @@

- [UPDATE] SwiftLint を 0.63.0 に上げる
- @zztkm
- [UPDATE] GitHub Actions のビルド環境を更新する
- Xcode の version を 16.4 に変更
- SDK を iOS 18.5 に変更
- @t-miya
- [UPDATE] `Claude Assistant``claude-response``ubuntu-slim` に移行する
- @zztkm
- [UPDATE] jazzy の設定ファイルを更新する
Expand Down
123 changes: 121 additions & 2 deletions Sora/MediaChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ public final class MediaChannel {

private let manager: Sora

// 映像ハードミュートの同時呼び出しを直列化するための Actor です
// MediaChannel 間の排他実行を保証するため static にしています
private static let videoHardMuteActor = VideoHardMuteActor()

// MARK: - インスタンスの生成

/// 初期化します。
Expand Down Expand Up @@ -632,18 +636,27 @@ public final class MediaChannel {
}

/// MediaChannel の接続中にマイクをハードミュート有効化/無効化します
///
/// - Parameter mute: `true` で有効化、`false` で無効化
/// - Returns: 成功した場合は `nil`、失敗した場合は `Error` を返します
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
public func setAudioHardMute(_ mute: Bool) -> Error? {
// 接続されているか
guard state == .connected else {
return SoraError.mediaChannelError(
reason: "MediaChannel is not connected (state: \(state))")
}

// 接続設定で音声が有効になっているか
guard configuration.audioEnabled else {
return SoraError.mediaChannelError(reason: "audioEnabled is false")
}

// 接続設定で配信側ロールになっているか
guard configuration.isSender else {
return SoraError.mediaChannelError(reason: "role is not sender")
}

// 音声ハードミュートを切り替えます
if !NativePeerChannelFactory.default.audioDeviceModuleWrapper.setAudioHardMute(mute) {
return SoraError.mediaChannelError(
reason: "AudioDeviceModuleWrapper::setAudioHardMute failed")
Expand All @@ -653,30 +666,136 @@ public final class MediaChannel {
}

/// MediaChannel の接続中にマイクをソフトミュート有効化 / 無効化します
///
/// - Parameter mute: `true` で有効化、`false` で無効化
/// - Returns: 成功した場合は `nil`、失敗した場合は `Error` を返します
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
public func setAudioSoftMute(_ mute: Bool) -> Error? {
// 接続されているか
guard state == .connected else {
return SoraError.mediaChannelError(
reason: "MediaChannel is not connected (state: \(state))")
}

// 接続設定で音声が有効になっているか
guard configuration.audioEnabled else {
return SoraError.mediaChannelError(reason: "audioEnabled is false")
}

// 接続設定で配信側ロールになっているか
guard configuration.isSender else {
return SoraError.mediaChannelError(reason: "role is not sender")
}

// 送信ストリームが有効か
guard let senderStream else {
return SoraError.mediaChannelError(reason: "senderStream is unavailable")
}

// ローカル音声トラックが存在するか
guard senderStream.hasAudioTrack else {
return SoraError.mediaChannelError(reason: "senderStream has no AudioTrack")
}

// ローカル音声トラックの有効/無効を切り替えます
senderStream.audioEnabled = !mute
Logger.debug(type: .mediaChannel, message: "setAudioSoftMute mute=\(mute)")
return nil
}

/// MediaChannel の接続中に映像をソフトミュート有効化 / 無効化します
/// 黒塗りフレームが送信される状態になります
///
/// - Parameter mute: `true` で有効化、`false` で無効化
/// - Returns: 成功した場合は `nil`、失敗した場合は `SoraError.mediaChannelError` を返します
public func setVideoSoftMute(_ mute: Bool) -> Error? {
let senderStream: MediaStream
switch requireSenderStreamForVideoMute() {
case .failure(let error):
return error
case .success(let stream):
senderStream = stream
}

// ローカル映像トラックの有効/無効を切り替えます
senderStream.videoEnabled = !mute
Logger.debug(type: .mediaChannel, message: "setVideoSoftMute mute=\(mute)")
return nil
}

/// MediaChannel の接続中に映像をハードミュート有効化 / 無効化します
///
/// 端末カメラ利用が有効になっている必要があります
/// 外部入力や別キャプチャ経路には対応していません
///
/// 内部で Actor により、操作を排他実行します。
/// 同時に呼び出された場合は Actor 側で `SoraError.mediaChannelError` がスローされます
///
/// 映像ハードミュートは、黒塗りフレーム状態で停止させるためローカルトラックの停止を含みます
/// 事前に映像ソフトミュートを利用していた場合は状態が上書きされます
/// ハードミュート解除時に直前のソフトミュートの状態を復元するようなことはしません
///
/// - Parameter mute: `true` で有効化、`false` で無効化
/// - Throws: エラー時は `SoraError.cameraError` または `SoraError.mediaChannelError` がスローされます
public func setVideoHardMute(_ mute: Bool) async throws {
let senderStream: MediaStream
switch requireSenderStreamForVideoMute() {
case .failure(let error):
throw error
case .success(let stream):
senderStream = stream
}

// 接続設定でカメラ利用が有効になっているか
// 端末カメラではなく別ソース(外部入力や別キャプチャ経路)の場合は false になることがあり、機能としては未対応
guard configuration.cameraSettings.isEnabled else {
throw SoraError.mediaChannelError(reason: "cameraSettings.isEnabled is false")
}

if mute {
// 黒塗りフレーム送出 -> ハードミュート有効化の順になるようにします
senderStream.videoEnabled = false
try await Self.videoHardMuteActor.setMute(mute: true, senderStream: senderStream)
} else {
// ハードミュート無効化 -> 黒塗りフレーム送出解除の順になるようにします
try await Self.videoHardMuteActor.setMute(mute: false, senderStream: senderStream)
senderStream.videoEnabled = true
}
Logger.debug(type: .mediaChannel, message: "setVideoHardMute mute=\(mute)")
}

// 映像ミュートのための接続状況や接続設定のチェックを実行した上で送信ストリームを取得します
//
// チェックを全て通過した場合は .success で送信ストリームを返します
// 問題があった場合は .failure で SoraError.mediaChannelError を返します
private func requireSenderStreamForVideoMute() -> Result<MediaStream, Error> {
// 接続されているか
guard state == .connected else {
return .failure(
SoraError.mediaChannelError(reason: "MediaChannel is not connected (state: \(state))"))
}

// 接続設定で映像が有効になっているか
guard configuration.videoEnabled else {
return .failure(SoraError.mediaChannelError(reason: "videoEnabled is false"))
}

// 接続設定で配信側ロールになっているか
guard configuration.isSender else {
return .failure(SoraError.mediaChannelError(reason: "role is not sender"))
}

// 送信ストリームが有効か
guard let senderStream else {
return .failure(SoraError.mediaChannelError(reason: "senderStream is unavailable"))
}

// 送信ストリームに映像トラックが含まれているか
guard senderStream.hasVideoTrack else {
return .failure(SoraError.mediaChannelError(reason: "senderStream has no VideoTrack"))
}

return .success(senderStream)
}
}

extension MediaChannel: CustomStringConvertible {
Expand Down
19 changes: 14 additions & 5 deletions Sora/MediaStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,23 @@ public protocol MediaStream: AnyObject {
// MARK: - 映像と音声の可否

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

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

/// 音声トラックを保持している場合は ``true`` を返します。
/// 映像トラックを保持している場合は `true` を返します。
///
/// SDK 内部で利用する判定用のプロパティです。
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これいる?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaChannel から直接取れるのか確認します

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaChannel.setVideoSoft(Hard)Mute 実行時にローカルトラックが存在しない場合にエラーで返せないため必要です

  • MediaStream がローカルトラックを公開していない
  • MediaStream.videoEnabled setter が返り値を返さない(ローカルトラックがなければ何もしない)ため

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

というのをコメントに書くととても良いです。

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あ、ごめんこれ閉じちゃった。コメントに残しておいて。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

なるほど承知です

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasAudioTrack も同様なので併せてコメント追加しました

var hasVideoTrack: Bool { get }

/// 音声トラックを保持している場合は `true` を返します。
///
/// SDK 内部で利用する判定用のプロパティです。
var hasAudioTrack: Bool { get }
Expand Down Expand Up @@ -206,6 +211,10 @@ class BasicMediaStream: MediaStream {
nativeAudioTrack != nil
}

var hasVideoTrack: Bool {
nativeVideoTrack != nil
}

var remoteAudioVolume: Double? {
get {
nativeAudioTrack?.source.volume
Expand Down
98 changes: 98 additions & 0 deletions Sora/VideoMute.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Foundation

// 映像ハードミュートの同時呼び出しによるレースコンディション防止を目的とした Actor です
// MediaChannel.setVideoHardMute(_:) での使用を想定しています
actor VideoHardMuteActor {
// 処理実行中フラグ
private var isProcessing = false
// カメラ操作のためのキャプチャラー
private var capturer: CameraVideoCapturer?

/// ハードミュートを有効化/無効化します
///
/// - Parameters:
/// - mute: `true` で有効化、`false` で無効化
/// - senderStream: 送信ストリーム
/// - Throws:
/// - 既に処理実行中、またはカメラキャプチャラーが無効な場合は `SoraError.mediaChannelError`
/// - カメラ操作の失敗時は `SoraError.cameraError`
func setMute(mute: Bool, senderStream: MediaStream) async throws {
guard !isProcessing else {
throw SoraError.mediaChannelError(reason: "video hard mute operation is in progress")
}
isProcessing = true
defer { isProcessing = false }

// ミュートを有効化します
if mute {
guard let currentCapturer = await currentCameraVideoCapturer() else {
// 既にハードミュート済み(再開用キャプチャラーを保持)なら、冪等として何もしません
if capturer != nil { return }
throw SoraError.mediaChannelError(reason: "CameraVideoCapturer is unavailable")
}
try await stopCameraVideoCapture(currentCapturer)
// ミュート無効化する際にキャプチャラーを使用するため保持しておきます
capturer = currentCapturer
return
}

// ミュートを無効化します
// 現在のキャプチャラーが取得できる場合は既に再開済みとして成功扱いにします
let currentCapturer = await currentCameraVideoCapturer()
if currentCapturer != nil { return }
// 前回停止時のキャプチャラーが保持できていない場合エラー
guard let stored = capturer else {
throw SoraError.mediaChannelError(reason: "CameraVideoCapturer is unavailable")
}
try await restartCameraVideoCapture(stored, senderStream: senderStream)
}

// 現在のカメラキャプチャラーを取得します
private func currentCameraVideoCapturer() async -> CameraVideoCapturer? {
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
await withCheckedContinuation { continuation in
SoraDispatcher.async(on: .camera) {
continuation.resume(returning: CameraVideoCapturer.current)
}
}
}

// カメラキャプチャを停止します
private func stopCameraVideoCapture(_ capturer: CameraVideoCapturer) async throws {
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
SoraDispatcher.async(on: .camera) {
// CameraVideoCapturer.stop はコールバック形式です
capturer.stop { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}

// カメラキャプチャを再開します
private func restartCameraVideoCapture(
_ capturer: CameraVideoCapturer,
senderStream: MediaStream
) async throws {
// libwebrtc のカメラ用キュー(SoraDispatcher)を利用して実行します
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
SoraDispatcher.async(on: .camera) {
// マルチストリームの場合、停止時と現在の送信ストリームが異なることがあるので再設定します
capturer.stream = senderStream
// CameraVideoCapturer.restart はコールバック形式です
capturer.restart { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
}