[話者分離] 最強の双子 vs AmiVoice 話者ダイアライゼーション

## 話者ダイアライゼーション
話者ダイアライゼーション機能は、複数の人が話している音声から、どの部分を誰が話しているかを識別する機能です。
会議での議事録作成や書紀などに応用が考えられますね。
Speech to textのAIやAPIは色々あるけど、話者分離をする機能を標準提供しているものは少ないので、これを使ってみようと思います。

https://docs.amivoice.com/amivoice-api/manual/user-guide/function/speaker-diarization/

## 双子の声
私には一卵性の双子の娘がいます。
双子姉と妹の声質は非常に似ていて、 **親でもどっちが話しているのかわからない** ときもあります。
ただ、わずかに差があり、姉に比べて妹のほうが少し低音という感じです。
このわずかな違いを見分けられるのか、話者ダイアライゼーションを試してみることにしました。

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/5795a456-b409-f4d6-467e-9c1b8237e206.png" />

## 環境
本当は指向性マイクがあれば良かったんですが・・・私のスマホのGoogle Pixel 6aにて録音を行い、そのファイルを読み込ませることにしました。
双子だけだと面白みがないので、遺伝的のつながりがある妻の音声も入れて3人での話者分離をさせることにしました。

## 使用するPythonスクリプト
こんな感じのPythonスクリプトを作りました。
同期/非同期で使える調整パラメータが変わるので、せっかくなのでそのへんも選べるようにして、どのような変化があるのか見られるようにしました。
非同期の場合は2秒間隔でジョブの状態をチェックするようにしています。

