見出し画像

ローカル環境で音声・動画ファイルの文字起こし&話者分離

※ この記事は、2024年08月12日にQiitaで投稿した記事を移転したものです。

なぜローカル環境で文字起こしをするのか?

昨今、文字起こしが簡単にできるアプリやWebAPIが様々な企業から提供されているが、処理できる音声の時間に制限があったり、有料であることが多い。
そこで、ローカル環境で実行できるPythonのライブラリが役に立つ。
いつ、誰が、何を話したのかを保存しておけば、会議等の内容を遡る際に役立つし、生成AIで要約文を作成することもできる。
筆者は普段Teamsを使っているのだが、付属の文字起こし機能の精度がイマイチだと感じる(2024年8月時点)。
今後の精度向上に期待しつつも、一旦は自前でコードを書いてみた。


処理の概要

  • メディアファイル(音声又は動画)が対象

  • OpenAIが提供しているWhisperモデルを使って文字起こしする

  • pyannoteモデルを使って話者分離(話者認識)をする

  • 文字起こしと話者分離の結果を結合する

  • 結合した結果をCSV、JSON、Markdown形式で保存する


成果物のイメージ


筆者の環境

  • macOS version14.5

  • Python3.12


GitHub


コード以外で必要なもの

  • メディアファイル(音声又は動画)を用意する

  • 話者分離も行う場合は、HuggingFaceのトークンを用意する(手順については後述)

  • GPU搭載のPCがあればVeryGood(筆者の家にはない)


ネタバレ(コピペ用)

# transcription.py

import json
import math
import mimetypes
import os
from datetime import timedelta
from typing import Iterable

import pandas as pd
import torch
import torchaudio
from faster_whisper import WhisperModel
from faster_whisper.transcribe import Segment
from moviepy.editor import VideoFileClip
from pyannote.audio import Pipeline
from pyannote.core import Annotation
from torch import Tensor


