GitHub Actionsで特定のブランチから特定のブランチにアセットの差分のみを取り込むCIを構築する

初めに

今回は アセット環境における ブランチ間のアセット差分取り込みCIについて書いていきます。

今回は自動で以下のようなPRまでを自動で作ってくれるものを想定しています。

デモとして以下のRepositoryを用意しています。参考にしてください

github.com

背景

ゲーム開発等のコンテンツ制作では、アセットデータをGitHubで管理することがあります。しかし以下のような制作フローになることがあります

  • ブランチA : 正式なデータ
  • ブランチB : とりあえず入れておくデータ

この場合、ブランチAで動くことは保証できますが ブランチBはブランチAも取り込まないと動かない場合が想定されます。しかし、ブランチAのコミット履歴をそのまま取り込むと以下のような問題が起きる可能性があります

  • ブランチAとブランチBで同じファイルの差分があった場合にコンフリクトが起きる
  • ブランチBでは残したいが、ブランチAでは消したファイルが取り込みによってブランチBからも消えてしまう

やりたいこと

背景の問題があり、今回実現したいことは以下になります。

  • ブランチAとブランチBの更新差分(削除は含めない) をブランチA → ブランチBに取り込む

実現方法

今回は以下のような方法を取ります

  1. 環境をブランチBにする
  2. ブランチAのデータを取得する
  3. ブランチAのデータをブランチBに上書き保存する

Actionsでの実装

ymlファイルでは、ブランチA = main、ブランチB = testとしています。

特定のブランチの情報を取得する際に以下を実行します。このときに処理の高速化でdepth は1としています。

      - name: Fetch ${{ env.SOURCE_BRANCH }} branch
        run: |
          git fetch origin ${{ env.SOURCE_BRANCH }} --depth=1

次に特定のブランチからデータをarchiveとしてダウンロードをしてきます。このデータを上書き解凍します

      - name: Copy asset data from ${{ env.SOURCE_BRANCH }} without deleting files in ${{ env.TARGET_BRANCH }}
        run: |
          set -x
          # アセットデータのディレクトリを指定
          ASSET_DIR="."  # 必要に応じて変更してください

          # ${{ env.SOURCE_BRANCH }} ブランチからアセットデータを取得
          git archive --format=tar origin/${{ env.SOURCE_BRANCH }} $ASSET_DIR | tar -x --overwrite

最後にPRの作成をします

      - name: Create Pull Request
        uses: peter-evans/[email protected]
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "Update assets from ${{ env.SOURCE_BRANCH }} to ${{ env.TARGET_BRANCH }}"
          branch: update-assets-run-${{ github.run_id }}
          base: ${{ env.TARGET_BRANCH }}
          title: "Update assets from ${{ env.SOURCE_BRANCH }} to ${{ env.TARGET_BRANCH }}"
          body: |
            この自動生成されたプルリクエストは、`${{ env.SOURCE_BRANCH }}` ブランチから `${{ env.TARGET_BRANCH }}` ブランチにアセットデータを取り込みます。
            - `${{ env.TARGET_BRANCH }}` にのみ存在するファイルは削除されていません。
            - 競合が発生した場合、`${{ env.SOURCE_BRANCH }}` の内容が優先されています。
          labels: automated-update

これらによってブランチAからブランチBに対して更新差分だけを流してブランチBを最新にすることができるようになりました。

Actionsの設定

ActionsでPRを作成する場合、以下の設定をする必要があります。

まずは Settingを開き、Actions/General を開きます。

次に Workflow permissions を以下の画像のように設定します

genagentsを使って文化シミュレーションを行う

初めに

LLM・LLM活用アドカレ 18日目です!

genagentsは、生成的エージェントを作成・操作するためのPythonライブラリです。こちらを使って仮想環境でのNPCの一定時間内でのシミュレーションをおこなっていきます

以下で動く環境を 作成していますので、是非触ってみてください。以下の内容はforkしたリポジトリを前提に進めていきます

github.com

開発環境

セットアップ

uv venv -p 3.11 
.venv\Scripts\activate
uv pip install -r requirements.txt

OpenAIのAPIを使うため、以下の .env ファイルを ルートに作成します

OPENAI_API_KEY = "api key"
KEY_OWNER = "name"

一人のエージェントにユーザー質問をする

まずはシンプルにコードを動かしていきます。エージェントの設定を行って質問に回答してもらいます

import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from genagents.genagents import GenerativeAgent

# エージェントを初期化
agent = GenerativeAgent()

# 個人情報を更新
agent.update_scratch({
    "first_name": "太郎",
    "last_name": "山田",
    "age": 30,
    "occupation": "ソフトウェアエンジニア",
    "interests": ["読書", "ハイキング", "プログラミング"]
})

# カテゴリカルな質問をする
questions = {
    "あなたはアウトドア活動が好きですか?": ["はい", "いいえ", "時々"]
}

response = agent.categorical_resp(questions)
print(response["responses"])

# 数値的な質問をする
questions = {
    "プログラミングはどのくらい好きですか?(1から10のスケール)": [1, 10]
}

response = agent.numerical_resp(questions, float_resp=False)
print(response["responses"])

# オープンエンドの質問をする
dialogue = [
    ("インタビュアー", "あなたの好きな趣味について教えてください。"),
]

response = agent.utterance(dialogue)
print(response)

実行した結果は以下のようになります

['はい']
[9]
私の好きな趣味は読書です。特に小説を読むのが好きで、いろいろなジャンルに挑戦しています。また、ハイキングも好きで、自然の中で過ごすのは心が落ち着きます。プログラミングは仕事の一部ですが、趣味としても
新しい技術を学ぶのが楽しいです。

記憶システムを追加

以下のメソッドを使ってメモリ機能を追加します

  • agent.remember(text, time_step) : エージェントの記憶ストリームに新しい記憶を追加
  • agent.reflect(anchor, time_step) : エージェントが自身の記憶を振り返り、新たな洞察や結論を得るためのプロセスを実行
  • agent.save(path) : エージェントの現在の状態(記憶ストリーム、個人情報、内省の結果など)を保存
  • GenerativeAgent(agent_folder="saved_agents/agent1") : 保存したエージェントを再度使用

これらの機能を追加して、エージェントに対して質問を行ってみます。

# run_agent_example.py

import os
import sys

