├── .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 |
--------------------------------------------------------------------------------