ミュート中の発話検知

## はじめに
Zoomやその他の一部ビデオ通話アプリにはミュート中に発話するとアラートが出る機能があります。
がんばって喋っていてもミュートに気づいてなければもう一度同じ内容を話す事になります。
Agora SDKでは2022年7月現在、このようなミュート中の発話検知機能が提供していません。
この記事では、Agora SDKを利用したビデオ通話アプリにミュート中の発話検知機能をつける方法について解説します。

## 前提
発話検知はiOS/Androidそれぞれの標準のマイク入力APIを活用します。
ミュート解除後は通常どおりAgora SDKのAPIでマイク入力を取得してパブリッシュします。

## Androidの実装方法
ベースとなるパッケージはAgora社公式のGithubに公開されている[Agora-Android-Tutorial-1to1](https://github.com/AgoraIO/Basic-Video-Call/tree/master/One-to-One-Video/Agora-Android-Tutorial-1to1)を利用します。
VideoChatViewActivity.javaにコードを追加していきます。
AudioRecordの実装については[こちら](https://steavevaivai.hatenablog.com/entry/2015/01/03/225505)を参考にさせて頂きました。

```java:VideoChatViewActivity.java
//中略
public class VideoChatViewActivity extends AppCompatActivity {
//中略
    private final IRtcEngineEventHandler mRtcEventHandler = new IRtcEngineEventHandler() {
        @Override
        public void onLocalAudioStateChanged(int state, int error){
            if(state == 0){
                startAudioRecord();//*4
            }
        }
    }
//中略
    private void joinChannel() {
        if (TextUtils.isEmpty(token) || TextUtils.equals(token, "#YOUR ACCESS TOKEN#")) {
            token = null; // default, no token
        }
        mRtcEngine.joinChannel(token, "demoChannel1", "Extra Optional Data", 0);
        initAudioRecord();//*1
    }
//中略
    private AudioRecord audioRecord;
    private int SAMPLING_RATE = 44100;
    private int bufSize;
    private short[] shortData;
    private void initAudioRecord() {
        bufSize = android.media.AudioRecord.getMinBufferSize(SAMPLING_RATE,
                AudioFormat.CHANNEL_IN_MONO,
                AudioFormat.ENCODING_PCM_16BIT);
        audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                SAMPLING_RATE,
                AudioFormat.CHANNEL_IN_MONO,
                AudioFormat.ENCODING_PCM_16BIT,
                bufSize);
        shortData = new short[bufSize / 2];
        audioRecord.setRecordPositionUpdateListener(new AudioRecord.OnRecordPositionUpdateListener() {
            @Override
            public void onPeriodicNotification(AudioRecord recorder) {
                audioRecord.read(shortData, 0, bufSize / 2);
                for(short s : shortData){
                    if(s>1000){
                        //閾値を超えたらアラート表示等の実装 *2
                    }
                }
            }
            @Override
            public void onMarkerReached(AudioRecord recorder) {
            }
        });
        audioRecord.setPositionNotificationPeriod(bufSize / 2);
    }
//中略
    private void startAudioRecord(){
        audioRecord.startRecording();
    }

    private void stopAudioRecord(){
        audioRecord.stop();
    }

    public void onLocalAudioMuteClicked(View view) {
        mMuted = !mMuted;
        mRtcEngine.muteLocalAudioStream(mMuted);
        int res = mMuted ? R.drawable.btn_mute : R.drawable.btn_unmute;
        mMuteBtn.setImageResource(res);
        if(mMuted){
            mRtcEngine.enableLocalAudio(false);//*3
        } else {
            mRtcEngine.enableLocalAudio(true);//*5
            stopAudioRecord();
        }
    }
}
```
*1でAndroidのマイク入力に関する初期設定を実行します。
*2でshortDataでボリュームを取得し、閾値を超えた場合に警告を出す等の実装をするイメージになります。
閾値は最適な値を独自で検討していただけたらと思います。
*3ではユーザーがミュート状態にした際、Agora SDKでのマイクキャプチャを停止させています。停止が完了した際、*4のコールバックが発生し、Androidの標準的なAPIでマイク入力を取得開始します。
*5ではミュート解除後に再度Agora SDKのAPIを利用してマイクキャプチャとパブリッシュを再開させます。

## iOSの実装方法
ベースとなるパッケージはAgora社公式のGithubに公開されている[Agora-iOS-Tutorial-Swift-1to1](https://github.com/AgoraIO/Basic-Video-Call/tree/master/One-to-One-Video/Agora-iOS-Tutorial-Swift-1to1)を利用します。
VideoChatViewController.swiftにコードを追加していきます。
マイク入力検知については[こちら](https://koogawa.hateblo.jp/entry/2016/04/17/133052)を参考にさせて頂きました。
```swift:VideoChatViewController.swift
//中略
import AudioToolbox
//中略
class VideoChatViewController: UIViewController{
//中略
    var queue: AudioQueueRef!
    var timer: Timer!

    func startUpdatingVolume() {
        // Set data format
        var dataFormat = AudioStreamBasicDescription(
            mSampleRate: 44100.0,
            mFormatID: kAudioFormatLinearPCM,
            mFormatFlags: AudioFormatFlags(kLinearPCMFormatFlagIsBigEndian | kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked),
            mBytesPerPacket: 2,
            mFramesPerPacket: 1,
            mBytesPerFrame: 2,
            mChannelsPerFrame: 1,
            mBitsPerChannel: 16,
            mReserved: 0)

        var audioQueue: AudioQueueRef? = nil
        var error = noErr
        error = AudioQueueNewInput(
            &dataFormat,
            AudioQueueInputCallback,
            UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
            .none,
            .none,
            0,
            &audioQueue)
        if error == noErr {
            self.queue = audioQueue
        }
        AudioQueueStart(self.queue, nil)
        var enabledLevelMeter: UInt32 = 1
        AudioQueueSetProperty(self.queue, kAudioQueueProperty_EnableLevelMetering, &enabledLevelMeter, UInt32(MemoryLayout<UInt32>.size))
        self.timer = Timer.scheduledTimer(timeInterval: 1.0,
                                                            target: self,
                                                            selector: #selector(VideoChatViewController.detectVolume(_:)),
                                                            userInfo: nil,
                                                            repeats: true)
        self.timer?.fire()
    }

    func stopUpdatingVolume()
    {
        self.timer.invalidate()
        self.timer = nil
        AudioQueueFlush(self.queue)
        AudioQueueStop(self.queue, false)
        AudioQueueDispose(self.queue, true)
    }
    
    @objc func detectVolume(_ timer: Timer)
    {
        var levelMeter = AudioQueueLevelMeterState()
        var propertySize = UInt32(MemoryLayout<AudioQueueLevelMeterState>.size)
        AudioQueueGetProperty(
            self.queue,
            kAudioQueueProperty_CurrentLevelMeterDB,
            &levelMeter,
            &propertySize)
        if levelMeter.mPeakPower >= -20{
            //閾値を超えたらアラート表示等の実装 *1
        }

    }
//中略
    @IBAction func didClickMuteButton(_ sender: UIButton) {
        sender.isSelected.toggle()
        agoraKit.muteLocalAudioStream(sender.isSelected)
        if sender.isSelected == true{
            self.startUpdatingVolume() //*2
        }else{
            self.stopUpdatingVolume() //*3
        }
    }
}
```
*1でボリュームを取得し、閾値を超えた場合に警告を出す等の実装をするイメージになります。
閾値は最適な値を独自で検討していただけたらと思います。
*2でiOSの標準的なAPIでマイク入力を取得開始します。
*3ではミュート解除後に再度Agora SDKのAPIを利用してマイクキャプチャとパブリッシュを再開させます。

## 補足
各OSで他にもマイクの入力検知方法があるかと思います。この記事で紹介している方法はあくまで一つの案であり、他にも良い方法があるかもしれません。
また、閾値についてもアプリ毎に最適な数値があると考えられます。このあたりは適宜調整していただけらと思います。

## 最後に
[**agora.ioに関するお問い合わせはこちらから**](https://jp.vcube.com/lp/agora?utm_campaign=Agora&utm_source=Qiita&arc_id=1802eb6c2cc4374f4f0b)
[<img width="912" alt="Agoraの詳細はこちら" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300377/5865441c-9d31-c702-3698-3aeb84927f28.png">](https://jp.vcube.com/lp/agora?utm_campaign=Agora&utm_source=Qiita&arc_id=1802eb6c2cc4374f4f0b)