# 現在のディレクトリをモジュール検索パスに追加
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from genagents.genagents import GenerativeAgent

# ----- エージェントの作成と初期化 -----

# エージェントを初期化
agent = GenerativeAgent()

# 個人情報を更新
agent.update_scratch({
    "first_name": "太郎",
    "last_name": "山田",
    "age": 30,
    "occupation": "ソフトウェアエンジニア",
    "interests": ["読書", "ハイキング", "プログラミング"]
})

# ----- メモリの追加 -----

# エージェントにメモリを追加
agent.remember("昨日、友人と一緒に山でハイキングを楽しんだ。", time_step=1)
agent.remember("新しいプログラミング言語を学び始めた。", time_step=2)

# ----- リフレクションの実行 -----

# アウトドア活動に関するリフレクション
agent.reflect(anchor="アウトドア活動", time_step=3)

# プログラミングに関するリフレクション
agent.reflect(anchor="プログラミング", time_step=4)

# ----- エージェントとの対話 -----

# カテゴリカルな質問をする
categorical_questions = {
    "あなたはアウトドア活動が好きですか?": ["はい", "いいえ", "時々"]
}
categorical_response = agent.categorical_resp(categorical_questions)
print("カテゴリカルな質問の回答:", categorical_response["responses"])

# 数値的な質問をする
numerical_questions = {
    "プログラミングはどのくらい好きですか?(1から10のスケール)": [1, 10]
}
numerical_response = agent.numerical_resp(numerical_questions, float_resp=False)
print("数値的な質問の回答:", numerical_response["responses"])

# オープンエンドの質問をする
dialogue = [
    ("インタビュアー", "あなたの好きな趣味について教えてください。"),
]
open_ended_response = agent.utterance(dialogue)
print("オープンエンドの質問の回答:", open_ended_response)

# ----- エージェントの保存 -----

# エージェントを保存するディレクトリを指定
save_directory = "saved_agents/agent_taro"

# ディレクトリが存在しない場合は作成
if not os.path.exists(save_directory):
    os.makedirs(save_directory)

# エージェントを保存
agent.save(save_directory)
print(f"エージェントを '{save_directory}' に保存しました。")

# ----- エージェントの読み込み -----

# 保存したエージェントを読み込む
loaded_agent = GenerativeAgent(agent_folder=save_directory)
print("保存したエージェントを読み込みました。")

# 読み込んだエージェントとの対話
dialogue = [
    ("インタビュアー", "最近学んだことについて教えてください。"),
]
loaded_response = loaded_agent.utterance(dialogue)
print("読み込んだエージェントからの回答:", loaded_response)

実行結果は以下のようになります

カテゴリカルな質問の回答: ['はい']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 私は読書とハイキングが大好きです。特に、自然の中で過ごす時間が心をリフレッシュさせてくれます。最近、友人と一緒に山でハイキングを楽しんだのですが、その経験を通じて友情も深
まったと感じています。また、プログラミングも趣味の一つで、新しいスキルを学ぶのが楽しいです。
エージェントを 'saved_agents/agent_taro' に保存しました。
保存したエージェントを読み込みました。
読み込んだエージェントからの回答: 最近、新しいプログラミング言語を学び始めました。具体的には、Pythonに挑戦しています。最初は少し難しく感じましたが、実際にコードを書いてみると、だんだん楽しくなってき
ました。

複数人のエージェントに対して質問をする

次に複数人に対して質問を投げます。基本的にエージェントの設定を増やして各エージェントに対して質問を投げています

# run_multiple_agents.py

import os
import sys

# 現在のディレクトリをモジュール検索パスに追加
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from genagents.genagents import GenerativeAgent

# ----- エージェントのプロフィール作成 -----

agent_profiles = [
    {
        "first_name": "太郎",
        "last_name": "山田",
        "age": 30,
        "occupation": "ソフトウェアエンジニア",
        "interests": ["読書", "ハイキング", "プログラミング"]
    },
    {
        "first_name": "花子",
        "last_name": "佐藤",
        "age": 28,
        "occupation": "デザイナー",
        "interests": ["イラスト", "カフェ巡り", "映画鑑賞"]
    },
    {
        "first_name": "健一",
        "last_name": "高橋",
        "age": 35,
        "occupation": "教師",
        "interests": ["歴史", "旅行", "料理"]
    },
    {
        "first_name": "美咲",
        "last_name": "鈴木",
        "age": 26,
        "occupation": "看護師",
        "interests": ["音楽", "ランニング", "写真"]
    },
    {
        "first_name": "一郎",
        "last_name": "田中",
        "age": 40,
        "occupation": "営業",
        "interests": ["ゴルフ", "ワイン", "ビジネス書"]
    },
    {
        "first_name": "由美",
        "last_name": "伊藤",
        "age": 32,
        "occupation": "エディター",
        "interests": ["読書", "ヨガ", "アート"]
    },
    {
        "first_name": "直樹",
        "last_name": "渡辺",
        "age": 29,
        "occupation": "エンジニア",
        "interests": ["ゲーム", "アニメ", "プログラミング"]
    },
    {
        "first_name": "理恵",
        "last_name": "中村",
        "age": 31,
        "occupation": "マーケティング",
        "interests": ["ファッション", "SNS", "料理"]
    },
    {
        "first_name": "健二",
        "last_name": "小林",
        "age": 27,
        "occupation": "カメラマン",
        "interests": ["写真", "登山", "ドキュメンタリー"]
    },
    {
        "first_name": "美香",
        "last_name": "加藤",
        "age": 33,
        "occupation": "弁護士",
        "interests": ["映画", "ジョギング", "旅行"]
    }
]

# ----- エージェントの作成と初期化 -----

agents = []

for profile in agent_profiles:
    agent = GenerativeAgent()
    agent.update_scratch(profile)
    agents.append(agent)

# ----- メモリの追加とリフレクション -----

for agent in agents:
    # メモリの追加
    memory_text = f"{agent.scratch['first_name']}は最近、{agent.scratch['interests'][0]}に関する新しい経験をした。"
    agent.remember(memory_text, time_step=1)
    
    # リフレクションの実行
    agent.reflect(anchor=agent.scratch['interests'][0], time_step=2)

# ----- 共通の質問 -----

# カテゴリカルな質問
categorical_questions = {
    "あなたはアウトドア活動が好きですか?": ["はい", "いいえ", "時々"]
}