class Transcription:
    """
    メディアファイル(音声又は動画)から文字起こしや話者分離を行うクラス

    Attributes:
        media_file_path (str): メディアファイルのパス
        transcriptions (list[dict]): 文字起こし結果を格納するリスト
        speaker_segments (list[dict]): 話者分離の結果を格納するリスト
        merged_results (list[dict]): 文字起こしと話者分離の結果を結合した後に格納するリスト
    """

    def __init__(self, media_file_path: str) -> None:
        """
        クラスの初期化メソッド

        Args:
            media_file_path (str): メディアファイルのパス
        """
        self.media_file_path: str = media_file_path
        self.transcriptions: list[dict] = []
        self.speaker_segments: list[dict] = []
        self.merged_results: list[dict] = []

    def is_video(self) -> bool:
        """
        メディアファイルが動画か否か判定する

        Returns:
            bool: 動画ファイルであればTrue、そうでなければFalse
        """
        mime_type: str | None = mimetypes.guess_type(self.media_file_path)[0]

        if not mime_type:
            return False

        return mime_type.startswith("video")

    def convert_video_to_audio(self) -> None:
        """
        動画ファイルを音声ファイルに変換し、mp3形式で保存する
        """
        video: VideoFileClip = VideoFileClip(self.media_file_path)
        output_path: str = os.path.splitext(self.media_file_path)[0] + ".mp3"

        video.audio.write_audiofile(output_path)
        self.media_file_path = output_path

    def transcribe_audio(self, model_size: str = "medium") -> None:
        """
        音声ファイルを文字起こしする

        Args:
            model_size (str): Whisperモデルのサイズ("tiny", "base", "small", "medium", "large")
        """
        device: str = ""
        compute_type: str = ""
        model: WhisperModel = None
        segments: Iterable[Segment] = None

        if torch.cuda.is_available():
            device = "cuda"
            compute_type = "float16"
        else:
            device = "cpu"
            compute_type = "int8"

        model = WhisperModel(model_size, device=device, compute_type=compute_type)
        segments, _ = model.transcribe(self.media_file_path, language="ja")
        self.transcriptions = []

        for segment in segments:
            item: dict = {
                "start_time": segment.start,
                "end_time": segment.end,
                "text": segment.text,
            }
            self.transcriptions.append(item)

    def diarize_audio(self, hugging_face_token: str) -> None:
        """
        音声ファイルを話者分離する
        モデルを認証する際にHugging Faceのトークンが必要になる
        トークンの発行方法
        1. HuggingFace(https://huggingface.co/)のアカウントを作る
        2. pyannoteのモデルの利用申請を行う
          - https://huggingface.co/pyannote/speaker-diarization-3.1
          - https://huggingface.co/pyannote/segmentation-3.0
        3. アクセストークンを発行する(https://huggingface.co/settings/tokens)

        Args:
            hugging_face_token (str): HuggingFaceのアクセストークン
        """
        pipeline: Pipeline = Pipeline.from_pretrained(
            "pyannote/speaker-diarization-3.1", use_auth_token=hugging_face_token
        )
        diarization: Annotation = None

        if torch.cuda.is_available():
            audio: tuple[Tensor, int] = torchaudio.load(self.media_file_path)

            pipeline.to(torch.device("cuda"))
            diarization = pipeline({"waveform": audio[0], "sample_rate": audio[1]})
        else:
            diarization = pipeline(self.media_file_path)

        self.speaker_segments = []

        for segment, _, speaker in diarization.itertracks(yield_label=True):
            item: dict = {
                "start_time": segment.start,
                "end_time": segment.end,
                "speaker": speaker,
            }
            self.speaker_segments.append(item)

    def __format_seconds_to_hhmmss(self, seconds: float) -> str:
        """
        秒数の小数点以下を切り捨て、"hh:mm:ss"形式の文字列に変換する

        Args:
            seconds (float): 秒数。

        Returns:
            str: "hh:mm:ss" 形式の文字列
        """
        temp_seconds: int = math.floor(seconds)
        return str(timedelta(seconds=temp_seconds))

    def merge_results(self) -> None:
        """
        文字起こしと話者分離の結果を時間軸に基づいて結合する
        """
        i: int = 0
        j: int = 0

        self.merged_results = []

        while i < len(self.transcriptions) and j < len(self.speaker_segments):
            tr_start: float = float(self.transcriptions[i]["start_time"])
            tr_end: float = float(self.transcriptions[i]["end_time"])
            sp_start: float = float(self.speaker_segments[j]["start_time"])
            sp_end: float = float(self.speaker_segments[j]["end_time"])

            if tr_start < sp_end and tr_end > sp_start:
                item: dict = {
                    "start_time": self.__format_seconds_to_hhmmss(tr_start),
                    "end_time": self.__format_seconds_to_hhmmss(tr_end),
                    "speaker": self.speaker_segments[j]["speaker"],
                    "text": self.transcriptions[i]["text"],
                }

                self.merged_results.append(item)
                i += 1
            elif tr_end <= sp_start:
                i += 1
            else:
                j += 1

    def export_results_to_csv(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をCSV形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        df: pd.DataFrame = pd.DataFrame(self.merged_results)
        df.to_csv(file_path, index=False, encoding=encoding)

    def export_results_to_json(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をJSON形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        with open(file_path, "w", encoding=encoding) as file:
            json.dump(self.merged_results, file, indent=4, ensure_ascii=False)

    def __format_values_to_md_table_row(self, values: list[str]) -> str:
        """
        リストの値をMarkdownのテーブル行の形式に変換する

        Args:
            values (list[str]): テーブル行に含める値のリスト

        Returns:
            str: 変換したMarkdownテーブル行
        """
        return f"| {' | '.join(values)} |"

    def export_results_to_md(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をMarkdown形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        col_names: list[str] = self.merged_results[0].keys()
        separators: list[str] = ["---"] * len(col_names)
        header_row: str = self.__format_values_to_md_table_row(col_names)
        separator_row: str = self.__format_values_to_md_table_row(separators)
        rows: list[str] = [header_row, separator_row]

        for merged_result in self.merged_results:
            row: str = ""
            values: list[str] = []

            for col_name in col_names:
                values.append(str(merged_result[col_name]))

            row = self.__format_values_to_md_table_row(values)
            rows.append(row)

        with open(file_path, "w", encoding=encoding) as file:
            file.write("\n".join(rows))
# app.py

import os
from datetime import datetime

from dotenv import load_dotenv

from libs.transcription import Transcription

load_dotenv()

HUGGING_FACE_TOKEN = os.environ["HUGGING_FACE_TOKEN"]

if __name__ == "__main__":
    prompt: str = "音声ファイル又は動画ファイルの名称を入力してください(拡張子あり): "
    media_dir_path: str = "./assets/media"
    media_file_name: str = input(prompt)
    media_file_path: str = f"{media_dir_path}/{media_file_name}"
    export_dir_path: str = "./assets/texts"
    export_file_path: str = ""
    prefix: str = ""
    transcription: Transcription = Transcription(media_file_path)
    is_video: bool = transcription.is_video()

    if is_video:
        transcription.convert_video_to_audio()

    transcription.transcribe_audio("large-v3")
    transcription.diarize_audio(HUGGING_FACE_TOKEN)
    transcription.merge_results()

    prefix = datetime.now().strftime("%Y%m%d-%H%M%S")
    export_file_path = f"{export_dir_path}/{prefix}_result"

    transcription.export_results_to_csv(f"{export_file_path}.csv")
    transcription.export_results_to_json(f"{export_file_path}.json")
    transcription.export_results_to_md(f"{export_file_path}.md")

    if is_video:
        os.remove(transcription.media_file_path)

    print("処理が完了しました")
# .env

HUGGING_FACE_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"


ディレクトリ構成

筆者のディレクトリ構成は以下のとおり。

.venv/
assets/
  ├─ media/
  └─ texts/
src/
  ├─ libs/
  │   ├─ transcription.py
  ├─ app.py
.env
.flake8
.gitignore
poetry.lock
pyproject.toml
README.md

この中で最低限必要なものは以下のとおり。


Step0:必要なライブラリをインポート

# poetry派の人はこちら
$ poetry add moviepy faster-whisper pyannote-audio python-dotenv

# pip派の人はこちら
$ pip install moviepy faster-whisper pyannote-audio python-dotenv
# transcription.py

import json
import math
import mimetypes
import os
from datetime import timedelta
from typing import Iterable

import pandas as pd
import torch
import torchaudio
from faster_whisper import WhisperModel
from faster_whisper.transcribe import Segment
from moviepy.editor import VideoFileClip
from pyannote.audio import Pipeline
from pyannote.core import Annotation
from torch import Tensor


Step1:Classを作成する

必ずしもClassが必要というわけではないが、文字起こしの結果と話者分離の結果を後から結合するので、インスタンス変数を使うのがスマートだと思う。

# transcription.py

class Transcription:
    """
    メディアファイル(音声又は動画)から文字起こしや話者分離を行うクラス

    Attributes:
        media_file_path (str): メディアファイルのパス
        transcriptions (list[dict]): 文字起こし結果を格納するリスト
        speaker_segments (list[dict]): 話者分離の結果を格納するリスト
        merged_results (list[dict]): 文字起こしと話者分離の結果を結合した後に格納するリスト
    """

    def __init__(self, media_file_path: str) -> None:
        """
        クラスの初期化メソッド

        Args:
            media_file_path (str): メディアファイルのパス
        """
        self.media_file_path: str = media_file_path
        self.transcriptions: list[dict] = []
        self.speaker_segments: list[dict] = []
        self.merged_results: list[dict] = []


Step2:動画ファイルを音声ファイルに変換するメソッドを作成する

文字起こし(Whisper)は動画ファイルのままでも動くが、話者分離(pyannote)は音声ファイルでないと動かない。
よって、与えられたメディアファイルが動画か否か判定し、動画の場合は音声ファイルに変換する必要がある。
MIMEタイプという「ファイルの種類を表す情報」を確認し、それが「video●●」であれば動画と判定する

    def is_video(self) -> bool:
        """
        メディアファイルが動画か否か判定する

        Returns:
            bool: 動画ファイルであればTrue、そうでなければFalse
        """
        mime_type: str | None = mimetypes.guess_type(self.media_file_path)[0]

        if not mime_type:
            return False

        return mime_type.startswith("video")

    def convert_video_to_audio(self) -> None:
        """
        動画ファイルを音声ファイルに変換し、mp3形式で保存する
        """
        video: VideoFileClip = VideoFileClip(self.media_file_path)
        output_path: str = os.path.splitext(self.media_file_path)[0] + ".mp3"

        video.audio.write_audiofile(output_path)
        self.media_file_path = output_path


Step3:文字起こしをするメソッドを作成する

OpenAIから提供されているピュアなWhisperを使ってもよいが、筆者は速度向上版のFasterWhisperを使用している(最新のモデル「large-v3」にも対応しているので)。
GPU搭載PCを持っている人であれば、どちらを選んでも大差ないと思う。
また、途中のif文にて、GPUの有無によってパラメータを変えている。
計算精度(compute_type)は、float32 > float16 > int8 であるが、高いものほどPCのメモリを消費する。
実用性を考慮すると、float16かint8で充分だと思う。

    def transcribe_audio(self, model_size: str = "medium") -> None:
        """
        音声ファイルを文字起こしする

        Args:
            model_size (str): Whisperモデルのサイズ("tiny", "base", "small", "medium", "large")
        """
        device: str = ""
        compute_type: str = ""
        model: WhisperModel = None
        segments: Iterable[Segment] = None

        if torch.cuda.is_available():
            device = "cuda"
            compute_type = "float16"
        else:
            device = "cpu"
            compute_type = "int8"

        model = WhisperModel(model_size, device=device, compute_type=compute_type)
        segments, _ = model.transcribe(self.media_file_path, language="ja")
        self.transcriptions = []

        for segment in segments:
            item: dict = {
                "start_time": segment.start,
                "end_time": segment.end,
                "text": segment.text,
            }
            self.transcriptions.append(item)


Step4:話者分離を行うメソッドを作成する

話者分離を行う際、モデルを認証するためにHugging Faceのトークンが必要になる。
トークンの発行方法は以下のとおり(10分程度で終わる)。

  1. HuggingFaceのアカウントを作る

  2. pyannoteのモデルのうち、以下の2つの利用申請を行う

    • speaker-diarization-3.1

    • segmentation-3.0
      ※ 直接呼び出すのは「speaker-diarization-3.1」のみだが、内部では「segmentation-3.0」も使用しているため、それぞれの利用申請が必要である

  3. 設定画面でアクセストークンを発行する

    def diarize_audio(self, hugging_face_token: str) -> None:
        """
        音声ファイルを話者分離する
        モデルを認証する際にHugging Faceのトークンが必要になる
        トークンの発行方法
        1. HuggingFace(https://huggingface.co/)のアカウントを作る
        2. pyannoteのモデルの利用申請を行う
          - https://huggingface.co/pyannote/speaker-diarization-3.1
          - https://huggingface.co/pyannote/segmentation-3.0
        3. アクセストークンを発行する(https://huggingface.co/settings/tokens)

        Args:
            hugging_face_token (str): HuggingFaceのアクセストークン
        """
        pipeline: Pipeline = Pipeline.from_pretrained(
            "pyannote/speaker-diarization-3.1", use_auth_token=hugging_face_token
        )
        diarization: Annotation = None

        if torch.cuda.is_available():
            audio: tuple[Tensor, int] = torchaudio.load(self.media_file_path)

            pipeline.to(torch.device("cuda"))
            diarization = pipeline({"waveform": audio[0], "sample_rate": audio[1]})
        else:
            diarization = pipeline(self.media_file_path)

        self.speaker_segments = []

        for segment, _, speaker in diarization.itertracks(yield_label=True):
            item: dict = {
                "start_time": segment.start,
                "end_time": segment.end,
                "speaker": speaker,
            }
            self.speaker_segments.append(item)


Step5:文字起こしと話者分離の結果を結合する

他の方のブログ記事も拝見したが、結合方法についてはやり方が様々であった。
今回は、時間軸に基づいて結合する方法を選んだ。

    def __format_seconds_to_hhmmss(self, seconds: float) -> str:
        """
        秒数の小数点以下を切り捨て、"hh:mm:ss"形式の文字列に変換する

        Args:
            seconds (float): 秒数。

        Returns:
            str: "hh:mm:ss" 形式の文字列
        """
        temp_seconds: int = math.floor(seconds)
        return str(timedelta(seconds=temp_seconds))

    def merge_results(self) -> None:
        """
        文字起こしと話者分離の結果を時間軸に基づいて結合する
        """
        i: int = 0
        j: int = 0

        self.merged_results = []

        while i < len(self.transcriptions) and j < len(self.speaker_segments):
            tr_start: float = float(self.transcriptions[i]["start_time"])
            tr_end: float = float(self.transcriptions[i]["end_time"])
            sp_start: float = float(self.speaker_segments[j]["start_time"])
            sp_end: float = float(self.speaker_segments[j]["end_time"])

            if tr_start < sp_end and tr_end > sp_start:
                item: dict = {
                    "start_time": self.__format_seconds_to_hhmmss(tr_start),
                    "end_time": self.__format_seconds_to_hhmmss(tr_end),
                    "speaker": self.speaker_segments[j]["speaker"],
                    "text": self.transcriptions[i]["text"],
                }

                self.merged_results.append(item)
                i += 1
            elif tr_end <= sp_start:
                i += 1
            else:
                j += 1
  • if節の条件式で、文字起こしの時間範囲と話者分離の時間範囲が重なっていることを確認している

    • 文字起こしデータと話者分離データを結合する

    • 次の文字起こしデータに進む

  • elif節の条件式で、文字起こしの時間範囲が話者分離の時間範囲の前にあることを確認している

    • 次の文字起こしデータに進む

  • else節まで到達するということは、文字起こしの時間範囲が話者分離の時間範囲の後にあることを意味している

    • 次の話者分離データに進む


Step6:結合した結果をファイルに保存する(CSV、JSON、Markdown形式)

どの形式で保存するかはお好みでどうぞ。
Markdownについては、テーブル形式で出力する。

    def export_results_to_csv(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をCSV形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        df: pd.DataFrame = pd.DataFrame(self.merged_results)
        df.to_csv(file_path, index=False, encoding=encoding)

    def export_results_to_json(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をJSON形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        with open(file_path, "w", encoding=encoding) as file:
            json.dump(self.merged_results, file, indent=4, ensure_ascii=False)

    def __format_values_to_md_table_row(self, values: list[str]) -> str:
        """
        リストの値をMarkdownのテーブル行の形式に変換する

        Args:
            values (list[str]): テーブル行に含める値のリスト

        Returns:
            str: 変換したMarkdownテーブル行
        """
        return f"| {' | '.join(values)} |"

    def export_results_to_md(self, file_path: str, encoding: str = "utf-8") -> None:
        """
        結合した結果をMarkdown形式で保存する

        Args:
            file_path (str): 保存先のファイルパス
            encoding (str): 保存する際の文字コード
        """
        col_names: list[str] = self.merged_results[0].keys()
        separators: list[str] = ["---"] * len(col_names)
        header_row: str = self.__format_values_to_md_table_row(col_names)
        separator_row: str = self.__format_values_to_md_table_row(separators)
        rows: list[str] = [header_row, separator_row]

        for merged_result in self.merged_results:
            row: str = ""
            values: list[str] = []

            for col_name in col_names:
                values.append(str(merged_result[col_name]))

            row = self.__format_values_to_md_table_row(values)
            rows.append(row)

        with open(file_path, "w", encoding=encoding) as file:
            file.write("\n".join(rows))


Step7:作成したClassを呼び出す

.envファイルから環境変数を読み込み、これまでに作成したClassを使用している。

# app.py

import os
from datetime import datetime

from dotenv import load_dotenv

from libs.transcription import Transcription

load_dotenv()

HUGGING_FACE_TOKEN = os.environ["HUGGING_FACE_TOKEN"]

if __name__ == "__main__":
    prompt: str = "音声ファイル又は動画ファイルの名称を入力してください(拡張子あり): "
    media_dir_path: str = "./assets/media"
    media_file_name: str = input(prompt)
    media_file_path: str = f"{media_dir_path}/{media_file_name}"
    export_dir_path: str = "./assets/texts"
    export_file_path: str = ""
    prefix: str = ""
    transcription: Transcription = Transcription(media_file_path)
    is_video: bool = transcription.is_video()

    if is_video:
        transcription.convert_video_to_audio()

    transcription.transcribe_audio("large-v3")
    transcription.diarize_audio(HUGGING_FACE_TOKEN)
    transcription.merge_results()

    prefix = datetime.now().strftime("%Y%m%d-%H%M%S")
    export_file_path = f"{export_dir_path}/{prefix}_result"

    transcription.export_results_to_csv(f"{export_file_path}.csv")
    transcription.export_results_to_json(f"{export_file_path}.json")
    transcription.export_results_to_md(f"{export_file_path}.md")

    if is_video:
        os.remove(transcription.media_file_path)

    print("処理が完了しました")


Step8:環境変数を設定する

# .env

HUGGING_FACE_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

"xxx..."は自分で発行したHuggingFaceのトークンを記載する。
発行方法については、前述の「Step4:話者分離を行うメソッドを作成する」を参照のこと。


Step9:app.pyを実行する

ルートディレクトリからapp.pyを実行する。

python ./src/app.py


2024.08.13追記

Windowsの場合、以下のエラーが発生することがある

OSError: [WinError 126] 指定されたモジュールが見つかりません。 Error loading "C:\Users\<省略>\.venv\Lib\site-packages\torch\lib\fbgemm.dll" or one of its dependencies.

この場合は、torchのバージョンを下げることで対処可能である

# torch「2.4.0」→「2.2.0」の場合
# poetry派の人はこちら
poetry remove torch
poetry add torch==2.2.0

# pip派の人はこちら
pip uninstall torch
pip install torch==2.2.0


成果物の例

"./assets/texts"ディレクトリに成果物が保存される(csv、json、mdファイル)
今回はこちらの動画を使って検証してみた。

冒頭部分だけ抜粋

話者分離についてはやや精度が甘いが、文字起こしについてはかなり精度が高いと思う。
「2、3億」が「2,000億」となっていたり、「非日常感」が「日にち情感」となっているが、それ以外は特に手直しする必要がなさそう。
文字起こしのライブラリは他にもあるが、現状はWhisperが一番精度が高いと思っている(2024年8月現在)。


番外編:生成AIを使って要約する

出力(保存)した結果を要約する場合、おすすめのサービスは以下の2つ

  • ChatGPT-4o(有料版)

  • NotionAI

ChatGPTについては、有料版であれば文字数制限が緩和されているので、そのままコピペして要約することができる(長すぎる場合は分割する必要あり)。
NotionAIについては、mdファイルの内容をNotionのページにコピペして、要約の指示を出せば、いい感じに要約してくれる。
先程の成果物(例)をNotionAIで要約した結果は以下のとおり。

## 1. スーツさんの自己紹介と成功の秘訣 (0:00 - 5:00)
交通系YouTuberのスーツさんが自己紹介。
YouTubeでの成功は趣味としてではなく、ビジネスとして取り組んだことが要因。
視聴者のニーズを理解し、それに合わせたコンテンツ作りが重要。

## 2. 人気のある旅行コンテンツについて (5:00 - 10:00)
ファーストクラスの紹介や有名観光地の紹介が人気。
しかし、スーツさんは街の歴史や成り立ちを紹介することに注力している。
これは視聴者の興味を引き、学びにもつながる。

## 3. 東京の地理と歴史について (10:00 - 20:00)
山手線や隅田川の橋について詳しく語る。
特に帝都復興計画で建てられた橋に注目し、その歴史的背景を説明。
これらの知識が旅行をより楽しくする方法として紹介。

## 4. コンテンツ作りの工夫と今後の展望 (20:00 - 34:49)
コロナ禍での東京探索経験や、同じ内容を複数回使用する戦略について言及。
今後は健康維持と社会の仕組みを理解することに注力したいと語る。
不動産との関連性も示唆される。


おわりに

お疲れ様です!結構簡単ですよね。
会議や打合せの議事メモを作るのに辟易している方は是非ご活用ください。


この記事が気に入ったらサポートをしてみませんか?