├── .gitignore ├── LICENSE ├── README.md ├── collect_laughter.py ├── exclude_words_default.txt ├── pattern.py ├── requirements.txt ├── slice.py ├── transcribe.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | logs/ 4 | unused/ 5 | results/ 6 | 7 | do.sh 8 | exclude_words.txt 9 | stats.png 10 | 11 | *.csv 12 | *.json 13 | *.txt 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 litagin02 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laughter Collector 2 | 3 | 音声データから笑い声を抽出してデータセットを作成するためのスクリプト群です。また非言語音声や感嘆詞の抽出も可能です(が誤りが多いかもしれません)。**日本語で書き起こしてそれを使って判定しているので、日本語以外の音声ではうまく動かないかもしれません**。 4 | 5 | ## 原理 6 | 7 | - 音声ファイルを読み込み、-40dBを無音とみなしスライス (`slice.py`) 8 | - スライスした音声データに対して、Whisperで日本語へ書き起こしを行う (`laughter_collector.py`内部) 9 | - 書き起こしデータに対して、正規表現を用いて笑い声かどうか・非言語音声や感嘆詞かどうかを判定 (`pattern.py`) 10 | 11 | ## 使い方 12 | 13 | Python>=3.10とNVIDIA GPUが必要です。 14 | 15 | ### インストール 16 | 17 | ```bash 18 | git clone https://github.com/litagin02/laughter-collector 19 | cd laughter-collector 20 | python -m venv venv 21 | venv\Scripts\activate 22 | pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 23 | pip install -r requirements.txt 24 | ``` 25 | 26 | ### 元データの準備 27 | 28 | - 音声ファイルたちをディレクトリ(以下`path/to/original_data`とする)に格納 29 | - 各ファイルの拡張子は".wav", ".mp3", ".flac", ".ogg", ".opus"のいずれかである必要があります(必要に応じて`utils.py`を書き換えれば他もいけます)。 30 | - `path/to/original_data`では好きなようにサブディレクトリたちの階層を作ってそこに音声ファイルを格納してください。**デフォルトでは直下の音声ファイルは反映されず、1つ下の階層からのみ反映されます**。直下のみを反映するには`-nr`オプションを指定してください。 31 | - 結果は相対パスを保持したまま指定した`path/to/output`に保存されます。 32 | 33 | ### データセットの作成 34 | 35 | ```bash 36 | python collect_laughter.py -i path/to/original_data -o path/to/output 37 | ``` 38 | 39 | 裏では`slice.py`が呼び出され、マルチプロセスで音声を切り出して`path/to/output/temp`にスライスされた音声が保存されて行き、それを本体のスクリプトが順次読み込んで書き起こしをバッチ処理で行います。 40 | 41 | 細かい他の引数はコードを参照してください。 42 | 43 | ### 結果 44 | 45 | 元々の音声ファイルが以下のような構造だったとします。 46 | ``` 47 | path/to/original_data 48 | ├── subdir1 49 | │ ├── foo.wav 50 | │ ├── bar.mp3 51 | │ └── baz.ogg 52 | └── subdir2 53 | ├── subdir3 54 | │ └── qux.mp3 55 | └── quux.flac 56 | ``` 57 | 58 | 結果は以下のような構造になります。 59 | ``` 60 | path/to/output 61 | ├── laugh 62 | │ ├── subdir1 63 | │ | ├── laugh.csv 64 | │ │ ├── foo_0.wav 65 | │ │ ├── foo_1.wav 66 | │ │ ├── bar_0.wav 67 | │ │ └── baz_0.wav 68 | │ └── subdir2 69 | │ ├── subdir3 70 | │ | ├── laugh.csv 71 | │ │ └── qux_0.wav 72 | | ├── laugh.csv 73 | │ └── quux_0.wav 74 | ├── nv 75 | │ ├── subdir1 76 | │ | ├── nv.csv 77 | │ │ ├── foo_2.wav 78 | | ... 79 | └── trans 80 | ├── subdir1 81 | │ └── all.csv 82 | └── subdir2 83 | ├── subdir3 84 | │ └── all.csv 85 | └── all.csv 86 | ``` 87 | 88 | ここで`foo_0.wav`は`foo.wav`の0番目のスライスを示しています。`trans.csv`は書き起こしデータです。また`laugh.csv`は笑い声と判定されたスライスの書き起こし、`nv.csv`は非言語音声や感嘆詞と判定されたスライスの書き起こし、また`all.csv`はそれ以外も含めた全てのスライスの書き起こしです。 89 | 90 | ### 注意 91 | 92 | - 笑い声の判定と非言語音声の判定では笑い声が優先されます。 93 | - 笑い声や非言語音声の判定の正規表現は改良の余地があると思うので、必要に応じて`pattern.py`を変更してください。 94 | - 非言語音声の判定は誤りが多く、特定のひらがなから単語ができてしまう場合それが非言語音声として判定されることがあります。結果を見ながら、そのような単語を`exclude_words.txt`に追加してください(毎回このファイルが参照されるので、スクリプト実行中でも変更が反映されます)。 95 | - スライスの細かいパラメータや、笑い声正規表現等は、それぞれ`splice.py`、`pattern.py`内を参照しつつ必要ならば変更してください。 96 | - デフォルトでは笑い声等判定のための書き起こしにはHugging FaceのWhisperのmediumモデルが使われます(笑い声や非言語音声かどうかさえ判定できればよく書き起こし精度はそこまで必要がない)、が必要に応じて`collect_laughter.py`の引数`--model large-v2`等でモデルを指定できます。 97 | - 細かい他の引数等はコードを参照してください。 98 | 99 | ## その他のスクリプト 100 | 101 | 結果の笑い声ファイルたちに対するFaster Whisper (large-v2) での書き起こし: 102 | ```bash 103 | python transcribe.py -i path/to/output/laugh -o transcriptions.csv 104 | ``` 105 | -------------------------------------------------------------------------------- /collect_laughter.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | import datetime 4 | import shutil 5 | import subprocess 6 | import sys 7 | import time 8 | 9 | from pathlib import Path 10 | 11 | import torch 12 | from torch.utils.data import Dataset 13 | from tqdm import tqdm 14 | from transformers import pipeline 15 | 16 | from pattern import is_laughing, is_nv, normalize_text 17 | from utils import is_audio_file, logger 18 | 19 | 20 | # HF Whisperの書き起こしの進捗を表示するために必要なデータセットクラス 21 | class ListDataset(Dataset): 22 | def __init__(self, original_list): 23 | self.original_list = original_list 24 | 25 | def __len__(self): 26 | return len(self.original_list) 27 | 28 | def __getitem__(self, i): 29 | return self.original_list[i] 30 | 31 | 32 | # Add log file 33 | logger.add(f'logs/{datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")}.log') 34 | 35 | parser = argparse.ArgumentParser() 36 | parser.add_argument("--input_dir", "-i", type=str, required=True) 37 | parser.add_argument("--output_dir", "-o", type=str, default="output") 38 | parser.add_argument("--non_recursive", "-nr", action="store_true") 39 | parser.add_argument("--overwrite", "-ow", action="store_true") 40 | parser.add_argument("--verbose", "-v", action="store_true") 41 | parser.add_argument("--keep", "-k", action="store_true") 42 | parser.add_argument("--num_workers", "-w", type=int, default=2) 43 | parser.add_argument("--model", "-m", type=str, default="medium") 44 | parser.add_argument("--batch_size", "-b", type=int, default=32) 45 | parser.add_argument("--not_do_sample", "-nds", action="store_true") 46 | parser.add_argument("--num_beams", "-nb", type=int, default=1) 47 | parser.add_argument("--threshold", "-t", type=float, default=40) 48 | 49 | args = parser.parse_args() 50 | 51 | logger.info(f"Args: {args}") 52 | 53 | 54 | device = "cuda:0" if torch.cuda.is_available() else "cpu" 55 | torch_dtype = torch.float16 if torch.cuda.is_available() else torch.float32 56 | 57 | model_id = f"openai/whisper-{args.model}" 58 | generate_kwargs = { 59 | "language": "ja", 60 | "do_sample": not args.not_do_sample, 61 | "num_beams": args.num_beams, 62 | "temperature": 0.1, 63 | "no_repeat_ngram_size": 10, 64 | } 65 | logger.info(f"Using model: {model_id}") 66 | logger.info(f"generate_kwargs: {generate_kwargs}") 67 | pipe = pipeline( 68 | model=model_id, 69 | max_new_tokens=128, 70 | chunk_length_s=30, 71 | batch_size=args.batch_size, 72 | torch_dtype=torch_dtype, 73 | device=device, 74 | generate_kwargs=generate_kwargs, 75 | ) 76 | 77 | 78 | input_dir = Path(args.input_dir) 79 | output_dir = Path(args.output_dir) 80 | output_dir.mkdir(exist_ok=True, parents=True) 81 | 82 | if args.non_recursive: 83 | subdirs = [input_dir] 84 | else: 85 | subdirs = (x for x in input_dir.rglob("*") if x.is_dir()) 86 | 87 | temp_dir = output_dir / "temp" 88 | if temp_dir.exists(): 89 | # Remove previous temp files 90 | logger.warning(f"Removing previous temp files in {temp_dir}") 91 | shutil.rmtree(temp_dir) 92 | temp_dir.mkdir(exist_ok=True, parents=True) 93 | 94 | 95 | keep_dir = output_dir / "keep" 96 | if args.keep: 97 | keep_dir.mkdir(exist_ok=True, parents=True) 98 | 99 | for subdir in subdirs: 100 | logger.info(f"Processing {subdir}...") 101 | 102 | current_out_dir_laugh = output_dir / "laugh" / subdir.relative_to(input_dir) 103 | current_out_dir_nv = output_dir / "nv" / subdir.relative_to(input_dir) 104 | 105 | csv_path_laugh = current_out_dir_laugh / "laugh.csv" 106 | csv_path_nv = current_out_dir_nv / "nv.csv" 107 | 108 | trans_all_csv = output_dir / "trans" / subdir.relative_to(input_dir) / "all.csv" 109 | 110 | if trans_all_csv.exists(): 111 | logger.warning(f"{trans_all_csv} already exists.") 112 | if not args.overwrite: 113 | logger.warning("Use --overwrite (-ow) to overwrite. Skipping...") 114 | continue 115 | 116 | audio_files = [x for x in subdir.iterdir() if is_audio_file(x)] 117 | if len(audio_files) == 0: 118 | logger.warning(f"No audio files found in {subdir}.") 119 | continue 120 | 121 | python = sys.executable 122 | slice_process = subprocess.Popen( 123 | [ 124 | python, 125 | "splice.py", 126 | "-i", 127 | str(subdir), 128 | "-o", 129 | str(temp_dir), 130 | "-t", 131 | str(args.threshold), 132 | "-w", 133 | str(args.num_workers), 134 | ], 135 | ) 136 | 137 | process_finished = False 138 | 139 | trans_results_all: list[tuple[Path, str, str]] = [] 140 | 141 | while True: 142 | logger.info("Waiting for slicing...") 143 | if slice_process.poll() is not None and not process_finished: 144 | logger.info("Finished slicing.") 145 | process_finished = True 146 | elif process_finished: 147 | break 148 | flag_files = [file for file in temp_dir.iterdir() if file.suffix == ".flag"] 149 | sliced_files = [file.with_suffix(".wav") for file in flag_files] 150 | if len(sliced_files) == 0: 151 | # sleep 1 sec 152 | logger.info("No sliced files found.") 153 | time.sleep(1) 154 | continue 155 | 156 | logger.info(f"Found {len(sliced_files)} sliced files.") 157 | 158 | trans_results = [] 159 | dataset = ListDataset([str(file) for file in sliced_files]) 160 | for whisper_result in tqdm( 161 | pipe(dataset), total=len(sliced_files), desc="Transcribing" 162 | ): 163 | trans_results.append(whisper_result["text"]) # type: ignore 164 | logger.success(f"Finished transcribing.") 165 | 166 | # Normalize texts to avoid crashes 167 | logger.info("Normalizing texts...") 168 | normalized_results = [] 169 | for text in tqdm(trans_results): 170 | normalized_results.append(normalize_text(text)) 171 | 172 | def process_text(item: tuple[Path, str]) -> tuple[Path, str, str]: 173 | file, text = item 174 | if args.verbose: 175 | logger.debug(f"Processing {file}: {text}") 176 | if is_laughing(text): 177 | return (file, text, "laugh") 178 | elif is_nv(text): 179 | return (file, text, "nv") 180 | else: 181 | if args.keep: 182 | shutil.move(file, keep_dir / file.name) 183 | else: 184 | file.unlink() 185 | file.with_suffix(".flag").unlink() 186 | return (file, text, "no-nv") 187 | 188 | logger.info("Processing texts...") 189 | text_results: list[tuple[Path, str, str]] = [] 190 | for item in tqdm( 191 | zip(sliced_files, normalized_results), 192 | total=len(sliced_files), 193 | desc="Processing", 194 | ): 195 | text_results.append(process_text(item)) 196 | 197 | trans_results_all.extend(text_results) 198 | 199 | results_laugh = [x for x in text_results if x[2] == "laugh"] 200 | results_nv = [x for x in text_results if x[2] == "nv"] 201 | 202 | for file, text, _ in results_laugh: 203 | logger.success(f"laugh: {file}: {text}") 204 | for file, text, _ in results_nv: 205 | logger.info(f"nv: {file}: {text}") 206 | 207 | if len(results_laugh) > 0: 208 | logger.success( 209 | f"Moving {len(results_laugh)} laugh files to {current_out_dir_laugh}" 210 | ) 211 | current_out_dir_laugh.mkdir(exist_ok=True, parents=True) 212 | for file, text, _ in results_laugh: 213 | out_file = current_out_dir_laugh / file.name 214 | if not args.keep: 215 | file.replace(out_file) 216 | else: 217 | shutil.copy(file, out_file) 218 | file.replace(keep_dir / subdir.relative_to(input_dir) / file.name) 219 | file.with_suffix(".flag").unlink() 220 | 221 | # CSVファイルが存在しないかサイズが0の場合は、ヘッダーを書き込む 222 | if not csv_path_laugh.exists() or csv_path_laugh.stat().st_size == 0: 223 | with csv_path_laugh.open("w", newline="", encoding="utf-8") as f: 224 | writer = csv.writer(f) 225 | writer.writerow(["file", "text"]) 226 | 227 | # データの追加 228 | with csv_path_laugh.open("a", newline="", encoding="utf-8") as f: 229 | writer = csv.writer(f) 230 | writer.writerows( 231 | (str(file.name), text) for file, text, _ in results_laugh 232 | ) 233 | else: 234 | logger.info(f"No laugh files found in this loop.") 235 | 236 | if len(results_nv) > 0: 237 | logger.success(f"Moving {len(results_nv)} nv files to {current_out_dir_nv}") 238 | current_out_dir_nv.mkdir(exist_ok=True, parents=True) 239 | for file, text, _ in results_nv: 240 | out_file = current_out_dir_nv / file.name 241 | if not args.keep: 242 | file.replace(out_file) 243 | else: 244 | shutil.copy(file, out_file) 245 | file.replace(keep_dir / subdir.relative_to(input_dir) / file.name) 246 | file.with_suffix(".flag").unlink() 247 | 248 | # CSVファイルが存在しないかサイズが0の場合は、ヘッダーを書き込む 249 | if not csv_path_nv.exists() or csv_path_nv.stat().st_size == 0: 250 | with csv_path_nv.open("w", newline="", encoding="utf-8") as f: 251 | writer = csv.writer(f) 252 | writer.writerow(["file", "text"]) 253 | 254 | # データの追加 255 | with csv_path_nv.open("a", newline="", encoding="utf-8") as f: 256 | writer = csv.writer(f) 257 | writer.writerows((str(file.name), text) for file, text, _ in results_nv) 258 | else: 259 | logger.info(f"No nv files found in this loop.") 260 | 261 | # Sort trans_results_all by file name 262 | trans_results_all.sort(key=lambda x: x[0].name) 263 | 264 | trans_all_csv.parent.mkdir(exist_ok=True, parents=True) 265 | 266 | # Write all transcriptions to a csv file 267 | with trans_all_csv.open("w", newline="", encoding="utf-8") as f: 268 | writer = csv.writer(f) 269 | writer.writerow(["file", "text", "label"]) 270 | writer.writerows( 271 | (str(file.name), text, label) for file, text, label in trans_results_all 272 | ) 273 | 274 | logger.success(f"Finished processing {subdir}.") 275 | -------------------------------------------------------------------------------- /exclude_words_default.txt: -------------------------------------------------------------------------------- 1 | あいてむ 2 | あたっく 3 | あなた 4 | あれは 5 | あんた 6 | いいよ 7 | いいん 8 | いく 9 | いっちゃ 10 | おいし 11 | おとな 12 | おに 13 | おね 14 | おーな 15 | おんな 16 | きっと 17 | きゃら 18 | くやし 19 | くん 20 | げーむ 21 | した 22 | して 23 | しちゃ 24 | しょうが 25 | しょっく 26 | すご 27 | そう 28 | そうして 29 | そして 30 | ただ 31 | たし 32 | たとえ 33 | だいたい 34 | だが 35 | ちゃーはん 36 | ちゃん 37 | とはいえ 38 | どう 39 | どんな 40 | ない 41 | なんとなく 42 | ひんと 43 | ふーど 44 | ほしい 45 | ほんと 46 | やがて 47 | やんきー 48 | よって 49 | ゆえに 50 | ゆき 51 | ゆにーく 52 | わたくし 53 | -------------------------------------------------------------------------------- /pattern.py: -------------------------------------------------------------------------------- 1 | """ 2 | 短いセリフが「笑い声か」「感嘆詞・非言語音声か」を判定するための正規表現を提供するモジュール。 3 | is_laughing()とis_nv()関数を提供する。 4 | """ 5 | 6 | import re 7 | import shutil 8 | import unicodedata 9 | from pathlib import Path 10 | 11 | import jaconv 12 | import pyopenjtalk 13 | 14 | """ 15 | 非言語音声・感嘆詞等ではないのにマッチしてしまう単語を除外するためのファイル。 16 | 結果を見ながら随時exclude_words.txtに追加してください。 17 | """ 18 | exclude_words_file = Path("exclude_words.txt") 19 | if not exclude_words_file.exists(): 20 | shutil.copy("exclude_words_default.txt", "exclude_words.txt") 21 | 22 | punctuations = re.escape("。、.,!?!?") 23 | 24 | 25 | def normalize_text(text: str) -> str: 26 | """ 27 | 日本語文章を句読点や空白等を排除し正規化する。 28 | ひらがな、カタカナ、漢字、アルファベット、数字、句読点等以外の文字を削除する。 29 | 長音符や「っ」や句読点等の連続を1つにする。「…」→「.」に注意。 30 | 句読点は正規化されて「。」「、」「.」「!」「?」になる。 31 | """ 32 | text = unicodedata.normalize("NFKC", text) 33 | # この段階で「…」は「...」に変換されることに注意 34 | 35 | text = text.replace("~", "ー") 36 | text = text.replace("~", "ー") 37 | text = text.replace("〜", "ー") 38 | text = text.replace("・", ".") 39 | 40 | text = re.sub( 41 | # ↓ ひらがな、カタカナ、漢字 42 | r"[^\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\u3005" 43 | # ↓ 半角アルファベット(大文字と小文字) 44 | + r"\u0041-\u005A\u0061-\u007A" 45 | # ↓ 半角数字 46 | + r"\u0030-\u0039" 47 | # ↓ 句読点等 48 | + punctuations 49 | # 上述以外の文字を削除 50 | + r"]+", 51 | "", 52 | text, 53 | ) 54 | text = text.replace("\u3099", "") # 結合文字の濁点を削除、る゙ → る 55 | text = text.replace("\u309A", "") # 結合文字の半濁点を削除、な゚ → な 56 | 57 | # 「ー」の連続を1つにする 58 | text = re.sub(r"ー+", "ー", text) 59 | # punctuationsと「ー」と「っ」と「ッ」の連続を1つにする 60 | text = re.sub(rf"([{punctuations}ーっッ])\1+", r"\1", text) 61 | return text 62 | 63 | 64 | # 「ー」と「っ」を取り除いた文章に対するひらがなの笑い声の正規表現 65 | warai_pattern = ( 66 | r"((" 67 | + r"(ん|む)*" 68 | # 「は」行を最後に含む系統 69 | + r"(あ+|い+|う+|え+|お+)(は+|ひ+|ふ+|へ+|ほ+)|" 70 | + r"かは+|きひ+|く(は+|ひ+|ふ+|へ+|ほ+)|けへ+|" 71 | + r"がは+|ぎひ+|ぐ(は+|ひ+|ふ+|へ+|ほ+)|げへ+|" 72 | + r"きゃは+|ぎゃは+|" 73 | + r"たは+|てへ+|" 74 | + r"なは+|に(は+|ひ+|ふ+|へ+|ほ+)|ぬ(は+|ひ+|ふ+|へ+|ほ+)|" 75 | + r"にゃは+|にゅふ+|にょほ+|" 76 | + r"ふ+(は+|ひ+|ふ+|へ+|ほ+)|" 77 | + r"ぶ+(は+|ひ+|ふ+|へ+|ほ+)|" 78 | + r"ぷ+(は+|ひ+|ふ+|へ+|ほ+)|" 79 | + r"(む|も)(は+|ひ+|ふ+|へ+|ほ+)|" 80 | + r"わ+は+|うぃひ+|うぇへ+|うぉほ+|" 81 | + r"ん(は+|ひ+|ふ+|へ+|ほ+)|" 82 | # 「ひゃ」で終わる系統(叫び声もある) 83 | + r"あ(ひゃ)+|う(ひゃ)+|う(ひょ)+|" 84 | # 「し」で終わる系統 85 | + r"(い|き|に)し+|" 86 | # 「ん」で終わる系統(自慢げや感心の感嘆詞の可能性もある) 87 | + r"ふ{2,}ん|へ{2,}ん|ほ{2,}ん|" 88 | # 2回以上の繰り返し 89 | + r"か{2,}|く{2,}|け{2,}|は{2,}|ひ{2,}|ふ{2,}|へ{2,}|ほ{2,}|ぷ{2,}|" 90 | # 擬態語 91 | + r"くす|けら|げら|にこ|にや|にた|にか|にま|" 92 | + r"てへ+|" 93 | + r"にやり)ん*)+" 94 | ) 95 | 96 | # 母音撥音促音等のパターン 97 | basis = r"[あいうえおやゆよわんぁぃぅぇぉゃゅょゎーっ]" 98 | 99 | 100 | # (母音 +)◯◯(+ 母音)で感嘆詞とみなせるパターン 101 | single_nv_pattern = ( 102 | # フィラー 103 | r"あ[あー]+|あの[うおー]*|え[ーっ]*と?|その[おー]*|ま[あー]+|う[ー]*ん?|" 104 | # それ以外の単体の感嘆詞や口語表現 105 | + r"ちぇ|くそ|(やれ){2,}|すご|ち(き|く)しょ|やば|まじで?っ*す?か?|あれ+|でし|" 106 | + r"おっ?け|あっぱれ|おっ?す|うぃ?っ?す|しまった|よくも?|" 107 | ) 108 | 109 | # ひらがな(「ー」「っ」含む)に対する非言語音声・感嘆詞の正規表現 110 | nv_pattern = ( 111 | r"(" 112 | # 母音等の2回以上の繰り返しの場合(「うおわー」「いやぁ」「うん」等) 113 | + rf"{basis}{{2,}}|" 114 | # 母音等以外が先頭・間に来る場合(「やったー」「げげ」「ぎょわあーーっ」「うみゃみゃーん」等) 115 | + rf"{basis}*(" 116 | # このブロックの中は単体文字でヒットするので、 117 | # これらの文字(と母音等)からできる単語が含まれてしまうことに注意 118 | # このためexclude_wordsで手動でそのようなものを除外する 119 | + r"き+|く+|が+|ぎ+|ぐ+|げ+|し+|そ+|た+|て+|と+|だ+|ど+|な+|に+|ぬ+|は+|ひ+|ふ+|へ+|ほ+|む+|" 120 | + r"ち?(ちゃ)+|ち?(ちゅ)+|ち?(ちょ)+|(でゅ)+|に?(にゃ)+|み?(みゃ)+|(ひゃ)+|(ひゅ)+|(ひょ)+|(しゃ)+|(しゅ)+|(しょ)+|" 121 | + rf"{single_nv_pattern}" 122 | + rf"){basis}*|" 123 | # 「ら」「りゃ」が間に入る場合(「ありゃ」「おらー」「てりゃりゃー」等) 124 | + rf"[あいうおこてとんぁぃぅぇぉゃゅょゎーっ]+(ら|りゃ)+{basis}*" 125 | + r")+" 126 | ) 127 | 128 | 129 | def is_laughing(norm_text: str) -> bool: 130 | # punctuationsを削除 131 | norm_text = re.sub("[" + punctuations + "]", "", norm_text) 132 | # wの繰り返しの場合はTrueを返す(書き起こし結果がたまに「www」となる) 133 | if re.fullmatch(r"(w|W)+", norm_text): 134 | return True 135 | # ひらがな、カタカナ以外があったらFalseを返す 136 | if not re.fullmatch(r"[\u3040-\u309F\u30A0-\u30FF]+", norm_text): 137 | return False 138 | # カタカナをひらがなに変換 139 | norm_text = jaconv.kata2hira(norm_text) 140 | # 「ー」と「っ」を取り除く 141 | norm_text = re.sub("[っー]", "", norm_text) 142 | 143 | # カタストロフバックトラッキングを防ぐために、は行の3回以上の繰り返しを2回にする 144 | norm_text = re.sub(rf"([は|ひ|ふ|へ|ほ])\1{{3,}}", r"\1\1", norm_text) 145 | # 全体がパターンにマッチするかどうかを判定 146 | return bool(re.fullmatch(warai_pattern, norm_text)) 147 | 148 | 149 | def is_kandoushi(text: str) -> bool: 150 | result = pyopenjtalk.run_frontend(text) 151 | pos_set = set(r["pos"] for r in result) 152 | if not pos_set.issubset({"感動詞", "フィラー", "記号"}): 153 | return False 154 | if pos_set == {"記号"}: 155 | return False 156 | return True 157 | 158 | 159 | def is_nv(norm_text: str) -> bool: 160 | if norm_text == "": 161 | return False 162 | # 漢字・アルファベット・数字が含まれていたらFalseを返す 163 | if bool( 164 | re.search( 165 | r"[\u4E00-\u9FFF\u3400-\u4DBF\u0041-\u005A\u0061-\u007A\u0030-\u0039]", 166 | norm_text, 167 | ) 168 | ): 169 | return False 170 | 171 | # 句読点のみからなればTrueを返す 172 | if re.fullmatch("[" + punctuations + "]+", norm_text): 173 | return True 174 | 175 | # 解析で感動詞、フィラー、記号のみからなればTrueを返す 176 | if is_kandoushi(norm_text): 177 | return True 178 | 179 | # 句読点を削除(「ー」「っ」は残す) 180 | norm_text = re.sub("[" + punctuations + "]", "", norm_text) 181 | # ここまででtextはひらがな、カタカナのみからなる 182 | 183 | # カタカナをひらがなに変換 184 | norm_text = jaconv.kata2hira(norm_text) 185 | 186 | # カタストロフバックトラッキングを防ぐために、母音等の3回以上の繰り返しを2回にする 187 | norm_text = re.sub(rf"({basis})\1{{3,}}", r"\1\1", norm_text) 188 | 189 | # カタストロフバックトラッキングを防ぐために、10文字で切る 190 | norm_text = norm_text[:10] 191 | # nvパターンにマッチするかどうかを判定 192 | if not bool(re.fullmatch(nv_pattern, norm_text)): 193 | return False 194 | 195 | # 特定の単語が含まれている場合はFalseを返す 196 | # 主に上のパターンでの1文字部分の繰り返しで意味のある単語ができてしまう場合に使用 197 | with exclude_words_file.open("r", encoding="utf-8") as f: 198 | exclude_words = f.read().splitlines() 199 | 200 | if any(word in norm_text for word in exclude_words): 201 | return False 202 | 203 | return True 204 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | faster-whisper==0.10.1 2 | jaconv 3 | loguru 4 | pydub 5 | pyopenjtalk 6 | torch 7 | -------------------------------------------------------------------------------- /slice.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from concurrent.futures import ProcessPoolExecutor 3 | from pathlib import Path 4 | 5 | import soundfile as sf 6 | from pydub import AudioSegment 7 | from pydub.silence import split_on_silence 8 | 9 | from utils import is_audio_file, logger 10 | 11 | 12 | def process_file(file: Path, output_dir: Path, threshold: int): 13 | chunks = split(file, threshold) 14 | for i, chunk in enumerate(chunks): 15 | chunk = chunk.set_channels(1) 16 | chunk = chunk.set_frame_rate(44100) 17 | out_file = output_dir / f"{file.stem}_{i}.wav" 18 | chunk.export(out_file, format="wav", codec="pcm_s16le") 19 | flag_file = out_file.with_suffix(".flag") 20 | flag_file.touch() 21 | 22 | 23 | def split(input_file: Path, threshold: int) -> list[AudioSegment]: 24 | try: 25 | audio, sr = sf.read(input_file) 26 | tmp_file = input_file.name 27 | sf.write(tmp_file, audio, sr) 28 | audio = AudioSegment.from_file(tmp_file) 29 | Path(tmp_file).unlink() 30 | except Exception as e: 31 | print(e) 32 | return [] 33 | 34 | min_silence_len = 250 35 | silence_thresh = -threshold 36 | keep_silence = 200 37 | 38 | min_chunk_len = 500 39 | max_chunk_len = 30_000 40 | 41 | if (len(audio) < min_chunk_len) or (len(audio) > max_chunk_len): 42 | return [] 43 | 44 | chunks = split_on_silence( 45 | audio, 46 | min_silence_len=min_silence_len, 47 | silence_thresh=silence_thresh, 48 | keep_silence=keep_silence, 49 | ) 50 | 51 | return [chunk for chunk in chunks if min_chunk_len < len(chunk) < max_chunk_len] 52 | 53 | 54 | logger.add(f"logs/split.log") 55 | 56 | if __name__ == "__main__": 57 | logger.info("Starting slicing") 58 | parser = argparse.ArgumentParser() 59 | parser.add_argument( 60 | "--input_dir", 61 | "-i", 62 | type=str, 63 | default="inputs", 64 | help="Directory of input wav files", 65 | ) 66 | parser.add_argument( 67 | "--output_dir", 68 | "-o", 69 | type=str, 70 | ) 71 | parser.add_argument( 72 | "--num_workers", 73 | "-w", 74 | type=int, 75 | default=10, 76 | ) 77 | parser.add_argument( 78 | "--threshold", 79 | "-t", 80 | type=int, 81 | default=40, 82 | ) 83 | args = parser.parse_args() 84 | logger.info(f"Slicing args: {args}") 85 | threshold: int = args.threshold 86 | 87 | input_dir = Path(args.input_dir) 88 | output_dir = Path(args.output_dir) 89 | 90 | output_dir.mkdir(exist_ok=True, parents=True) 91 | 92 | audio_files = [x for x in input_dir.glob("*") if is_audio_file(x)] 93 | 94 | logger.info(f"Found {len(audio_files)} audio files in {input_dir}") 95 | 96 | with ProcessPoolExecutor(max_workers=args.num_workers) as executor: 97 | futures = [ 98 | executor.submit(process_file, file, output_dir, threshold) 99 | for file in audio_files 100 | ] 101 | for future in futures: 102 | future.result() 103 | 104 | logger.success(f"Slice done for {input_dir}") 105 | -------------------------------------------------------------------------------- /transcribe.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | from pathlib import Path 4 | 5 | from faster_whisper import WhisperModel 6 | 7 | from utils import is_audio_file, logger 8 | 9 | model = WhisperModel("large-v2", device="cuda") 10 | 11 | logger.add( 12 | f'logs/transcribe_{datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")}.log' 13 | ) 14 | 15 | 16 | def transcribe_local(audio_path: Path) -> str: 17 | segments, _ = model.transcribe( 18 | str(audio_path), 19 | language="ja", 20 | ) 21 | texts = [segment.text for segment in segments] 22 | return "".join(texts) 23 | 24 | 25 | parser = argparse.ArgumentParser(description="Transcribe audio files") 26 | parser.add_argument( 27 | "-i", "--input_dir", type=Path, required=True, help="Input directory" 28 | ) 29 | parser.add_argument( 30 | "-o", 31 | "--output_file", 32 | default="transcriptions.csv", 33 | ) 34 | 35 | args = parser.parse_args() 36 | 37 | input_dir = Path(args.input_dir) 38 | csv_path = Path(args.output_file) 39 | 40 | for d in input_dir.rglob("*"): 41 | if not d.is_dir(): 42 | continue 43 | audio_files = [f for f in d.glob("*") if is_audio_file(f)] 44 | if not audio_files: 45 | logger.info(f"No audio files found in {d}") 46 | continue 47 | logger.info(f"Found {len(audio_files)} files in {d}") 48 | for i, audio_path in enumerate(audio_files): 49 | logger.info(f"{i + 1}/{len(audio_files)}: Processing {audio_path}") 50 | text = transcribe_local(audio_path) 51 | logger.info(f"Transcribed: {text}") 52 | # Write to csv 53 | with open(csv_path, "a", newline="", encoding="utf-8") as f: 54 | f.write(f"{audio_path.relative_to(input_dir)},{text}\n") 55 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from loguru import logger 5 | 6 | 7 | logger.remove() 8 | 9 | log_format = ( 10 | "{time:MM-DD HH:mm:ss} |{level:^8}| {file}:{line} | {message}" 11 | ) 12 | 13 | 14 | logger.add(sys.stdout, format=log_format, backtrace=True, diagnose=True) 15 | 16 | 17 | def is_audio_file(file: Path) -> bool: 18 | return file.suffix.lower() in (".wav", ".mp3", ".flac", ".ogg", ".opus") 19 | --------------------------------------------------------------------------------