# 数値的な質問
numerical_questions = {
    "あなたの仕事への満足度はどのくらいですか?(1から10のスケール)": [1, 10]
}

# オープンエンドの質問
dialogue = [
    ("インタビュアー", "最近の出来事について教えてください。"),
]

# ----- 各エージェントに質問を行う -----

for idx, agent in enumerate(agents):
    print(f"\n===== エージェント {idx + 1}: {agent.scratch['first_name']} {agent.scratch['last_name']} =====")
    
    # カテゴリカルな質問の回答
    categorical_response = agent.categorical_resp(categorical_questions)
    print("カテゴリカルな質問の回答:", categorical_response["responses"])
    
    # 数値的な質問の回答
    numerical_response = agent.numerical_resp(numerical_questions, float_resp=False)
    print("数値的な質問の回答:", numerical_response["responses"])
    
    # オープンエンドの質問の回答
    open_ended_response = agent.utterance(dialogue)
    print("オープンエンドの質問の回答:", open_ended_response)

実行結果は以下になります

===== エージェント 1: 太郎 山田 =====
カテゴリカルな質問の回答: ['はい']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、新しい本を読んでみたんです。特に心理学に関する本で、他の人の考えや感情を理解する手助けになりました。それによって、周りの人との関係がより深まったと感じています。

===== エージェント 2: 花子 佐藤 =====
カテゴリカルな質問の回答: ['いいえ']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、イラストを描くことに挑戦してみたんです。自分の表現力が広がったと感じていて、特に新しい技法を使うことで、作品に深みが出てきました。カフェ巡りをしながら、インスピレー
ションを得ることも多くて、本当に楽しいです。

===== エージェント 3: 健一 高橋 =====
カテゴリカルな質問の回答: ['時々']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、地元の歴史博物館を訪れたんです。そこで、私の故郷の文化や歴史についての特別展があって、とても感動しました。自分のルーツを知ることができ、今まで以上に歴史が身近に感じ
られるようになりました。

===== エージェント 4: 美咲 鈴木 =====
カテゴリカルな質問の回答: ['はい']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、友達と一緒に音楽フェスに行ったんです。そこでたくさんのアーティストの演奏を聴いて、音楽が持つ力を改めて感じました。人々が同じメロディに合わせて踊ったり歌ったりする姿
を見て、音楽がどれだけ人を結びつけるかを実感しました。

===== エージェント 5: 一郎 田中 =====
カテゴリカルな質問の回答: ['時々']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、友人たちと一緒にゴルフをプレイする機会がありました。自分のスキルを見直す良いチャンスになったんです。特に、ショットの精度やコースマネジメントについて考える時間が増え
ました。それに、ゴルフを通じて友人たちとの絆も深まりました。

===== エージェント 6: 由美 伊藤 =====
カテゴリカルな質問の回答: ['時々']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、素晴らしい本に出会いました。それは私にとって新たな視点を提供してくれるもので、内容に深く感動しました。読書を通じて、自分の考えや感情を整理できることがとても大切だと
感じています。あなたは最近、どんな本を読みましたか?

===== エージェント 7: 直樹 渡辺 =====
カテゴリカルな質問の回答: ['いいえ']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、友人たちと一緒に新しいゲームを始めました。最初はちょっと難しかったけれど、みんなで協力しながらプレイすることで、色々な戦略やプレイスタイルを学ぶことができて、とても
楽しかったです。それに、ゲームを通じて友達との絆も深まったと感じています。

===== エージェント 8: 理恵 中村 =====
カテゴリカルな質問の回答: ['時々']
数値的な質問の回答: [7]
オープンエンドの質問の回答: 最近、ファッションに関する新しい経験をしました。新しいトレンドに触れることで、自分のスタイルを見つめ直す良い機会になったと思います。特に、個性を表現する方法が広がったと感
じています。

===== エージェント 9: 健二 小林 =====
カテゴリカルな質問の回答: ['はい']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、写真に関する新しい経験をしたんです。特に、日常の中で見過ごしがちな美しさに気づくことができて、自分の視点や感性を再発見できた気がします。そのおかげで、もっと積極的に
シャッターを切りたくなりました。

===== エージェント 10: 美香 加藤 =====
カテゴリカルな質問の回答: ['時々']
数値的な質問の回答: [8]
オープンエンドの質問の回答: 最近、映画に関する新しい経験をしました。それが私の映画の楽しみ方を広げてくれたんです。具体的には、映画の制作過程や監督の意図を理解する機会があったんです。それによって、映
画が私に与える影響や感情について深く考えるようになりました。

数年単位の複数エージェントに対しての文化シミュレーション

架空の村に住むエージェントたちの生活を100年間にわたってシミュレートするものです。エージェントは個々のプロフィール(名前、年齢、職業、興味など)を持ち、毎年発生するイベントや相互作用を通じて変化していきます。

# simulation.py

import os
import sys
import random

# 現在のディレクトリをモジュール検索パスに追加
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from genagents.genagents import GenerativeAgent

# ----- 1. エージェントの初期化 -----

agent_profiles = [
    {
        "first_name": "たかし",
        "last_name": "すずき",
        "age": 25,
        "occupation": "農民",
        "interests": ["農業", "漁業", "物語"]
    },
    {
        "first_name": "あいこ",
        "last_name": "たなか",
        "age": 23,
        "occupation": "織物師",
        "interests": ["織物", "薬草", "音楽"]
    },
    {
        "first_name": "ひろし",
        "last_name": "やまだ",
        "age": random.randint(20, 40),
        "occupation": "狩人",
        "interests": ["狩猟", "追跡", "登山"]
    },
    {
        "first_name": "ゆみ",
        "last_name": "さとう",
        "age": random.randint(20, 40),
        "occupation": "陶芸家",
        "interests": ["陶芸", "芸術", "デザイン"]
    },
    {
        "first_name": "けんた",
        "last_name": "こばやし",
        "age": random.randint(20, 40),
        "occupation": "漁師",
        "interests": ["漁業", "船作り", "水泳"]
    },
    {
        "first_name": "さくら",
        "last_name": "わたなべ",
        "age": random.randint(20, 40),
        "occupation": "大工",
        "interests": ["木工", "建築", "絵画"]
    },
    {
        "first_name": "だいち",
        "last_name": "いとう",
        "age": random.randint(20, 40),
        "occupation": "薬草師",
        "interests": ["薬草学", "園芸", "料理"]
    },
    {
        "first_name": "ゆうこ",
        "last_name": "なかむら",
        "age": random.randint(20, 40),
        "occupation": "語り部",
        "interests": ["物語", "音楽", "踊り"]
    },
    {
        "first_name": "さとし",
        "last_name": "かとう",
        "age": random.randint(20, 40),
        "occupation": "交易商",
        "interests": ["交易", "探索", "交渉"]
    },
    {
        "first_name": "めぐみ",
        "last_name": "よしだ",
        "age": random.randint(20, 40),
        "occupation": "庭師",
        "interests": ["農業", "植栽", "自然"]
    }
]