```python
import requests
import argparse
import json
import time
from dotenv import load_dotenv
import os

# 環境変数の読み込み
load_dotenv()


def check_job_status(session_id, api_key):
    url = f"https://acp-api-async.amivoice.com/v1/recognitions/{session_id}"
    headers = {"Authorization": f"Bearer {api_key}"}
    while True:
        response = requests.get(url, headers=headers)
        if response.status_code != 200:
            print("Error checking job status:", response.text)
            break
        data = response.json()
        if data["status"] in ["completed", "error"]:
            print(f"Job {data['status']}:")
            print(json.dumps(data, indent=4, ensure_ascii=False))
            break
        else:
            print("Job status:", data["status"])
            time.sleep(2)


def amivoice_api(args):
    api_key = os.getenv("API_KEY")
    if not api_key:
        print("APIキーが設定されていません。.envファイルを確認してください。")
        return

    if args.use_async:
        url = "https://acp-api-async.amivoice.com/v1/recognitions"
        diarization_settings = f"speakerDiarization=True diarizationMinSpeaker={args.diarization_min_speaker} diarizationMaxSpeaker={args.diarization_max_speaker}"
    else:
        url = "https://acp-api.amivoice.com/v1/recognize"
        diarization_settings = f"segmenterProperties=useDiarizer=1 diarizerAlpha={args.diarizer_alpha} diarizerTransitionBias={args.diarizer_transition_bias}"

    params = {
        "u": api_key,
        "d": f"grammarFileNames=-a-general {diarization_settings}",
    }

    # 音声ファイルを読み込む
    files = {"a": open(args.audio_file, "rb")}

    # POSTリクエストを送信
    response = requests.post(url, files=files, data=params)

    # レスポンスを確認
    if response.status_code == 200:
        print("Response from AmiVoice:")
        data = response.json()
        print(json.dumps(data, indent=4, ensure_ascii=False))
        if args.use_async:
            # ジョブの状態を確認する
            session_id = data.get("sessionid")
            if session_id:
                check_job_status(session_id, api_key)
    else:
        print("Error:", response.status_code)


def diarization():
    parser = argparse.ArgumentParser(
        description="AmiVoice APIを使用して話者の識別を行います。"
    )
    parser.add_argument("audio_file", help="音声ファイルのパス")
    parser.add_argument(
        "--diarizer-alpha",
        default="1",
        help="新しい話者の出現のしやすさ\n大きな値を指定するほど新規話者が出現しやすくなり、小さな値を指定するほど新規話者が出現しづらくなる（デフォルト：1、同期APIのみ）",
    )
    parser.add_argument(
        "--diarizer-transition-bias",
        default="1e-40",
        help="話者の切り替わりやすさ\n大きな値を指定するほど話者が切り替わりやすくなり、小さな値を指定するほど話者が切り替わりづらくなる（デフォルト：1e-40、同期APIのみ）",
    )
    parser.add_argument(
        "--diarization-min-speaker",
        type=int,
        default=2,
        help="最小話者数（デフォルト：2、非同期APIのみ）",
    )
    parser.add_argument(
        "--diarization-max-speaker",
        type=int,
        default=3,
        help="最大話者数（デフォルト：3、非同期APIのみ）",
    )
    parser.add_argument(
        "--use-async",
        action="store_true",
        help="非同期APIを使用",
    )

    args = parser.parse_args()

    amivoice_api(args)

```
ヘルプは以下
```
$ rye run diarization --help                       
usage: diarization [-h] [--diarizer-alpha DIARIZER_ALPHA] [--diarizer-transition-bias DIARIZER_TRANSITION_BIAS] [--diarization-min-speaker DIARIZATION_MIN_SPEAKER] [--diarization-max-speaker DIARIZATION_MAX_SPEAKER] [--use-async] audio_file

AmiVoice APIを使用して話者の識別を行います。

positional arguments:
  audio_file            音声ファイルのパス

options:
  -h, --help            show this help message and exit
  --diarizer-alpha DIARIZER_ALPHA
                        新しい話者の出現のしやすさ 大きな値を指定するほど新規話者が出現しやすくなり、小さな値を指定するほど新規話者が出現しづらくなる（デフォルト：1、同期APIのみ）
  --diarizer-transition-bias DIARIZER_TRANSITION_BIAS
                        話者の切り替わりやすさ 大きな値を指定するほど話者が切り替わりやすくなり、小さな値を指定するほど話者が切り替わりづらくなる（デフォルト：1e-40、同期APIのみ）
  --diarization-min-speaker DIARIZATION_MIN_SPEAKER
                        最小話者数（デフォルト：2、非同期APIのみ）
  --diarization-max-speaker DIARIZATION_MAX_SPEAKER
                        最大話者数（デフォルト：3、非同期APIのみ）
  --use-async           非同期APIを使用
```
`rye` を使用して、以下のようなコマンドで実行できるようにしました
```
# 同期の場合
$ rye run diarization path_to_your_audio_file.wav
# 非同期の場合
$ rye run diarization path_to_your_audio_file.wav --async --diarization-min-speaker 3 --diarization-max-speaker 3
```

## 録音データの採取
以下のパターンを、それぞれ2回録音しました

### パターン1
| 話者 | 言葉 |
|:-:|:-:|
| 姉 :raising_hand: | おはようございます |
| - | <1秒あける> |
| 姉:raising_hand: | こんにちは |
| - | <1秒あける> |
| 姉 :raising_hand: | こんばんは |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | おはようございます |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | こんにちは |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | こんばんは |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | おはようございます |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | こんにちは |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | こんばんは |

### パターン2
| 話者 | 言葉 |
|:-:|:-:|
| 姉 :raising_hand: | おはようございます |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | おはようございます |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | おはようございます |
| - | <1秒あける> |
| 姉 :raising_hand: | こんにちは |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | こんにちは |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | こんにちは |
| - | <1秒あける> |
| 姉 :raising_hand: | こんばんは |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | こんばんは |
| - | <1秒あける> |
| 妻 :bride_with_veil_tone1: | こんばんは |

### パターン3
姉->妹の順だったので逆にし、2回つづけて同じ言葉を言ってみる
| 話者 | 言葉 |
|:-:|:-:|
| 妹 :raising_hand_tone1: | おはようございます |
| - | <1秒あける> |
| 妹 :raising_hand_tone1: | おはようございます |
| - | <1秒あける> |
| 姉 :raising_hand: | おはようございます |
| - | <1秒あける> |
| 姉 :raising_hand: | おはようございます |

