チャンク処理
This commit is contained in:
parent
ea17845fe1
commit
1e808cb472
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
.output/
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@ -15,7 +17,6 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|||||||
16
README.md
16
README.md
@ -1,2 +1,18 @@
|
|||||||
# speech-to-text-pipeline
|
# speech-to-text-pipeline
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python src\main.py data\samples\task-smp.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## セットアップ方法
|
||||||
|
|
||||||
|
**前提条件**
|
||||||
|
|
||||||
|
システムに `ffmpeg` がインストールされている必要があります。
|
||||||
|
|
||||||
|
```text
|
||||||
|
- macOS: `brew install ffmpeg`
|
||||||
|
- Ubuntu: `sudo apt install ffmpeg`
|
||||||
|
- Windows: `choco install ffmpeg`(または手動でPATHを通す)
|
||||||
|
```
|
||||||
|
|||||||
BIN
data/samples/interview_aps-smp.mp3
Normal file
BIN
data/samples/interview_aps-smp.mp3
Normal file
Binary file not shown.
168
data/samples/interview_aps-smp.txt
Normal file
168
data/samples/interview_aps-smp.txt
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
%講演ID:D04M0041
|
||||||
|
%
|
||||||
|
%<SOT>
|
||||||
|
%%【略】
|
||||||
|
0003 00008.805-00012.085 L:
|
||||||
|
質問させていただきます & シツモンサセテイタダキマス
|
||||||
|
(F あの) & (F アノ)
|
||||||
|
読んだんですけれども & ヨンダンデスケレドモ
|
||||||
|
0004 00009.417-00009.838 R:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0005 00011.770-00012.901 R:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
分からなかった & ワカラナカッタ
|
||||||
|
0006 00012.536-00013.221 L:
|
||||||
|
大抵の & タイ(笑 テーノ)
|
||||||
|
0007 00013.250-00014.315 R:<笑>
|
||||||
|
0008 00013.698-00016.817 L:
|
||||||
|
(F あのー) & (F アノー)
|
||||||
|
理解には & リカイニワ
|
||||||
|
遠く & トーク
|
||||||
|
及ばずという & オヨバズトユー
|
||||||
|
感じで & カンジデ
|
||||||
|
0009 00017.156-00018.411 L:
|
||||||
|
(F あのー) & (F アノー)
|
||||||
|
言葉の & コトバノ
|
||||||
|
意味 & イミ
|
||||||
|
0010 00018.861-00020.915 L:
|
||||||
|
から & カラ
|
||||||
|
お聞きしたいと & オキキシタイト
|
||||||
|
思うんですけど & オモウンデスケド
|
||||||
|
0011 00019.775-00020.137 R:
|
||||||
|
(F はい)(F はい) & (F (? ハ)イ)(F ハイ)
|
||||||
|
0012 00020.603-00020.823 R:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0013 00021.484-00022.802 L:
|
||||||
|
パラ言語情報 & パラゲンゴジョーホー
|
||||||
|
0014 00023.223-00029.484 L:
|
||||||
|
っていう & ッテ(W ユ;ユウ)
|
||||||
|
言葉と & コトバト
|
||||||
|
後 & アト
|
||||||
|
ホルマント & (W フォルマント;ホルマント)
|
||||||
|
後 & アト
|
||||||
|
調音運動っていう & チョーオ(? ン)ウンドーッテユー
|
||||||
|
ことについて & コトニツイテ
|
||||||
|
まず & マズ
|
||||||
|
初めに & ハジメニ
|
||||||
|
聞かせてください & キカセテクダサイ
|
||||||
|
0015 00029.360-00030.235 R:
|
||||||
|
三つね & ミッツネ<H>
|
||||||
|
0016 00030.120-00030.355 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0017 00030.360-00031.091 R:
|
||||||
|
(F あのー) & (F アノー)
|
||||||
|
0018 00030.469-00030.950 L:<笑>
|
||||||
|
0019 00031.758-00034.406 R:
|
||||||
|
言語ってのは & ゲンゴッテノワ
|
||||||
|
分かりますよね & ワカリマスヨネ<H>
|
||||||
|
言葉ですよね & コトバデスヨネ
|
||||||
|
0020 00034.287-00034.610 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0021 00034.608-00035.659 R:
|
||||||
|
そんで & (W ウン;ソン)デ
|
||||||
|
言語情報 & ゲンゴジョーホー
|
||||||
|
0022 00035.863-00038.959 R:
|
||||||
|
っていうのはね & (? ッテユー)ノワネ<H>
|
||||||
|
(F まー) & (F マー)
|
||||||
|
簡単に & カンタンニ
|
||||||
|
言えば & イエバ
|
||||||
|
単語の & タンゴノ
|
||||||
|
意味 & イミ
|
||||||
|
0023 00039.234-00039.547 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0024 00040.215-00044.337 R:
|
||||||
|
(F あーのー) & (F アーノー)
|
||||||
|
辞書に & ジショニ
|
||||||
|
書いてありますよね & カイテアリマスヨネ<H>
|
||||||
|
それから & (W ソエ;ソレ)カラ
|
||||||
|
(F その) & (F ソノ)
|
||||||
|
単語が & タンゴガ
|
||||||
|
くっ付いた & クッツイタ
|
||||||
|
時に & トキニ<H>
|
||||||
|
0025 00042.086-00042.410 L:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0026 00045.143-00046.896 R:
|
||||||
|
(D (? つ)) & (D (? ツ))
|
||||||
|
くっ付いて & クッツイテ
|
||||||
|
ほら & ホラ
|
||||||
|
文を & ブンオ
|
||||||
|
作ったり & ツクッタリ
|
||||||
|
0027 00047.033-00047.309 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0028 00047.107-00050.050 R:
|
||||||
|
する & スル
|
||||||
|
時に & トキニ<H>
|
||||||
|
助詞が & ジョシ(? ガ)
|
||||||
|
名詞に & メーシニ
|
||||||
|
助詞が & ジョシガ
|
||||||
|
くっ付いて & クッツイテ<H>
|
||||||
|
0029 00050.267-00051.283 R:
|
||||||
|
動詞が & ドーシガ
|
||||||
|
あって & アッテ
|
||||||
|
0030 00051.534-00052.914 R:
|
||||||
|
最後に & サイゴニ
|
||||||
|
助動詞が & ジョドーシガ
|
||||||
|
あって & アッテ
|
||||||
|
0031 00053.116-00053.973 R:
|
||||||
|
ってのは & ッテノワ
|
||||||
|
(D す) & (D ス)
|
||||||
|
(F まー) & (F マー)
|
||||||
|
そういう & ソーユー
|
||||||
|
0032 00054.237-00054.812 R:
|
||||||
|
普通に & フツーニ
|
||||||
|
0033 00055.349-00058.636 R:
|
||||||
|
言語学の & ゲンゴガクノ
|
||||||
|
教科書に & キョーカショニ
|
||||||
|
書いてあるような & カイテアルヨー(W (? ン);ナ)
|
||||||
|
それが & ソレガ
|
||||||
|
(F まー) & (F マー)
|
||||||
|
言語情報ですね & ゲンゴジョーホーデスネ
|
||||||
|
0034 00056.998-00057.373 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0035 00059.323-00060.808 R:
|
||||||
|
で & デ
|
||||||
|
パラって & パラッテ
|
||||||
|
言葉はね & コトバワネ<H>
|
||||||
|
0036 00061.138-00062.393 R:
|
||||||
|
(?)語源的にはね & (?)ゴゲンテキニワネ<H>
|
||||||
|
0037 00062.773-00063.557 R:
|
||||||
|
(F そのー) & (F ソノー)
|
||||||
|
0038 00064.159-00066.039 R:
|
||||||
|
何とかの & ナントカノ
|
||||||
|
横にとかね & ヨコニ<Q>トカネ
|
||||||
|
0039 00066.163-00066.903 L:
|
||||||
|
(F はー) & (F ハー)
|
||||||
|
0040 00066.599-00067.278 R:
|
||||||
|
隣りに & トナリニ
|
||||||
|
0041 00067.524-00067.898 R:
|
||||||
|
とかね & トカネ
|
||||||
|
0042 00068.271-00069.537 R:
|
||||||
|
そういう & ソーユー
|
||||||
|
意味なんですよ & イミナンデスヨ
|
||||||
|
0043 00069.548-00069.944 L:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0044 00071.148-00074.140 R:
|
||||||
|
で & デ<H>
|
||||||
|
つまり & ツマリ
|
||||||
|
言語から & ゲンゴカラ
|
||||||
|
ちょっと & チョット
|
||||||
|
ずれたとこに & ズレタトコニ
|
||||||
|
ある & アル
|
||||||
|
0045 00074.125-00074.514 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0046 00074.349-00074.835 R:
|
||||||
|
情報 & ジョーホー
|
||||||
|
0047 00075.089-00075.690 L:
|
||||||
|
(F ふーん) & (F <VN>)
|
||||||
|
0048 00075.201-00075.726 R:
|
||||||
|
だけど & ダケド
|
||||||
|
0049 00076.286-00076.757 R:
|
||||||
|
だけど & ダケド
|
||||||
|
0050 00077.039-00079.949 R:
|
||||||
|
(F そのー) & (F ソノー)
|
||||||
|
実際には & ジッサイニワ
|
||||||
|
存在してる & ソンザイシテル
|
||||||
|
情報っていう & ジョーホー(? ッテユー)
|
||||||
|
ことですね & コトデスネ
|
||||||
|
%%【略】
|
||||||
|
%<EOT>
|
||||||
BIN
data/samples/task-smp.mp3
Normal file
BIN
data/samples/task-smp.mp3
Normal file
Binary file not shown.
121
data/samples/task-smp.txt
Normal file
121
data/samples/task-smp.txt
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
%講演ID:D02M0016
|
||||||
|
%
|
||||||
|
%<SOT>
|
||||||
|
0001 00000.314-00000.946 L:
|
||||||
|
開けます & アケマス
|
||||||
|
0002 00001.020-00001.420 R:
|
||||||
|
(F はい) & (F ハ<H>イ)
|
||||||
|
0003 00003.253-00003.612 R:
|
||||||
|
(F あー) & (F アー)
|
||||||
|
0004 00003.366-00003.670 R:<雑音>
|
||||||
|
0005 00003.971-00005.436 R:
|
||||||
|
僕の & ボクノ
|
||||||
|
方には & ホーニワ
|
||||||
|
写真が & シャシンガ
|
||||||
|
入ってる & ハイッテル
|
||||||
|
0006 00006.080-00011.018 L:
|
||||||
|
(F あ) & (F ア)
|
||||||
|
あたしの & アタシノ
|
||||||
|
方は & ホーワ<H>
|
||||||
|
(F あの) & (F アノ)
|
||||||
|
名前と & ナマエト
|
||||||
|
(D けー) & (D ケー)
|
||||||
|
一言っていうのが & ヒトコトッテユーノガ
|
||||||
|
入ってます & ハイッテマス
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0007 00010.192-00010.473 R:
|
||||||
|
(F あー) & (F アー)
|
||||||
|
そう & ソー
|
||||||
|
0008 00010.980-00011.187 R:
|
||||||
|
(F ん) & (F (? ン))
|
||||||
|
0009 00011.951-00014.537 R:
|
||||||
|
知らない & シ<H>ラナイ
|
||||||
|
人が & ヒトガ
|
||||||
|
いっぱい & (笑 イッパイ
|
||||||
|
いるぞ & イルゾ<H>)<笑>
|
||||||
|
0010 00014.391-00015.204 L:
|
||||||
|
(F え) & (F エ)
|
||||||
|
そうですか & ソーデスカ<H>
|
||||||
|
0011 00015.182-00015.785 R:
|
||||||
|
(F うーん) & (F <VN>)
|
||||||
|
0012 00015.866-00018.167 L:
|
||||||
|
名前 & ナマエ
|
||||||
|
じゃ & ジャ<H>
|
||||||
|
ちょっと & チョット
|
||||||
|
読み上げてくんで & ヨミアゲテクンデ<H>
|
||||||
|
0013 00017.339-00017.827 R:
|
||||||
|
(F はい)(F はい) & (F ハイ)(F ハイ)
|
||||||
|
0014 00018.111-00018.436 R:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0015 00018.690-00019.721 L:
|
||||||
|
行きます & イキマス
|
||||||
|
上から & ウエカラ
|
||||||
|
0016 00019.313-00019.554 R:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0017 00019.934-00020.263 R:
|
||||||
|
(F うん) & (F <VN>)
|
||||||
|
0018 00019.961-00020.752 L:
|
||||||
|
平野レミ & ヒラノレミ
|
||||||
|
0019 00021.406-00022.671 R:
|
||||||
|
平野レミ & ヒラノレミ
|
||||||
|
0020 00022.308-00028.617 L:
|
||||||
|
平野レミ & ヒラノレミ
|
||||||
|
(F あの) & (F アノ)
|
||||||
|
お料理 & オリョーリ<H>
|
||||||
|
お料理 & オリョーリ
|
||||||
|
(F あのー) & (F アノー)
|
||||||
|
お料理研究家の & オリョーリケンキューカノ
|
||||||
|
人 & (W シト;ヒト)
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
眼鏡 & メガネ
|
||||||
|
掛けてて & カケテテ
|
||||||
|
ショートカット & ショートカット
|
||||||
|
0021 00025.914-00027.060 R:
|
||||||
|
女の & オンナノ
|
||||||
|
人だよね & ヒトダヨネ
|
||||||
|
0022 00027.805-00029.129 R:
|
||||||
|
眼鏡 & メガネ
|
||||||
|
掛けた & (? カ)ケタ
|
||||||
|
(F あー) & (F アー)
|
||||||
|
分かった & ワカッタ
|
||||||
|
分かった & ワカッタ
|
||||||
|
0023 00028.159-00028.678 R:<雑音>
|
||||||
|
0024 00028.822-00029.086 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0025 00029.318-00029.520 R:
|
||||||
|
(F はい) & (F (W アイ;ハイ))
|
||||||
|
0026 00030.031-00030.914 L:
|
||||||
|
セルジオ & セルジオ<H>
|
||||||
|
0027 00030.138-00031.642 R:
|
||||||
|
ちょっと & (W チョト;チョット)
|
||||||
|
待ってね & マッテネ
|
||||||
|
平野 & ヒラノ
|
||||||
|
0028 00031.314-00031.582 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0029 00031.939-00033.269 R:
|
||||||
|
平らな & タイラナ
|
||||||
|
野っ原ですか & ノッパラデスカ
|
||||||
|
0030 00033.130-00034.861 L:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
レミは & レミワ
|
||||||
|
片仮名です & カタカナデス
|
||||||
|
0031 00033.577-00033.830 R:
|
||||||
|
(D (? ん)) & (D (? ン))
|
||||||
|
0032 00035.022-00035.267 R:
|
||||||
|
(F はい) & (F ハイ)
|
||||||
|
0033 00036.548-00036.863 R:
|
||||||
|
それから & (? ソ)レカラ
|
||||||
|
0034 00036.998-00038.020 L:
|
||||||
|
セルジオ越後 & セルジオエチゴ
|
||||||
|
0035 00038.760-00040.562 R:
|
||||||
|
これ & コレ
|
||||||
|
分かんない & ワカンナイ
|
||||||
|
セルジオ越後って & (笑 セルジ(? オ)エチゴッテ)
|
||||||
|
誰 & ダレ
|
||||||
|
0036 00040.110-00043.657 L:
|
||||||
|
これは & コレワ<H>
|
||||||
|
(F あのー) & (F アノー)
|
||||||
|
サッカーの & サッカーノ<H>
|
||||||
|
解説者なんですけど & カイセツシャナンデスケド<H>
|
||||||
|
%%【略】
|
||||||
|
%<EOT>
|
||||||
39
docs/outline.md
Normal file
39
docs/outline.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# speech-to-text-pipeline
|
||||||
|
|
||||||
|
音声データをテキストデータへ変換し最終成果物を出力する
|
||||||
|
|
||||||
|
## ユースケース
|
||||||
|
|
||||||
|
* 会議録の音声メモを入力して会議録を作成する
|
||||||
|
* 音声データからアクションプランや課題を抽出して、レポートを出力する
|
||||||
|
|
||||||
|
## 機能
|
||||||
|
|
||||||
|
* 音声フォーマット統一
|
||||||
|
* チャンク処理
|
||||||
|
* 音声前処理(ノイズ除去)
|
||||||
|
* 音声強調
|
||||||
|
* 話者分別機能(VAD)
|
||||||
|
* 文字起こし
|
||||||
|
* テキストファイル出力
|
||||||
|
* 制度評価
|
||||||
|
* 精度結果出力
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 実現する技術
|
||||||
|
|
||||||
|
**インフラ**
|
||||||
|
|
||||||
|
* GPU処理
|
||||||
|
* Modal
|
||||||
|
* Pipeライン
|
||||||
|
* 検討中
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 仕様
|
||||||
|
|
||||||
|
### 音声フォーマット統一
|
||||||
|
|
||||||
|
* Whisper系やgpt-4o-transcribeの両方ともmono / 16kHz / PCM16 が最適となる
|
||||||
11
examples/example_main.py
Normal file
11
examples/example_main.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(".env")
|
||||||
|
|
||||||
|
|
||||||
|
def example():
|
||||||
|
|
||||||
|
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
librosalibrosa
|
||||||
17
src/app.py
Normal file
17
src/app.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
from lib.custom_logger import get_logger
|
||||||
|
from app_status import AppStatus
|
||||||
|
from pipeline.app_pipeline import AppPipeline
|
||||||
|
|
||||||
|
def app_start():
|
||||||
|
logger = get_logger()
|
||||||
|
logger.info("Application started")
|
||||||
|
app_status = AppStatus()
|
||||||
|
app_status.request_id = "6cb2da8f-ffde-4af6-9c25-19513da40b2c"
|
||||||
|
logger.info(f"Input file path: {app_status.input_filepath}")
|
||||||
|
|
||||||
|
pipeline = AppPipeline()
|
||||||
|
pipeline.run()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
68
src/app_file_storage.py
Normal file
68
src/app_file_storage.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from app_status import AppStatus
|
||||||
|
from lib.custom_logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
class AppFileStorage:
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_output_dir(cls) -> str:
|
||||||
|
"""出力ディレクトリを作成してパスを返す"""
|
||||||
|
app_status = AppStatus()
|
||||||
|
base_dir = app_status.output_base_dir
|
||||||
|
request_id = app_status.request_id
|
||||||
|
if not request_id:
|
||||||
|
raise ValueError("Request ID is not set in AppStatus")
|
||||||
|
try:
|
||||||
|
output_dir = f"{base_dir}/{request_id}"
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
logger.info(f"Output directory created at: {output_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating output directory: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def copy_to_source(cls) -> str:
|
||||||
|
"""出力ディレクトリを作成してパスを返す"""
|
||||||
|
app_status = AppStatus()
|
||||||
|
# ファイル元のパス
|
||||||
|
source_file = app_status.input_filepath
|
||||||
|
# ファイルのコピー先ディレクトリ
|
||||||
|
destination_dir = app_status.source_dir
|
||||||
|
if not destination_dir:
|
||||||
|
raise ValueError("Source directory is not set in AppStatus")
|
||||||
|
try:
|
||||||
|
os.makedirs(destination_dir, exist_ok=True)
|
||||||
|
# ファイル名を取得してコピー先のフルパスを作成
|
||||||
|
filename = os.path.basename(source_file)
|
||||||
|
destination_file = os.path.join(destination_dir, filename)
|
||||||
|
# ファイルをコピー
|
||||||
|
shutil.copy2(source_file, destination_file)
|
||||||
|
logger.info(f"File copied to source directory: {destination_file}")
|
||||||
|
app_status.source_file = destination_file
|
||||||
|
return destination_file
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error copying file to source directory: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_source_file(cls) -> str:
|
||||||
|
app_status = AppStatus()
|
||||||
|
# ファイル元のパス
|
||||||
|
source_file = app_status.input_filepath
|
||||||
|
# ファイルのコピー先ディレクトリ
|
||||||
|
destination_dir = app_status.source_dir
|
||||||
|
if not destination_dir:
|
||||||
|
raise ValueError("Source directory is not set in AppStatus")
|
||||||
|
try:
|
||||||
|
os.makedirs(destination_dir, exist_ok=True)
|
||||||
|
# ファイル名を取得してコピー先のフルパスを作成
|
||||||
|
filename = os.path.basename(source_file)
|
||||||
|
destination_file = os.path.join(destination_dir, filename)
|
||||||
|
app_status.source_file = destination_file
|
||||||
|
return destination_file
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error copying file to source directory: {e}")
|
||||||
|
raise
|
||||||
85
src/app_status.py
Normal file
85
src/app_status.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
from lib.singleton import Singleton
|
||||||
|
|
||||||
|
class AppStatus(Singleton):
|
||||||
|
"""アプリケーションの状態を管理するシングルトンクラス"""
|
||||||
|
def __init__(self):
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return # すでに初期化済みなら何もしない
|
||||||
|
self.status = {}
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""状態をリセット"""
|
||||||
|
self.status.clear()
|
||||||
|
|
||||||
|
def set_status(self, key, value):
|
||||||
|
self.status[key] = value
|
||||||
|
|
||||||
|
def get_status(self, key, default=None):
|
||||||
|
return self.status.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_filepath(self)-> str:
|
||||||
|
"""入力音声ファイルのパス"""
|
||||||
|
return self.get_status('input_filepath')
|
||||||
|
|
||||||
|
@input_filepath.setter
|
||||||
|
def input_filepath(self, value:str):
|
||||||
|
"""入力音声ファイルのパス"""
|
||||||
|
self.set_status('input_filepath', value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_id(self) -> str:
|
||||||
|
"""リクエストID"""
|
||||||
|
return self.get_status('request_id')
|
||||||
|
|
||||||
|
@request_id.setter
|
||||||
|
def request_id(self, value: str):
|
||||||
|
"""リクエストID"""
|
||||||
|
self.set_status('request_id', value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_base_dir(self)-> str:
|
||||||
|
"""出力ディレクトリのベースパス"""
|
||||||
|
return self.get_status('output_base_dir', default='.output')
|
||||||
|
|
||||||
|
@output_base_dir.setter
|
||||||
|
def output_base_dir(self, value:str):
|
||||||
|
"""出力ディレクトリのベースパス"""
|
||||||
|
self.set_status('output_base_dir', value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_dir(self)-> str:
|
||||||
|
"""出力ディレクトリのパス"""
|
||||||
|
output_dir = f"{self.output_base_dir}/{self.request_id}"
|
||||||
|
return output_dir
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_dir(self)-> str:
|
||||||
|
"""ソースディレクトリのパス"""
|
||||||
|
source_dir = f"{self.output_base_dir}/{self.request_id}/source"
|
||||||
|
return source_dir
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunk_dir(self)-> str:
|
||||||
|
"""チャンクディレクトリのパス"""
|
||||||
|
chunk_dir = f"{self.output_base_dir}/{self.request_id}/chunk"
|
||||||
|
return chunk_dir
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_file(self)-> str:
|
||||||
|
"""ソースファイルのパス"""
|
||||||
|
return self.get_status('source_file')
|
||||||
|
|
||||||
|
@source_file.setter
|
||||||
|
def source_file(self, value:str):
|
||||||
|
"""ソースファイルのパス"""
|
||||||
|
self.set_status('source_file', value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unified_file(self)-> str:
|
||||||
|
"""統一ファイルのパス"""
|
||||||
|
return f"{self.output_dir}/unified.wav"
|
||||||
0
src/jobs/__init__.py
Normal file
0
src/jobs/__init__.py
Normal file
16
src/jobs/job_base.py
Normal file
16
src/jobs/job_base.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from lib.custom_logger import get_logger
|
||||||
|
from app_status import AppStatus
|
||||||
|
|
||||||
|
class JobBase():
|
||||||
|
"""ジョブの基底クラス"""
|
||||||
|
def __init__(self, name="JobBase"):
|
||||||
|
self.logger = get_logger()
|
||||||
|
self.name = name
|
||||||
|
self.status = AppStatus()
|
||||||
|
self.logger.info(f"{self.name} initialized")
|
||||||
|
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""ジョブの実行"""
|
||||||
|
self.logger.info(f"{self.name} execute called")
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
72
src/jobs/job_chunk_files.py
Normal file
72
src/jobs/job_chunk_files.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import os
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
|
||||||
|
class JobChunkFiles(JobBase):
|
||||||
|
"""音声ファイルをチャンクに分割するジョブ"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name=self.__class__.__name__)
|
||||||
|
self.description = "Chunk Audio Files Job"
|
||||||
|
|
||||||
|
def _chunk_ffmpeg(self, src, dst, segment_time: int = 1200, overlap: int = 2):
|
||||||
|
import subprocess, pathlib, math, json
|
||||||
|
|
||||||
|
out = pathlib.Path(dst)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 総尺(秒)を ffprobe で取得
|
||||||
|
dur = float(subprocess.check_output(
|
||||||
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", src],
|
||||||
|
text=True).strip())
|
||||||
|
|
||||||
|
step = segment_time - overlap # 次チャンクの開始 = 前チャンク開始 + step
|
||||||
|
if step <= 0:
|
||||||
|
raise ValueError("overlap は segment_time より小さくしてください")
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
manifest = []
|
||||||
|
start = 0.0
|
||||||
|
while start < dur:
|
||||||
|
end = min(start + segment_time, dur)
|
||||||
|
# ※ 精密カットしたいので -i の後ろに -ss/-to を置く
|
||||||
|
dst = out / f"{i:06d}.wav"
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", src,
|
||||||
|
"-ss", f"{start:.3f}",
|
||||||
|
"-to", f"{end:.3f}",
|
||||||
|
"-c", "copy", # WAVならcopyでOK(圧縮音源なら再エンコード推奨)
|
||||||
|
str(dst)
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
manifest.append({
|
||||||
|
"chunk_id": f"{i:06d}",
|
||||||
|
"abs_start": round(start, 3),
|
||||||
|
"abs_end": round(end, 3),
|
||||||
|
"overlap_right": overlap if end < dur else 0.0,
|
||||||
|
"path": str(dst)
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
start += step
|
||||||
|
|
||||||
|
# manifest を保存(後段で絶対時刻復元に使う)
|
||||||
|
(out.parent / "chunks.manifest.jsonl").write_text(
|
||||||
|
"\n".join(json.dumps(m, ensure_ascii=False) for m in manifest),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
self.logger.info(f"{self.name} execute started")
|
||||||
|
|
||||||
|
if os.path.exists(self.status.chunk_dir):
|
||||||
|
# すでに変換済み
|
||||||
|
self.logger.info(f"Audio already standardized: {self.status.unified_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
src = self.status.unified_file
|
||||||
|
dst = self.status.chunk_dir
|
||||||
|
self._chunk_ffmpeg(src, dst)
|
||||||
|
return
|
||||||
24
src/jobs/job_get_request_id.py
Normal file
24
src/jobs/job_get_request_id.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
from app_file_storage import AppFileStorage
|
||||||
|
|
||||||
|
class JobGetRequestId(JobBase):
|
||||||
|
"""リクエストIDを取得するジョブ"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name=self.__class__.__name__)
|
||||||
|
self.description = "Get Request ID Job"
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
self.logger.info(f"{self.name} execute started")
|
||||||
|
if self.status.request_id:
|
||||||
|
self.logger.info(f"Request ID already set: {self.status.request_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
self.status.request_id = request_id
|
||||||
|
self.logger.info(f"Obtained request ID: {request_id}")
|
||||||
|
# request_idファイルを生成する
|
||||||
|
AppFileStorage.create_output_dir()
|
||||||
|
|
||||||
|
return
|
||||||
23
src/jobs/job_set_soruce_file.py
Normal file
23
src/jobs/job_set_soruce_file.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import app_status
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
from app_file_storage import AppFileStorage
|
||||||
|
|
||||||
|
class JobSetSourceFile(JobBase):
|
||||||
|
"""ソースファイルを設定するジョブ"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name=self.__class__.__name__)
|
||||||
|
self.description = "Set Source File Job"
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
self.logger.info(f"{self.name} execute started")
|
||||||
|
# outputにsourceフォルダが存在する場合
|
||||||
|
if os.path.exists(self.status.source_dir):
|
||||||
|
self.logger.info(f"Source directory already set: {self.status.source_dir}")
|
||||||
|
AppFileStorage.set_source_file()
|
||||||
|
else:
|
||||||
|
self.logger.info("Source directory is not set")
|
||||||
|
# ソースファイルをコピー
|
||||||
|
AppFileStorage.copy_to_source()
|
||||||
28
src/jobs/job_standardize_format.py
Normal file
28
src/jobs/job_standardize_format.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
|
||||||
|
class JobStandardizeFormat(JobBase):
|
||||||
|
"""音声ファイルのフォーマットを標準化するジョブ"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name=self.__class__.__name__)
|
||||||
|
self.description = "Standardize Audio Format Job"
|
||||||
|
|
||||||
|
def _convert_ffmpeg(self, src, dst):
|
||||||
|
import subprocess, pathlib
|
||||||
|
pathlib.Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = ["ffmpeg","-y","-i",src,"-ac","1","-ar","16000","-c:a","pcm_s16le",dst]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
self.logger.info(f"{self.name} execute started")
|
||||||
|
|
||||||
|
if os.path.exists(self.status.unified_file):
|
||||||
|
# すでに変換済み
|
||||||
|
self.logger.info(f"Audio already standardized: {self.status.unified_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
src = self.status.source_file
|
||||||
|
dst = self.status.unified_file
|
||||||
|
# フォーマット変換処理(WAV mono / 16kHz / PCM16)
|
||||||
|
self._convert_ffmpeg(src, dst)
|
||||||
|
return
|
||||||
82
src/jobs/job_visualize_audio.py
Normal file
82
src/jobs/job_visualize_audio.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import librosa
|
||||||
|
import librosa.display
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
from app_file_storage import AppFileStorage
|
||||||
|
|
||||||
|
|
||||||
|
class JobVisualizeAudio(JobBase):
|
||||||
|
"""
|
||||||
|
音声の波形とスペクトログラムを可視化するジョブ (CPU)
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name=self.__class__.__name__)
|
||||||
|
self.description = "Visualize Audio (waveform & spectrogram)"
|
||||||
|
|
||||||
|
def get_visualization(self, audio_path: str, out_dir: str,
|
||||||
|
n_fft: int = 1024, hop_length: int = 256):
|
||||||
|
|
||||||
|
self.logger.info(f"{self.name} started: {audio_path}")
|
||||||
|
out = Path(out_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) 音声読み込み(SRそのまま)
|
||||||
|
y, sr = librosa.load(audio_path, sr=None, mono=True)
|
||||||
|
dur = y.shape[0] / sr
|
||||||
|
self.logger.info(f"loaded: sr={sr}, dur={dur:.2f}s, samples={len(y)}")
|
||||||
|
|
||||||
|
# 2) 波形
|
||||||
|
plt.figure(figsize=(12, 3))
|
||||||
|
librosa.display.waveshow(y, sr=sr)
|
||||||
|
plt.title("Waveform")
|
||||||
|
plt.xlabel("Time (s)")
|
||||||
|
plt.ylabel("Amplitude")
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(out / "waveform.png", dpi=150)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# 3) スペクトログラム
|
||||||
|
D = np.abs(librosa.stft(y, n_fft=n_fft, hop_length=hop_length))
|
||||||
|
DB = librosa.amplitude_to_db(D, ref=np.max)
|
||||||
|
plt.figure(figsize=(12, 4))
|
||||||
|
librosa.display.specshow(DB, sr=sr, hop_length=hop_length,
|
||||||
|
x_axis="time", y_axis="log")
|
||||||
|
plt.colorbar(format="%+2.0f dB")
|
||||||
|
plt.title("Spectrogram (dB)")
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(out / "spectrogram.png", dpi=150)
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# 4) サマリ保存
|
||||||
|
summary = {
|
||||||
|
"audio_path": str(audio_path),
|
||||||
|
"sr": sr,
|
||||||
|
"duration_sec": dur,
|
||||||
|
"outputs": {
|
||||||
|
"waveform": str(out / "waveform.png"),
|
||||||
|
"spectrogram": str(out / "spectrogram.png"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(out / "visualize_summary.json").write_text(
|
||||||
|
json.dumps(summary, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
self.logger.info(f"{self.name} done: results under {out}")
|
||||||
|
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
self.logger.info(f"{self.name} execute started")
|
||||||
|
|
||||||
|
if os.path.exists(f"{self.status.output_dir}/unified"):
|
||||||
|
# すでに可視化済み
|
||||||
|
self.logger.info(f"Visualization already done: {self.status.output_dir}/unified")
|
||||||
|
return
|
||||||
|
|
||||||
|
audio_path = self.status.unified_file
|
||||||
|
self.get_visualization(audio_path, f"{self.status.output_dir}/unified")
|
||||||
|
return
|
||||||
10
src/lib/__init__.py
Normal file
10
src/lib/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
This module provides the pengent library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .custom_logger import get_logger, CustomLogger
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_logger",
|
||||||
|
"CustomLogger",
|
||||||
|
]
|
||||||
2
src/lib/common.py
Normal file
2
src/lib/common.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
56
src/lib/custom_logger.py
Normal file
56
src/lib/custom_logger.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
import functools
|
||||||
|
from .singleton import Singleton
|
||||||
|
|
||||||
|
class CustomLogger(Singleton):
|
||||||
|
"""
|
||||||
|
Singleton logger class that initializes a logger with a specified name and log file.
|
||||||
|
It provides a method to log entry and exit of functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name='main', log_file=None, level=logging.INFO):
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return # すでに初期化済みなら何もしない
|
||||||
|
# self.logger.setLevel(level)
|
||||||
|
|
||||||
|
self.logger = logging.getLogger(name)
|
||||||
|
self.logger.setLevel(level)
|
||||||
|
self.logger.propagate = False
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s %(levelname)s [%(filename)s:%(lineno)3d]: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setFormatter(formatter)
|
||||||
|
self.logger.addHandler(ch)
|
||||||
|
|
||||||
|
# File handler
|
||||||
|
if log_file:
|
||||||
|
fh = logging.FileHandler(log_file, encoding='utf-8')
|
||||||
|
fh.setFormatter(formatter)
|
||||||
|
self.logger.addHandler(fh)
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(self):
|
||||||
|
return self.logger
|
||||||
|
|
||||||
|
def log_entry_exit(self, func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
self.logger.info(f"Enter: {func.__qualname__}")
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
self.logger.info(f"Exit: {func.__qualname__}")
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name='main', log_file=None, level=logging.INFO):
|
||||||
|
custom_logger = CustomLogger(name, log_file, level)
|
||||||
|
return custom_logger.get_logger()
|
||||||
20
src/lib/singleton.py
Normal file
20
src/lib/singleton.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Singleton pattern implementation in Python.
|
||||||
|
This implementation is thread-safe and ensures that only one instance of the class is created.
|
||||||
|
|
||||||
|
Singleton が提供するのは「同じインスタンスを返す仕組み」
|
||||||
|
* __init__() は毎回呼ばれる(多くの人が意図しない動作)
|
||||||
|
* __init__の2回目は_initialized というフラグは 使う側で管理する必要がある。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class Singleton(object):
|
||||||
|
_instances = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
if cls not in cls._instances:
|
||||||
|
with cls._lock:
|
||||||
|
if cls not in cls._instances: # ダブルチェック
|
||||||
|
cls._instances[cls] = super(Singleton, cls).__new__(cls)
|
||||||
|
return cls._instances[cls]
|
||||||
35
src/main.py
Normal file
35
src/main.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from lib.custom_logger import get_logger
|
||||||
|
from app_status import AppStatus
|
||||||
|
from app import app_start
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Speech to Text Pipeline")
|
||||||
|
parser.add_argument("filepath", type=str, help="Path to the audio file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
if not os.path.isfile(args.filepath):
|
||||||
|
logger.error(f"File not found: {args.filepath}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ファイルの拡張子が音声データ(FFMPEGでwavに変換可能)であることを確認
|
||||||
|
valid_extensions = ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a']
|
||||||
|
if not any(args.filepath.lower().endswith(ext) for ext in valid_extensions):
|
||||||
|
logger.error("Invalid file format. Supported formats are: " + ", ".join(valid_extensions))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info(f"Processing file: {args.filepath}")
|
||||||
|
# ここに音声認識処理を追加
|
||||||
|
|
||||||
|
app_status = AppStatus()
|
||||||
|
app_status.reset()
|
||||||
|
app_status.input_filepath = args.filepath
|
||||||
|
app_start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
19
src/pipeline/app_pipeline.py
Normal file
19
src/pipeline/app_pipeline.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from pipeline.pipeline_base import PipelineBase
|
||||||
|
from jobs.job_get_request_id import JobGetRequestId
|
||||||
|
from jobs.job_set_soruce_file import JobSetSourceFile
|
||||||
|
from jobs.job_standardize_format import JobStandardizeFormat
|
||||||
|
from jobs.job_visualize_audio import JobVisualizeAudio
|
||||||
|
from jobs.job_chunk_files import JobChunkFiles
|
||||||
|
|
||||||
|
class AppPipeline(PipelineBase):
|
||||||
|
"""アプリケーションのパイプライン"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.logger.info("AppPipeline initialized")
|
||||||
|
self.add_job(JobGetRequestId())
|
||||||
|
self.add_job(JobSetSourceFile())
|
||||||
|
self.add_job(JobStandardizeFormat())
|
||||||
|
self.add_job(JobChunkFiles())
|
||||||
|
self
|
||||||
|
|
||||||
|
|
||||||
17
src/pipeline/pipeline_base.py
Normal file
17
src/pipeline/pipeline_base.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from typing import List
|
||||||
|
from jobs.job_base import JobBase
|
||||||
|
from lib.custom_logger import get_logger
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
class PipelineBase:
|
||||||
|
"""Pipelineの基本クラス"""
|
||||||
|
def __init__(self):
|
||||||
|
self.jobs:List[JobBase] = []
|
||||||
|
self.logger = get_logger()
|
||||||
|
|
||||||
|
def add_job(self, job: JobBase):
|
||||||
|
self.jobs.append(job)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
for job in self.jobs:
|
||||||
|
job.execute()
|
||||||
Loading…
x
Reference in New Issue
Block a user