# プログラムで追加のプロフィールを生成
names = [
    ("ひろし", "やまだ"),
    ("ゆみ", "さとう"),
    ("けんた", "こばやし"),
    ("さくら", "わたなべ"),
    ("だいち", "いとう"),
    ("ゆうこ", "なかむら"),
    ("さとし", "かとう"),
    ("めぐみ", "よしだ")
]

occupations = ["狩人", "陶芸家", "漁師", "大工", "薬草師", "語り部", "交易商", "庭師"]
interests = [
    ["狩猟", "追跡", "登山"],
    ["陶芸", "芸術", "デザイン"],
    ["漁業", "船作り", "水泳"],
    ["木工", "建築", "絵画"],
    ["薬草学", "園芸", "料理"],
    ["物語", "音楽", "踊り"],
    ["交易", "探索", "交渉"],
    ["農業", "植栽", "自然"]
]

# エージェントの初期化
agents = []

for profile in agent_profiles:
    agent = GenerativeAgent()
    agent.update_scratch(profile)
    agents.append(agent)

# ----- 2. 年次シミュレーション -----

total_years = 10
current_year = 3000  # 西暦3000年からスタート
time_step = 0  # タイムステップ

for year in range(total_years):
    current_year += 1
    time_step += 1
    print(f"\n===== 年 {current_year} =====")

    # --- 集落イベント ---
    community_events = [
        "豊作に恵まれ、食料が潤沢になった。",
        "厳しい冬が訪れ、集落を試練が襲った。",
        "近隣の部族が交易に訪れた。",
        "大雨による洪水が田畑を襲った。",
        "新年を祝う祭りが開催された。",
        "地震が地域を揺るがした。"
    ]
    event = random.choice(community_events)
    print(f"集落イベント: {event}")

    # エージェントがイベントを記憶
    for agent in agents:
        agent.remember(f"{current_year}年に、{event}", time_step)

    # --- エージェントのライフイベント ---
    for agent in agents[:]:  # リストをコピーしてイテレート
        # 年齢を増やす
        agent_age = agent.scratch.get('age', 25) + 1
        agent.scratch['age'] = agent_age

        # 死亡チェック(簡易的な確率モデル)
        if agent_age > 60 and random.random() < 0.1:
            print(f"{agent.scratch['first_name']} {agent.scratch['last_name']} が {agent_age} 歳で亡くなりました。")
            agents.remove(agent)
            continue

        # 出生イベント
        if 18 <= agent_age <= 45 and random.random() < 0.3:
            # 子供を持つ可能性
            child_first_name = random.choice(["はると", "ゆい", "かいと", "はな", "そら", "まお", "れん", "みこ"])
            child_last_name = agent.scratch['last_name']
            child_profile = {
                "first_name": child_first_name,
                "last_name": child_last_name,
                "age": 0,
                "occupation": "子供",
                "interests": ["遊び", "学び"]
            }
            child_agent = GenerativeAgent()
            child_agent.update_scratch(child_profile)
            agents.append(child_agent)
            agent.remember(f"{child_first_name}という子供が生まれた。", time_step)
            print(f"{agent.scratch['first_name']} に {child_first_name} という子供が生まれました。")

        # 内省の実行
        agent.reflect(anchor="年次イベント", time_step=time_step)

    # --- エージェント間の相互作用 ---
    for i in range(len(agents)):
        for j in range(i + 1, len(agents)):
            agent_a = agents[i]
            agent_b = agents[j]

            # シンプルな対話
            if random.random() < 0.2:
                dialogue = [
                    (agent_a.scratch['first_name'], "最近どうですか?"),
                    (agent_b.scratch['first_name'], "元気です。最近の出来事は興味深いですね。"),
                ]
                agent_a.utterance(dialogue)
                # 相互に記憶
                agent_a.remember(f"{agent_b.scratch['first_name']} と会話した。", time_step)
                agent_b.remember(f"{agent_a.scratch['first_name']} と会話した。", time_step)

# ----- 3. 結果の分析 -----

print("\n===== シミュレーション完了 =====")
print(f"シミュレーション終了時のエージェント数: {len(agents)}")
for agent in agents:
    print(f"{agent.scratch['first_name']} {agent.scratch['last_name']}, 年齢: {agent.scratch['age']}, 職業: {agent.scratch['occupation']}")
    # 最近の記憶を表示
    recent_memories = agent.memory_stream[-3:]  # 最新の3つの記憶
    print("最近の記憶:")
    for memory in recent_memories:
        print(f"- {memory}")
    print()

実行結果は以下のようになります

=====3001 =====
集落イベント: 地震が地域を揺るがした。
ひろし に ゆい という子供が生まれました。
けんた に まお という子供が生まれました。
さくら に れん という子供が生まれました。

=====3002 =====
集落イベント: 新年を祝う祭りが開催された。
ゆみ に はな という子供が生まれました。
さくら に はると という子供が生まれました。

=====3003 =====
集落イベント: 厳しい冬が訪れ、集落を試練が襲った。

=====3004 =====
集落イベント: 地震が地域を揺るがした。
たかし に ゆい という子供が生まれました。
ゆみ に みこ という子供が生まれました。

=====3005 =====
集落イベント: 豊作に恵まれ、食料が潤沢になった。
あいこ に みこ という子供が生まれました。
さくら に みこ という子供が生まれました。
ゆうこ に れん という子供が生まれました。
さとし に ゆい という子供が生まれました。

=====3006 =====
集落イベント: 地震が地域を揺るがした。
けんた に はると という子供が生まれました。