### パターン4
間隔をあけずに、つなげるパターン
| 話者 | 言葉 |
|:-:|:-:|
| 妹 :raising_hand_tone1: | おはよう |
| 姉 :raising_hand: | ございます |
| - | <1秒あける> |
| 姉 :raising_hand: | おはよう |
| 妹 :raising_hand_tone1: | ございます |

## いざ勝負
まずはデフォルトパラメータで試してみます
### パターン1
| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>すべて同じ話者として認識された |
| 録音データ① | 非同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |
| 録音データ② | 非同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |

### パターン2
| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>ほぼ判定できていない。話者をラベリングする `label` 属性自体が無いデータがほとんどになってしまった・・・ |
| 録音データ② | 同期 | :x:<br>ほぼ判定できていない。話者をラベリングする `label` 属性自体が無いデータがほとんどになってしまった・・・ |
| 録音データ① | 非同期 | :x:<br>双子は同じ話者、双子妹と妻が同じ話者となっている部分がある |
| 録音データ② | 非同期 | :x:<br>双子は同じ話者、双子姉と妻が同じ話者となっている部分がある |

### パターン3
| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ① | 非同期 | :ok_hand_tone1:<br>録音データ通りに認識された |
| 録音データ② | 非同期 | :ok_hand_tone1:<br>録音データ通りに認識された |

### パターン4
| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ① | 非同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ② | 非同期 | :x:<br>全てが同じ話者として認識された |

### 結果
なかなか厳しい結果になりました。:sweat:
声が似てるとやっぱり厳しそう。
また、同期APIよりも非同期APIのほうが精度が高そう。
ただ、同期APIはパラメータ調整できるので、次はそちらを試してみます。

## 同期API パラメータ調整
### パターン1
デフォルトでは、双子が同一話者、妻は別として認識されていました。
`--diarizer-alpha 1e10` として実行してみます。

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |

`1e10`ではまだ足りないのかな
では最大の`--diarizer-alpha 1e50`でやってみます

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |

ん〜 `diarizerAlpha` 側のパラメータだけでは無理なのかも
`diarizerTransitionBias` 側のデフォルトが `1e-40` であり、切り替わりづらいと思うので、こちらも調整していきます。
切り替わりやすさを最大にした`--diarizer-alpha 1e50 --diarizer-transition-bias 1e-10` の組み合わせで試してみます。

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>妻は認識できているが双子は同じ話者として認識された |
| 録音データ② | 同期 | :white_flower:<br>妻は認識できている。双子は・・・おしい |

#### パターン1 録音データ②の応答詳細
```json
"tokens": [
    {
        "written": "おはようございます",
        "confidence": 0.98,
        "starttime": 1760,
        "endtime": 3376,
        "spoken": "おはようございます",
        "label": "speaker1"
    },
    {
        "written": "。",
        "confidence": 0.86,
        "starttime": 3376,
        "endtime": 3488,
        "spoken": "_",
        "label": "speaker1"
    },
    {
        "written": "こんにちは",
        "confidence": 1.0,
        "starttime": 4238,
        "endtime": 5086,
        "spoken": "こんにちは",
        "label": "speaker2"
    },
    {
        "written": "こんばんは",
        "confidence": 1.0,
        "starttime": 6204,
        "endtime": 7036,
        "spoken": "こんばんは",
        "label": "speaker2"
    },
    {
        "written": "おはようございます",
        "confidence": 1.0,
        "starttime": 8190,
        "endtime": 9470,
        "spoken": "おはようございます",
        "label": "speaker2"
    },
    {
        "written": "。",
        "confidence": 0.88,
        "starttime": 10142,
        "endtime": 10238,
        "spoken": "_",
        "label": "speaker2"
    },
    {
        "written": "こんにちは",
        "confidence": 1.0,
        "starttime": 10238,
        "endtime": 11022,
        "spoken": "こんにちは",
        "label": "speaker2"
    },
    {
        "written": "こんばんは",
        "confidence": 1.0,
        "starttime": 11920,
        "endtime": 12656,
        "spoken": "こんばんは",
        "label": "speaker2"
    },
    {
        "written": "おはようございます",
        "confidence": 0.99,
        "starttime": 13836,
        "endtime": 15180,
        "spoken": "おはようございます",
        "label": "speaker3"
    },
    {
        "written": "。",
        "confidence": 0.89,
        "starttime": 15180,
        "endtime": 15388,
        "spoken": "_",
        "label": "speaker3"
    },
    {
        "written": "こんにちは",
        "confidence": 1.0,
        "starttime": 15872,
        "endtime": 16768,
        "spoken": "こんにちは",
        "label": "speaker3"
    },
    {
        "written": "こんばんは",
        "confidence": 1.0,
        "starttime": 17770,
        "endtime": 18522,
        "spoken": "こんばんは",
        "label": "speaker3"
    }
]
```
双子姉が話している「おはようございます」は認識されました。
双子姉が話している「こんにちは」「こんばんは」が妹として認識されてしまいました。
パラメータ調整により結構精度が上がった感じです :+1:

### パターン2
パターン1で顕著な改善が見られた、`--diarizer-alpha 1e50 --diarizer-transition-bias 1e-10` の組み合わせでいきます。

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>ほぼ判定できていない。話者をラベリングする `label` 属性自体が無いデータがほとんどになってしまった・・・ |
| 録音データ② | 同期 | :x:<br>ほぼ判定できていない。話者をラベリングする `label` 属性自体が無いデータがほとんどになってしまった・・・ |

#### パターン2 録音データ①の応答詳細

パターン2での応答がおかしい。
相変わらず `label` の属性自体が欠損しているデータが多い。

```json
{
    "written": "おはようございます",
    "confidence": 0.93,
    "starttime": 1792,
    "endtime": 3264,
    "spoken": "おはようございます",
    "label": "speaker0"
},
{
    "written": "。",
    "confidence": 0.79,
    "starttime": 3264,
    "endtime": 3488,
    "spoken": "_",
    "label": "speaker0"
},
{
    "written": "おはようございます",
    "confidence": 1.0,
    "starttime": 3796,
    "endtime": 5204,
    "spoken": "おはようございます"
},
{
    "written": "。",
    "confidence": 0.9,
    "starttime": 5204,
    "endtime": 5444,
    "spoken": "_"
},
{
    "written": "おはようございます",
    "confidence": 1.0,
    "starttime": 5854,
    "endtime": 7134,
    "spoken": "おはようございます"
},
```
他と比べて`label`属性が欠損したり、ちょっとおかしい

### パターン3

`--diarizer-alpha 1e50 --diarizer-transition-bias 1e-10` の組み合わせで。

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |

### パターン4

こちらも`--diarizer-alpha 1e50 --diarizer-transition-bias 1e-10` の組み合わせで。

| 使用データ | API | 結果 |
|:-:|:-:|:-:|
| 録音データ① | 同期 | :x:<br>全てが同じ話者として認識された |
| 録音データ② | 同期 | :x:<br>全てが同じ話者として認識された |

## 結果&まとめ

* 同期APIで、`diarizerAlpha`や`diarizerTransitionBias`パラメータ調整をすることで、認識精度が向上する可能性があることが確認された。
  ただ、この調整がすべてのパターンで効果的とは限らない。
* 非同期APIは細かいパラメータ調整ができないが、一部のパターンで正確に話者を識別することができた。

話者ダイアライゼーションは、音声が似ている場合には特に難易度が高いことがわかりました。
技術的な限界と、APIの選択やパラメータの調整が重要であることが分かりました。

この記事が話者ダイアライゼーションの理解に役立ち、技術の可能性をさらに探るきっかけになればと思います。

---

AmiVoice様の「音声認識APIを使ってみよう!」投稿キャンペーンで、AmiVoice賞いただきました :bow_tone1:

https://zine.qiita.com/event/202406-advanced-media/