=====3007 =====
集落イベント: 厳しい冬が訪れ、集落を試練が襲った。
ゆうこ に そら という子供が生まれました。

=====3008 =====
集落イベント: 大雨による洪水が田畑を襲った。
ひろし に ゆい という子供が生まれました。
けんた に かいと という子供が生まれました。

=====3009 =====
集落イベント: 地震が地域を揺るがした。
たかし に まお という子供が生まれました。
あいこ に かいと という子供が生まれました。
めぐみ に はると という子供が生まれました。

=====3010 =====
集落イベント: 厳しい冬が訪れ、集落を試練が襲った。

ゆうこ に そら という子供が生まれました。

===== シミュレーション完了 =====
シミュレーション終了時のエージェント数: 29
たかし すずき, 年齢: 35, 職業: 農民

wespeakerとxvectorの話者埋め込みモデルを使った日本語話者ダイアライゼーションの評価

初めに

音声データを文字お越しをする際に、複数人の音声が入っている場合に 「誰がいつ話したのか」を推定する技術として 話者ダイアライゼーションがあります。今回は、日本語音声において いくつかのモデルを使って比較をしていきます

過去に各モデルを動かす記事は書いていますので、参考に見てください

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

事前調査

話者ダイアライゼーションでよく使われるものは、pyannote-audioがあります。こちらは少し前に pyannote/speaker-diarization-3.1がリリースされています。この埋め込みモデルには wespeaker-voxceleb-resnet34-LMが使われているため、元になっている wespeakerのオリジナルモデルで評価を行っています

開発環境

評価データ

今回の評価データには CABank Japanese CallHome Corpusを使用しています。

このデータセットは huggingfaceのtalkbank/callhomeからダウンロードすることができます。

今回評価をするために、元のデータセットからwavデータに変換してメタデータjson化したデータセットを以下に公開しています

huggingface.co

対象のモデル

今回は以下のモデルを対象に評価を行いました

モデル名 URL
wespeaker-cnceleb-resnet34-LM ダウンロードリンク
wespeaker-voxceleb-resnet152-LM ダウンロードリンク
wespeaker-voxceleb-resnet293-LM ダウンロードリンク
wespeaker-voxceleb-resnet34-LM ダウンロードリンク
xvector_jtubespeech ダウンロードリンク

評価結果

それぞれのモデルの評価結果は以下になります。詳細はリンク先の評価データをご確認ください。

モデル名 平均DER 評価結果のリンク先
wespeaker-cnceleb-resnet34-LM 46.99% wespeaker-cnceleb-resnet34-LM-result.txt
wespeaker-voxceleb-resnet152-LM 38.72% wespeaker-voxceleb-resnet152-LM_results.txt
wespeaker-voxceleb-resnet293-LM 39.02% wespeaker-voxceleb-resnet293-LM_results.txt
wespeaker-voxceleb-resnet34-LM 39.27% wespeaker-voxceleb-resnet34-LM-result.txt
xvector_jtubespeech 48.52% xvector_jtubespeech-der-umap_results.txt

評価方法

それぞれのモデルの評価方法およびそのコードは以下になります。

フォルダ構成は以下にようになっています。

- **root/**
  - **callhome_japanese_audio/**
    - callhome_jpn_0.wav
    - callhome_jpn_1.wav
    - callhome_jpn_2.wav
    - ...
    - callhome_jpn_99.wav
    - wav.scp
  - **wespeaker-cnceleb-resnet34-LM/**
  - **wespeaker-voxceleb-resnet152-LM/**
  - **wespeaker-voxceleb-resnet293-LM/**
  - **wespeaker-voxceleb-resnet34-LM/**
  - callhome_japanese_metadata.json
  - callhome_japanese.rttm
  - predicted.rttm
  - README.md
  - requirements.txt
  - umap_clusterer.py
  - wespeacker-test.py
  - wespeaker-cnceleb-resnet34-LM-result.txt
  - wespeaker-voxceleb-resnet152-LM_results.txt
  - wespeaker-voxceleb-resnet293-LM_results.txt
  - wespeaker-voxceleb-resnet34-LM-result.txt
  - x-vector-umap-test.py
  - xvector_jtubespeech-der-umap_results.txt

以下に実際に評価を行ったRepositoryを公開しています(詳細の結果もこちらにあげています)

github.com

wespeaker

評価の流れとしては以下になります

  1. WeSpeakerモデルをロードし、デバイス(CPU/GPU)を設定する。
  2. 音声ファイルのリストを作成して準備する。
  3. 各音声ファイルに対してmodel.diarizeで話者ダイアライゼーションを実行する。
  4. ダイアライゼーション結果をRTTM形式のファイルに保存する。
  5. リファレンスのRTTMファイルを用意する(必要ならメタデータから作成)。
  6. リファレンスと予測結果のRTTMファイルを読み込み、評価用アノテーションを作成する。
  7. DiarizationErrorRateの評価指標を初期化する。
  8. 各音声ファイルについてDERを計算し、結果を記録する。
  9. 全体のDERを計算し、評価結果をファイルに保存する。

評価コードは以下になります

import os
import json
import torch
import wespeaker
from pyannote.metrics.diarization import DiarizationErrorRate
from pyannote.core import Annotation, Segment

# モデルのパスを指定
model_dir = "wespeaker-voxceleb-resnet152-LM"
model = wespeaker.load_model_local(model_dir)

# 必要に応じてデバイスを設定
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.set_device(device)

print(f"Using device: {device}")
if device.type == 'cuda':
    print(f"CUDA Device Name: {torch.cuda.get_device_name(torch.cuda.current_device())}")
else:
    print("CUDA is not available. Using CPU.")

# 音声ファイルのディレクトリ
audio_dir = 'callhome_japanese_audio'

# 音声ファイルのリストを作成
audio_files = [
    os.path.join(audio_dir, f)
    for f in os.listdir(audio_dir)
    if f.endswith('.wav') or f.endswith('.mp3')
]

# 結果を格納するリスト
all_results = []

for audio_file in audio_files:
    utt_id = os.path.splitext(os.path.basename(audio_file))[0]
    print(f"Processing {utt_id}...")
    diarization_result = model.diarize(audio_file)
    all_results.append((utt_id, diarization_result))

# 予測結果のRTTMファイルの作成
hypothesis_rttm = 'predicted.rttm'
with open(hypothesis_rttm, 'w', encoding='utf-8') as f:
    for utt_id, result in all_results:
        for segment in result:
            # segment: [開始時間, 終了時間, 話者ラベル]
            start_time = float(segment[1])
            end_time = float(segment[2])
            speaker_label = segment[3]
            duration = end_time - start_time
            f.write(f"SPEAKER {utt_id} 1 {start_time:.3f} {duration:.3f} <NA> <NA> {speaker_label} <NA> <NA>\n")

# リファレンスのRTTMファイルを作成(既に作成済みの場合はこの部分をコメントアウト可能)
json_input_path = 'callhome_japanese_metadata.json'
reference_rttm = 'callhome_japanese.rttm'
with open(json_input_path, 'r', encoding='utf-8') as f:
    metadata_list = json.load(f)
with open(reference_rttm, 'w', encoding='utf-8') as f_rttm:
    for metadata in metadata_list:
        audio_filename = metadata['audio_filename']
        uri = os.path.splitext(audio_filename)[0]
        utterances = metadata['utterances']
        for utt in utterances:
            start_time = utt['start_time']
            end_time = utt['end_time']
            duration = end_time - start_time
            speaker = utt['speaker']
            f_rttm.write(f"SPEAKER {uri} 1 {start_time:.3f} {duration:.3f} <NA> <NA> {speaker} <NA> <NA>\n")

# リファレンスと予測結果のRTTMファイルを読み込み
def load_rttm(file_path):
    annotations = {}
    with open(file_path, 'r') as f:
        for line in f:
            tokens = line.strip().split()
            uri = tokens[1]
            start_time = float(tokens[3])
            duration = float(tokens[4])
            end_time = start_time + duration
            speaker = tokens[7]
            segment = Segment(start_time, end_time)
            if uri not in annotations:
                annotations[uri] = Annotation(uri=uri)
            annotations[uri][segment] = speaker
    return annotations

reference = load_rttm(reference_rttm)
hypothesis = load_rttm(hypothesis_rttm)

metric = DiarizationErrorRate()

## 出力を保存するテキストファイルを開く
output_file = 'wespeaker-voxceleb-resnet152-LM_results.txt'
with open(output_file, 'w', encoding='utf-8') as result_f:

    # 各ファイルごとに評価
    for utt_id in reference:
        ref = reference[utt_id]
        hyp = hypothesis.get(utt_id, None)

        if hyp is None:
            result_line = f"Hypothesis for {utt_id} not found."
            
            print(result_line)
            result_f.write(result_line + '\n')
            continue

        der = metric(ref, hyp)
        result_line = f"{utt_id}: DER = {der * 100:.2f}%"
        print(result_line)
        result_f.write(result_line + '\n')

    # 全体のDERを計算
    total_der = abs(metric)
    total_result_line = f"Total DER: {total_der * 100:.2f}%"
    print(total_result_line)
    result_f.write(total_result_line + '\n')

# 結果が 'der_results.txt' に保存されます
print(f"Results saved to {output_file}")

xvector_jtubespeech

wespeakerと評価方法を揃えるために、UMAPによる次元削減およびHDBSCANによるクラスタリングを行っています

  1. x-vectorモデルをロードし、デバイス(CPU/GPU)を設定する。
  2. 処理する音声ファイルのリストを準備する。
  3. Silero VADモデルをロードして音声区間検出を準備する。
  4. 各音声ファイルに対してVADで音声区間を検出する。
  5. 検出された各音声区間からMFCCとx-vector埋め込みを抽出する。
  6. 埋め込みベクトルと対応するセグメントを収集する。
  7. UMAPで埋め込みベクトルの次元削減を行う。
  8. HDBSCANで次元削減後のベクトルをクラスタリングし、話者ラベルを割り当てる。
  9. 必要に応じてPAHCでクラスタを精錬する。
  10. ダイアライゼーション結果をRTTM形式のファイルに書き出す。
  11. リファレンスのRTTMファイルを読み込み、評価用アノテーションを作成する。
  12. DiarizationErrorRate評価指標を初期化する。
  13. 各音声ファイルについてDERを計算し、結果を記録する。
  14. 全体のDERを計算し、評価結果をファイルに保存する。
  15. 結果を出力して評価を完了する。

評価コードは以下になります

# スクリプトの最初に環境変数を設定
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
os.environ["NUMEXPR_NUM_THREADS"] = "1"
os.environ["NUMBA_NUM_THREADS"] = "1"

import json
import numpy as np
import torch
import torchaudio
from scipy.io import wavfile
from torchaudio.compliance import kaldi
from pyannote.metrics.diarization import DiarizationErrorRate
from pyannote.core import Annotation, Segment
# UMAPとHDBSCANをインポート
import umap
import hdbscan
# 必要に応じてPAHCクラスをインポートまたは定義
from wespeaker.diar.umap_clusterer import PAHC  # PAHCクラスを別途コピーして使用

from xvector_jtubespeech import XVector
from tqdm import tqdm

# 1. x-vectorモデルのロード
model = torch.hub.load("sarulab-speech/xvector_jtubespeech", "xvector", trust_repo=True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)

print(f"Using device: {device}")
if device.type == 'cuda':
    print(f"CUDA Device Name: {torch.cuda.get_device_name(torch.cuda.current_device())}")
else:
    print("CUDA is not available. Using CPU.")

# 2. 音声ファイルのディレクトリ
audio_dir = 'callhome_japanese_audio'

# 音声ファイルのリストを作成
audio_files = [
    os.path.join(audio_dir, f)
    for f in os.listdir(audio_dir)
    if f.endswith('.wav') or f.endswith('.mp3')
]

# 音声区間検出(VAD)の準備
vad_model, utils = torch.hub.load(
    repo_or_dir='snakers4/silero-vad',
    model='silero_vad',
    force_reload=False
)
(get_speech_timestamps, _, _, _, _) = utils

# VADモデルはCPU上にあることを確認
vad_model.eval()

# 予測結果のRTTMファイルを作成するためのリスト
all_results = []

for audio_file in audio_files:
    utt_id = os.path.splitext(os.path.basename(audio_file))[0]
    print(f"Processing {utt_id}...")

    # 2.1 音声の読み込み(wav は CPU 上)
    wav, sr = torchaudio.load(audio_file)
    if sr != 16000:
        resampler = torchaudio.transforms.Resample(sr, 16000)
        wav = resampler(wav)
        sr = 16000

    # 2.2 音声区間検出(VAD)の適用
    speech_timestamps = get_speech_timestamps(wav.squeeze(0), vad_model, sampling_rate=sr)
    if not speech_timestamps:
        print(f"No speech detected in {utt_id}.")
        continue

    # 3. セグメントごとにx-vectorを抽出
    embeddings = []
    segments = []
    for ts in speech_timestamps:
        start_frame = ts['start']
        end_frame = ts['end']
        segment_wav = wav[:, start_frame:end_frame]
        segment_wav_np = segment_wav.numpy().squeeze(0)

        # 3.1 MFCCの抽出
        segment_tensor = torch.from_numpy(segment_wav_np.astype(np.float32)).unsqueeze(0).to(device)
        mfcc = kaldi.mfcc(segment_tensor, num_ceps=24, num_mel_bins=24).unsqueeze(0)

        # 3.2 x-vectorの抽出
        with torch.no_grad():
            xvector = model.vectorize(mfcc)
        xvector = xvector.cpu().numpy()[0]

        embeddings.append(xvector)
        # 時間を秒に変換
        start_time = start_frame / sr
        end_time = end_frame / sr
        segments.append((start_time, end_time))

    embeddings = np.array(embeddings)

    # 4. UMAPによる次元削減
    if len(embeddings) <= 2:
        labels = [0] * len(embeddings)
    else:
        umap_embeddings = umap.UMAP(
            n_components=min(32, len(embeddings) - 2),
            metric='cosine',
            n_neighbors=16,  # 必要に応じて調整
            min_dist=0.05,   # 必要に応じて調整
            random_state=2023,
            n_jobs=1
        ).fit_transform(embeddings)

        # 5. HDBSCANによるクラスタリング
        labels = hdbscan.HDBSCAN(
            allow_single_cluster=True,
            min_cluster_size=4,
            approx_min_span_tree=False,
            core_dist_n_jobs=1
        ).fit_predict(umap_embeddings)

        # 6. PAHCによるクラスタのマージと吸収
        labels = PAHC(
            merge_cutoff=0.3,
            min_cluster_size=3,
            absorb_cutoff=0.0
        ).fit_predict(labels, embeddings)

    # 予測結果を保存
    diarization_result = []
    for (segment, label) in zip(segments, labels):
        diarization_result.append([utt_id, segment[0], segment[1], label])
    all_results.extend(diarization_result)

# 7. 予測結果のRTTMファイルの作成
hypothesis_rttm = 'predicted.rttm'
with open(hypothesis_rttm, 'w', encoding='utf-8') as f:
    for entry in all_results:
        utt_id, start_time, end_time, speaker_label = entry
        duration = end_time - start_time
        f.write(f"SPEAKER {utt_id} 1 {start_time:.3f} {duration:.3f} <NA> <NA> speaker_{speaker_label} <NA> <NA>\n")

# 以下、評価コード(リファレンスのRTTMファイルの読み込みなど)を追加

# 8. リファレンスのRTTMファイルを読み込み(ご自身のコードに合わせてください)
# 例えば:
reference_rttm = 'callhome_japanese.rttm'
def load_rttm(file_path):
    annotations = {}
    with open(file_path, 'r') as f:
        for line in f:
            tokens = line.strip().split()
            uri = tokens[1]
            start_time = float(tokens[3])
            duration = float(tokens[4])
            end_time = start_time + duration
            speaker = tokens[7]
            segment = Segment(start_time, end_time)
            if uri not in annotations:
                annotations[uri] = Annotation(uri=uri)
            annotations[uri][segment] = speaker
    return annotations

reference = load_rttm(reference_rttm)
hypothesis = load_rttm(hypothesis_rttm)

metric = DiarizationErrorRate()

# 9. 評価結果を保存するテキストファイルを開く
output_file = 'xvector_jtubespeech-der-umap_results.txt'
with open(output_file, 'w', encoding='utf-8') as result_f:

    # 各ファイルごとに評価
    for utt_id in reference:
        ref = reference[utt_id]
        hyp = hypothesis.get(utt_id, None)

        if hyp is None:
            result_line = f"Hypothesis for {utt_id} not found."
            print(result_line)
            result_f.write(result_line + '\n')
            continue

        der = metric(ref, hyp)
        result_line = f"{utt_id}: DER = {der * 100:.2f}%"
        print(result_line)
        result_f.write(result_line + '\n')

    # 全体のDERを計算
    total_der = abs(metric)
    total_result_line = f"Total DER: {total_der * 100:.2f}%"
    print(total_result_line)
    result_f.write(total_result_line + '\n')

print(f"Results saved to {output_file}")

talkbank/callhomeの日本語音声をwav形式で保存する

開発環境

セットアップ

ライブラリをインストールします

uv pip install datasets[audio] soundfile pydub 

実行

以下でデータセットをダウンロードして、wav形式で保存します

from datasets import load_dataset
import soundfile as sf  # wavファイルの保存に使用
from pydub import AudioSegment  # mp3ファイルの保存に使用
import os

# 日本語のデータセットをロード
ds = load_dataset("diarizers-community/callhome", "jpn", split='data')

# 保存先のディレクトリを指定
output_dir = "callhome_japanese_audio"
os.makedirs(output_dir, exist_ok=True)

# 音声データをループして保存
for idx, example in enumerate(ds):
    # 音声データの取得
    audio = example['audio']
    array = audio['array']
    sampling_rate = audio['sampling_rate']

    # ファイル名を作成
    filename_base = f"callhome_jpn_{idx}"

    # wavファイルとして保存
    wav_path = os.path.join(output_dir, f"{filename_base}.wav")
    sf.write(wav_path, array, sampling_rate)
    print(f"Saved WAV file: {wav_path}")

pyannote + whisperで話者ダイアライゼーションを行う

初めに

今回は定番のpyanonoteとwhisperで話者ダイアライゼーションを行ってみます

以下で記事のサンプルリポジトリを公開しています

github.com

過去にはほかのライブラリでも試しているので、ほかにどのようなライブラリがあるのか気になる場合はご覧ください

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

ayousanz.hatenadiary.jp

開発環境

セットアップ

uv でpython 3.9の環境を作ります. pyanonoteが依存している numbaが3.10以上は対応していませんでした

uv venv -p 3.9
source venv/bin/activate

必要なライブラリを入れていきます

uv pip install pyannote.audio

torchをgpu版を入れます

uv pip install torch==2.5.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu121 --force-reinstall

mp3のファイルを扱えるように 追加のライブラリを入れます

uv pip install pydub

文字お越しようにwhisperを入れます

uv pip install - U openai-whisper

実行

以下のスクリプトを実行することで話者ダイアライゼーションおよび文字起こしを行うことができます

# 必要なライブラリのインポート
from pyannote.audio import Pipeline
import whisper
import numpy as np
from pydub import AudioSegment

# 話者分離モデルの初期化
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")

# Whisperモデルのロード
model = whisper.load_model("large-v3")

# 音声ファイルを指定
audio_file = "JA_B00000_S00529_W000007.mp3"  # MP3ファイルを指定します

# 話者分離の実行
diarization = pipeline(audio_file)

# MP3ファイルをAudioSegmentで読み込む
audio_segment = AudioSegment.from_file(audio_file, format="mp3")

# 音声ファイルを16kHz、モノラルに変換
audio_segment = audio_segment.set_frame_rate(16000).set_channels(1)

# 話者分離の結果をループ処理
for segment, _, speaker in diarization.itertracks(yield_label=True):
    # 話者ごとの発話区間の音声を切り出し(ミリ秒単位)
    start_ms = int(segment.start * 1000)
    end_ms = int(segment.end * 1000)
    segment_audio = audio_segment[start_ms:end_ms]

    # 音声データをnumpy配列に変換
    waveform = np.array(segment_audio.get_array_of_samples()).astype(np.float32)

    # 音声データを[-1.0, 1.0]の範囲に正規化
    waveform = waveform / np.iinfo(segment_audio.array_type).max

    # Whisperによる文字起こし
    # 音声データをサンプリングレート16kHzに合わせて、テンソルに変換
    result = model.transcribe(waveform,fp16=False)

    # 話者ラベル付きで結果をフォーマットして出力
    for data in result["segments"]:
        start_time = segment.start + data["start"]
        end_time = segment.start + data["end"]
        print(f"{start_time:.2f},{end_time:.2f},{speaker},{data['text']}")

結果は以下になります

0.03,4.15,SPEAKER_00,物事に対しても、真っ直ぐに取り組めるような姿勢とか、

Wespeaker/wespeaker-voxceleb-resnet34-LMで話者ダイアライゼーションを行う

初めに

wespeakerで話者ダイアライゼーションを行ってみます。

モデルは以下です

huggingface.co

以下に記事の内容のRepositoryを公開しています

github.com

開発環境

セットアップ

まずは uv環境を作成します

uv venv -p 3.11
.venv\Scripts\activate 

次に必要なライブラリを入れていきます

uv pip install git+https://github.com/wenet-e2e/wespeaker.git
uv pip install PyYAML setuptools requests soundfile

torchのgpu版をインストールします

uv pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall

モデルのダウンロードを行います

huggingface-cliのインストールを行い、ログインを済ませておきます

uv pip install -U "huggingface_hub[cli]"
huggingface-cli login

以下でモデルのダウンロードを行うことができます

huggingface-cli download --repo-type model Wespeaker/wespeaker-voxceleb-resnet34-LM --local-dir ResNet34_download_dir

CLIから実行

デフォルトではwavファイルのみの対応になっているため、mp3等の場合は ffmpeg等で以下のように変換をします

ffmpeg -i JA_B00000_S00529_W000007.mp3 JA_B00000_S00529_W000007.wav

wavに変換ができたら、以下のコマンドを実行します

 wespeaker -p ResNet34_download_dir --task diarization --audio_file .\JA_B00000_S00529_W000007.wav

結果は以下のようになります

0.000   4.500   0

Pythonのコードで実行

PythonスクリプトでもCLIのコマンドと同様のことを行ってみます

以下のスクリプトを実行します

import wespeaker

# モデルのパスを指定
model_dir = 'ResNet34_download_dir'
model = wespeaker.load_model_local(model_dir)
# model.set_gpu(0)

# 音声ファイルのパスを指定
audio_file = 'JA_B00000_S00529_W000007.mp3'

# 話者ダイアリゼーションの実行
diarization_result = model.diarize(audio_file)

# 結果の表示
for segment in diarization_result:
    # segmentの内容を確認(デバッグ用)
    print(f"Segment content: {segment}, Type: {type(segment)}")
    start_time = float(segment[1])
    end_time = float(segment[2])
    speaker_label = segment[3]
    print(f"{start_time:.3f}\t{end_time:.3f}\t{speaker_label}")

結果は以下のようになります

Segment content: ('unk', 0.0, 4.5, 0), Type: <class 'tuple'>
0.000   4.500   0

Segmetの一つ目のデータは、今回必要ないため表示はしないようにしています

備考

以下のモデルのほうが日本語の精度は高いみたいです

huggingface.co

powerset_calibrationを使って話者ダイアライゼーションを行う

初めに

powerset_calibrationを使って音声内の話者ダイアライゼーションを行ってみます。論文によりデータセットには日本語が含まれていないため、日本語の音声に使う場合は自分で学習を行う必要がありそうです

github.com

以下で動かしたリポジトリを公開しています

github.com

開発環境

セットアップ

環境を作っていきます

uv venv -p 3.9
.venv\Scripts\activate

ライブラリをインストールします。リポジトリpyproject.toml が提供されているので、そのまま使っていきます

uv sync

torchがcpu版になっているので、GPU版をインストールします

uv pip install torch==2.5.1 --index-url https://download.pytorch.org/whl/cu121 --force-reinstall

話者ダイアライゼーションを実行

from pyannote.audio import Pipeline

# プリトレーニング済みモデルのロード
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization")

# 音声ファイルのパス(ご自身のファイルパスに変更してください)
AUDIO_FILE = "test.mp3"

# ダイアリゼーションの実行
diarization = pipeline(AUDIO_FILE)

# 結果の表示
for turn, _, speaker in diarization.itertracks(yield_label=True):
    print(f"start={turn.start:.1f}s stop={turn.end:.1f}s speaker_{speaker}")

上記のコードで実行をします

結果は以下のようになります

start=0.0s stop=5.0s speaker_SPEAKER_00