datashare-asr-worker 0.6.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datashare_asr_worker-0.6.10/.gitignore +21 -0
- datashare_asr_worker-0.6.10/Dockerfile +83 -0
- datashare_asr_worker-0.6.10/PKG-INFO +36 -0
- datashare_asr_worker-0.6.10/README.md +7 -0
- datashare_asr_worker-0.6.10/asr_worker/__init__.py +0 -0
- datashare_asr_worker-0.6.10/asr_worker/__main__.py +9 -0
- datashare_asr_worker-0.6.10/asr_worker/activities.py +439 -0
- datashare_asr_worker-0.6.10/asr_worker/cli/__init__.py +35 -0
- datashare_asr_worker-0.6.10/asr_worker/cli/models.py +13 -0
- datashare_asr_worker-0.6.10/asr_worker/cli/utils.py +35 -0
- datashare_asr_worker-0.6.10/asr_worker/config.py +24 -0
- datashare_asr_worker-0.6.10/asr_worker/constants.py +41 -0
- datashare_asr_worker-0.6.10/asr_worker/dependencies.py +37 -0
- datashare_asr_worker-0.6.10/asr_worker/objects.py +79 -0
- datashare_asr_worker-0.6.10/asr_worker/utils.py +11 -0
- datashare_asr_worker-0.6.10/asr_worker/workflows.py +106 -0
- datashare_asr_worker-0.6.10/entrypoints/cpu_worker.sh +8 -0
- datashare_asr_worker-0.6.10/entrypoints/inference_gpu_worker.sh +6 -0
- datashare_asr_worker-0.6.10/entrypoints/io_worker.sh +8 -0
- datashare_asr_worker-0.6.10/pyproject.toml +160 -0
- datashare_asr_worker-0.6.10/uv.dist.lock +3527 -0
- datashare_asr_worker-0.6.10/uv.lock +3553 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
FROM python:3.13-slim-trixie AS builder
|
|
2
|
+
|
|
3
|
+
RUN apt update && \
|
|
4
|
+
apt install -y --no-install-recommends build-essential g++ && \
|
|
5
|
+
rm -rf /var/lib/apt/lists/*
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
FROM python:3.13-slim-trixie AS asr-worker-builder
|
|
9
|
+
|
|
10
|
+
ENV PYTHONUNBUFFERED=1
|
|
11
|
+
ENV UV_HTTP_TIMEOUT=300
|
|
12
|
+
ENV UV_LINK_MODE=copy
|
|
13
|
+
ENV UV_COMPILE_BYTECODE=1
|
|
14
|
+
ENV UV_NO_DEV=1
|
|
15
|
+
|
|
16
|
+
COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/
|
|
17
|
+
|
|
18
|
+
RUN apt-get update && \
|
|
19
|
+
apt-get install -y ffmpeg libavcodec-dev libavformat-dev libavutil-dev
|
|
20
|
+
|
|
21
|
+
WORKDIR /app
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FROM asr-worker-builder AS io-worker
|
|
25
|
+
# Install deps first to optimize layer cache
|
|
26
|
+
RUN --mount=type=cache,target=~/.cache/uv \
|
|
27
|
+
--mount=type=bind,source=uv.dist.lock,target=uv.lock \
|
|
28
|
+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
29
|
+
uv sync -v --frozen --no-editable --no-install-project --extra cpu
|
|
30
|
+
|
|
31
|
+
# Then copy code
|
|
32
|
+
ADD uv.dist.lock ./uv.lock
|
|
33
|
+
ADD pyproject.toml README.md ./
|
|
34
|
+
ADD asr_worker ./asr_worker/
|
|
35
|
+
ADD entrypoints/io_worker.sh ./entrypoints/io_worker.sh
|
|
36
|
+
|
|
37
|
+
# Then install service
|
|
38
|
+
|
|
39
|
+
RUN --mount=type=cache,target=~/.cache/uv uv sync -v --frozen --no-editable --extra cpu
|
|
40
|
+
RUN rm -rf ~/.cache/pip
|
|
41
|
+
|
|
42
|
+
ENTRYPOINT ["entrypoints/io_worker.sh"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
FROM asr-worker-builder AS cpu-worker
|
|
46
|
+
# Install deps first to optimize layer cache
|
|
47
|
+
RUN --mount=type=cache,target=~/.cache/uv \
|
|
48
|
+
--mount=type=bind,source=uv.dist.lock,target=uv.lock \
|
|
49
|
+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
50
|
+
uv sync -v --frozen --no-editable --no-install-project --extra preprocessing --extra cpu
|
|
51
|
+
|
|
52
|
+
# Then copy code
|
|
53
|
+
ADD uv.dist.lock ./uv.lock
|
|
54
|
+
ADD pyproject.toml README.md ./
|
|
55
|
+
ADD asr_worker ./asr_worker/
|
|
56
|
+
ADD entrypoints/cpu_worker.sh ./entrypoints/cpu_worker.sh
|
|
57
|
+
|
|
58
|
+
# Then install service
|
|
59
|
+
RUN --mount=type=cache,target=~/.cache/uv uv sync -v --frozen --no-editable --extra preprocessing --extra cpu
|
|
60
|
+
RUN rm -rf ~/.cache/pip
|
|
61
|
+
|
|
62
|
+
ENTRYPOINT ["entrypoints/cpu_worker.sh"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
FROM asr-worker-builder AS inference-gpu-worker
|
|
66
|
+
RUN apt-get update && apt-get install -y build-essential
|
|
67
|
+
# Install deps first to optimize layer cache
|
|
68
|
+
RUN --mount=type=cache,target=~/.cache/uv \
|
|
69
|
+
--mount=type=bind,source=uv.dist.lock,target=uv.lock \
|
|
70
|
+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
71
|
+
uv sync -v --frozen --no-editable --no-install-project --extra inference --extra gpu
|
|
72
|
+
|
|
73
|
+
# Then copy code
|
|
74
|
+
ADD uv.dist.lock ./uv.lock
|
|
75
|
+
ADD pyproject.toml README.md ./
|
|
76
|
+
ADD asr_worker ./asr_worker/
|
|
77
|
+
ADD entrypoints/inference_gpu_worker.sh ./entrypoints/inference_gpu_worker.sh
|
|
78
|
+
|
|
79
|
+
# Then install service
|
|
80
|
+
RUN --mount=type=cache,target=~/.cache/uv uv sync -v --frozen --no-editable --extra inference --extra gpu
|
|
81
|
+
RUN rm -rf ~/.cache/pip
|
|
82
|
+
|
|
83
|
+
ENTRYPOINT ["entrypoints/inference_gpu_worker.sh"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datashare-asr-worker
|
|
3
|
+
Version: 0.6.10
|
|
4
|
+
Summary: ASR Temporal task
|
|
5
|
+
Author-email: Clément Doumouro <cdoumouro@icij.org>, Clément Doumouro <clement.doumouro@gmail.com>, Lion Summerbell <lsummerbell@icij.org>
|
|
6
|
+
Requires-Python: <3.13,>=3.11.0
|
|
7
|
+
Requires-Dist: datashare-python~=0.8.4
|
|
8
|
+
Provides-Extra: cli
|
|
9
|
+
Requires-Dist: caul==0.5.1; extra == 'cli'
|
|
10
|
+
Provides-Extra: cpu
|
|
11
|
+
Requires-Dist: caul==0.5.1; extra == 'cpu'
|
|
12
|
+
Requires-Dist: torch==2.10.0; extra == 'cpu'
|
|
13
|
+
Requires-Dist: torchaudio==2.10.0; extra == 'cpu'
|
|
14
|
+
Requires-Dist: torchcodec==0.10.0; extra == 'cpu'
|
|
15
|
+
Provides-Extra: gpu
|
|
16
|
+
Requires-Dist: cuda-bindings==12.9.4; (sys_platform == 'linux') and extra == 'gpu'
|
|
17
|
+
Requires-Dist: torch==2.10.0+cu129; (sys_platform == 'linux') and extra == 'gpu'
|
|
18
|
+
Requires-Dist: torchaudio==2.10.0+cu129; (sys_platform == 'linux') and extra == 'gpu'
|
|
19
|
+
Requires-Dist: torchcodec==0.10.0+cu129; (sys_platform == 'linux') and extra == 'gpu'
|
|
20
|
+
Provides-Extra: inference
|
|
21
|
+
Requires-Dist: caul[nemo]==0.5.1; extra == 'inference'
|
|
22
|
+
Requires-Dist: kaldialign==0.9.3; extra == 'inference'
|
|
23
|
+
Requires-Dist: ml-dtypes==0.5.4; extra == 'inference'
|
|
24
|
+
Requires-Dist: numpy==2.3.0; extra == 'inference'
|
|
25
|
+
Requires-Dist: pyarrow==20.0.0; extra == 'inference'
|
|
26
|
+
Provides-Extra: preprocessing
|
|
27
|
+
Requires-Dist: caul==0.5.1; extra == 'preprocessing'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ASR Worker for Temporal
|
|
31
|
+
|
|
32
|
+
Module for starting an ASR worker process using Temporal (https://temporal.io)
|
|
33
|
+
task management. Transcription is performed by https://github.com/ICIJ/caul.
|
|
34
|
+
You'll need a Temporal server running somewhere to use the worker, which can
|
|
35
|
+
be passed to `asr_worker.worker.run_asr_worker` through the `target_host`
|
|
36
|
+
parameter.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# ASR Worker for Temporal
|
|
2
|
+
|
|
3
|
+
Module for starting an ASR worker process using Temporal (https://temporal.io)
|
|
4
|
+
task management. Transcription is performed by https://github.com/ICIJ/caul.
|
|
5
|
+
You'll need a Temporal server running somewhere to use the worker, which can
|
|
6
|
+
be passed to `asr_worker.worker.run_asr_worker` through the `target_host`
|
|
7
|
+
parameter.
|
|
File without changes
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from asyncio import AbstractEventLoop
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterable, Iterable
|
|
7
|
+
from itertools import tee
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from caul.objects import ASRResult, PreprocessedInput
|
|
12
|
+
from caul.tasks import (
|
|
13
|
+
InferenceRunner,
|
|
14
|
+
ParakeetPostprocessorConfig,
|
|
15
|
+
ParakeetPreprocessorConfig,
|
|
16
|
+
Postprocessor,
|
|
17
|
+
Preprocessor,
|
|
18
|
+
)
|
|
19
|
+
from datashare_python.dependencies import lifespan_worker_config
|
|
20
|
+
from datashare_python.objects import (
|
|
21
|
+
DocArtifact,
|
|
22
|
+
Document,
|
|
23
|
+
DocumentLocation,
|
|
24
|
+
FilesystemDocument,
|
|
25
|
+
)
|
|
26
|
+
from datashare_python.types_ import ProgressRateHandler, RawProgressHandler
|
|
27
|
+
from datashare_python.utils import (
|
|
28
|
+
ActivityWithProgress,
|
|
29
|
+
activity_contextual_id,
|
|
30
|
+
activity_defn,
|
|
31
|
+
debuggable_name,
|
|
32
|
+
safe_dir,
|
|
33
|
+
to_raw_progress,
|
|
34
|
+
write_artifact,
|
|
35
|
+
)
|
|
36
|
+
from icij_common.es import (
|
|
37
|
+
DOC_CONTENT_TYPE,
|
|
38
|
+
DOC_LANGUAGE,
|
|
39
|
+
DOC_METADATA,
|
|
40
|
+
DOC_PATH,
|
|
41
|
+
DOC_ROOT_ID,
|
|
42
|
+
ES_DOCUMENT_TYPE,
|
|
43
|
+
HITS,
|
|
44
|
+
QUERY,
|
|
45
|
+
ESClient,
|
|
46
|
+
and_query,
|
|
47
|
+
has_type,
|
|
48
|
+
)
|
|
49
|
+
from icij_common.iter_utils import async_batches
|
|
50
|
+
from icij_common.pydantic_utils import safe_copy
|
|
51
|
+
from pydantic import TypeAdapter
|
|
52
|
+
|
|
53
|
+
from asr_worker.utils import read_jsonl
|
|
54
|
+
|
|
55
|
+
from .config import ASRWorkerConfig
|
|
56
|
+
from .constants import (
|
|
57
|
+
POSTPROCESS_ACTIVITY,
|
|
58
|
+
PREPROCESS_ACTIVITY,
|
|
59
|
+
RUN_INFERENCE_ACTIVITY,
|
|
60
|
+
SEARCH_AUDIOS_ACTIVITY,
|
|
61
|
+
SUPPORTED_CONTENT_TYPES,
|
|
62
|
+
TRANSCRIPTION_METADATA_KEY,
|
|
63
|
+
TRANSCRIPTION_METADATA_VALUE,
|
|
64
|
+
)
|
|
65
|
+
from .dependencies import lifespan_es_client
|
|
66
|
+
from .objects import InferenceRunnerConfig, Transcription
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
_BASE_WEIGHT = 1.0
|
|
71
|
+
_SEARCH_AUDIOS_WEIGHT = _BASE_WEIGHT * 2
|
|
72
|
+
_PREPROCESS_WEIGHT = 5 * _BASE_WEIGHT
|
|
73
|
+
_INFERENCE_WEIGHT = 10 * _PREPROCESS_WEIGHT
|
|
74
|
+
|
|
75
|
+
_LIST_OF_PATH_ADAPTER = TypeAdapter(list[Path])
|
|
76
|
+
_INFERENCE_CONFIG_TYPE_ADAPTER = TypeAdapter(InferenceRunnerConfig)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ASRActivities(ActivityWithProgress):
|
|
80
|
+
@activity_defn(name=SEARCH_AUDIOS_ACTIVITY, progress_weight=_SEARCH_AUDIOS_WEIGHT)
|
|
81
|
+
async def search_audio_paths(
|
|
82
|
+
self, project: str, query: dict[str, Any], batch_size: int
|
|
83
|
+
) -> list[Path]:
|
|
84
|
+
es_client = lifespan_es_client()
|
|
85
|
+
worker_config = cast(ASRWorkerConfig, lifespan_worker_config())
|
|
86
|
+
batch_dir_name = activity_contextual_id()
|
|
87
|
+
workdir = worker_config.workdir
|
|
88
|
+
output_dir = workdir / batch_dir_name
|
|
89
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
batch_paths = [
|
|
91
|
+
p.relative_to(workdir)
|
|
92
|
+
async for p in search_audio_paths_act(
|
|
93
|
+
project,
|
|
94
|
+
es_client,
|
|
95
|
+
query,
|
|
96
|
+
config=worker_config,
|
|
97
|
+
output_dir=output_dir,
|
|
98
|
+
batch_size=batch_size,
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
return batch_paths
|
|
102
|
+
|
|
103
|
+
@activity_defn(name=PREPROCESS_ACTIVITY, progress_weight=_PREPROCESS_WEIGHT)
|
|
104
|
+
def preprocess(
|
|
105
|
+
self, audio_batch: Path, config: ParakeetPreprocessorConfig
|
|
106
|
+
) -> list[Path]:
|
|
107
|
+
# TODO: this shouldn't be necessary, fix this bug
|
|
108
|
+
worker_config = cast(ASRWorkerConfig, lifespan_worker_config())
|
|
109
|
+
workdir = worker_config.workdir
|
|
110
|
+
# TODO: implement caching
|
|
111
|
+
preprocessor = Preprocessor.from_config(config)
|
|
112
|
+
contextual_id = activity_contextual_id()
|
|
113
|
+
output_dir = workdir / contextual_id
|
|
114
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
audio_batch = workdir / audio_batch
|
|
116
|
+
with preprocessor:
|
|
117
|
+
batch_paths = preprocess_act(
|
|
118
|
+
preprocessor,
|
|
119
|
+
audio_batch,
|
|
120
|
+
worker_config=worker_config,
|
|
121
|
+
output_dir=output_dir,
|
|
122
|
+
)
|
|
123
|
+
batches = [p.relative_to(workdir) for p in batch_paths]
|
|
124
|
+
return batches
|
|
125
|
+
|
|
126
|
+
@activity_defn(name=RUN_INFERENCE_ACTIVITY, progress_weight=_INFERENCE_WEIGHT)
|
|
127
|
+
async def infer(
|
|
128
|
+
self,
|
|
129
|
+
preprocessed_inputs: list[Path],
|
|
130
|
+
project: str,
|
|
131
|
+
config: InferenceRunnerConfig,
|
|
132
|
+
*,
|
|
133
|
+
progress: ProgressRateHandler | None = None,
|
|
134
|
+
) -> list[Path]:
|
|
135
|
+
# TODO: fix this temporal by, we shouldn't have to reload
|
|
136
|
+
config = _INFERENCE_CONFIG_TYPE_ADAPTER.validate_python(config)
|
|
137
|
+
worker_config = cast(ASRWorkerConfig, lifespan_worker_config())
|
|
138
|
+
workdir = worker_config.workdir
|
|
139
|
+
contextual_id = activity_contextual_id()
|
|
140
|
+
output_dir = workdir / project / contextual_id
|
|
141
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
preprocessed_inputs = _LIST_OF_PATH_ADAPTER.validate_python(preprocessed_inputs)
|
|
143
|
+
preprocessed_inputs = [workdir / p for p in preprocessed_inputs]
|
|
144
|
+
if progress is not None:
|
|
145
|
+
progress = to_raw_progress(progress, max_progress=len(preprocessed_inputs))
|
|
146
|
+
inference_runner = InferenceRunner.from_config(config)
|
|
147
|
+
logger.info("loading model %s", config.model)
|
|
148
|
+
with inference_runner:
|
|
149
|
+
logger.info(
|
|
150
|
+
"model loaded, starting inference on %s audio chunks !",
|
|
151
|
+
len(preprocessed_inputs),
|
|
152
|
+
)
|
|
153
|
+
inference_res = infer_act(
|
|
154
|
+
inference_runner,
|
|
155
|
+
preprocessed_inputs,
|
|
156
|
+
output_dir=output_dir,
|
|
157
|
+
progress=progress,
|
|
158
|
+
)
|
|
159
|
+
inference_res = [p.relative_to(workdir) async for p in inference_res]
|
|
160
|
+
return inference_res
|
|
161
|
+
|
|
162
|
+
@activity_defn(name=POSTPROCESS_ACTIVITY, progress_weight=_BASE_WEIGHT)
|
|
163
|
+
def postprocess(
|
|
164
|
+
self,
|
|
165
|
+
inference_results: list[Path],
|
|
166
|
+
audio_batch: Path,
|
|
167
|
+
config: ParakeetPostprocessorConfig,
|
|
168
|
+
project: str,
|
|
169
|
+
*,
|
|
170
|
+
progress: ProgressRateHandler | None = None,
|
|
171
|
+
) -> int:
|
|
172
|
+
# TODO: this shouldn't be necessary, fix this bug
|
|
173
|
+
config = ParakeetPostprocessorConfig.model_validate(config)
|
|
174
|
+
worker_config = cast(ASRWorkerConfig, lifespan_worker_config())
|
|
175
|
+
workdir = worker_config.workdir
|
|
176
|
+
audio_batch = workdir / audio_batch
|
|
177
|
+
artifacts_root = worker_config.artifacts_root
|
|
178
|
+
inference_results = _LIST_OF_PATH_ADAPTER.validate_python(inference_results)
|
|
179
|
+
inference_results = (
|
|
180
|
+
ASRResult.model_validate_json((workdir / p).read_text())
|
|
181
|
+
for p in inference_results
|
|
182
|
+
)
|
|
183
|
+
# TODO: implement caching
|
|
184
|
+
postprocessor = Postprocessor.from_config(config)
|
|
185
|
+
with postprocessor:
|
|
186
|
+
docs = (
|
|
187
|
+
FilesystemDocument.model_validate(fs_doc)
|
|
188
|
+
for fs_doc in read_jsonl(audio_batch)
|
|
189
|
+
)
|
|
190
|
+
doc_ids = [doc.id for doc in docs]
|
|
191
|
+
if progress is not None:
|
|
192
|
+
progress = to_raw_progress(progress, max_progress=len(doc_ids))
|
|
193
|
+
return postprocess_act(
|
|
194
|
+
postprocessor,
|
|
195
|
+
inference_results,
|
|
196
|
+
doc_ids,
|
|
197
|
+
project=project,
|
|
198
|
+
artifacts_root=artifacts_root,
|
|
199
|
+
event_loop=self._event_loop,
|
|
200
|
+
progress=progress,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def search_audio_paths_act(
|
|
205
|
+
project: str,
|
|
206
|
+
es_client: ESClient,
|
|
207
|
+
query: dict[str, Any],
|
|
208
|
+
*,
|
|
209
|
+
config: ASRWorkerConfig,
|
|
210
|
+
output_dir: Path,
|
|
211
|
+
batch_size: int,
|
|
212
|
+
) -> AsyncIterable[Path]:
|
|
213
|
+
# TODO: supported content types should be args
|
|
214
|
+
docs = _search_audio_paths(
|
|
215
|
+
es_client, project, query, supported_content_types=SUPPORTED_CONTENT_TYPES
|
|
216
|
+
)
|
|
217
|
+
docs = create_symlinks_for_embedded_audios(docs, config)
|
|
218
|
+
async for p in write_audio_batches(docs, output_dir, batch_size):
|
|
219
|
+
yield p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def preprocess_act(
|
|
223
|
+
preprocessor: Preprocessor,
|
|
224
|
+
audio_batch: Path,
|
|
225
|
+
*,
|
|
226
|
+
worker_config: ASRWorkerConfig,
|
|
227
|
+
output_dir: Path,
|
|
228
|
+
) -> list[Path]:
|
|
229
|
+
docs_root = worker_config.docs_root
|
|
230
|
+
artifacts_root = worker_config.artifacts_root
|
|
231
|
+
workdir = worker_config.workdir
|
|
232
|
+
logger.debug("locating files...")
|
|
233
|
+
audios = (
|
|
234
|
+
FilesystemDocument.model_validate(fs_doc) for fs_doc in read_jsonl(audio_batch)
|
|
235
|
+
)
|
|
236
|
+
audios = (
|
|
237
|
+
fs_doc.locate(
|
|
238
|
+
original_root=docs_root, artifacts_root=artifacts_root, workdir=workdir
|
|
239
|
+
)
|
|
240
|
+
for fs_doc in audios
|
|
241
|
+
)
|
|
242
|
+
audios = (str(a) for a in audios)
|
|
243
|
+
# TODO: implement a caching strategy here, we could avoid processing files
|
|
244
|
+
# which have already been preprocessed
|
|
245
|
+
logger.debug("starting preprocessing...")
|
|
246
|
+
return list(_preprocess(preprocessor, audios, output_dir))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def infer_act(
|
|
250
|
+
inference_runner: InferenceRunner,
|
|
251
|
+
preprocessed_inputs: list[Path],
|
|
252
|
+
output_dir: Path,
|
|
253
|
+
event_loop: AbstractEventLoop | None = None,
|
|
254
|
+
progress: RawProgressHandler | None = None,
|
|
255
|
+
) -> AsyncIterable[Path]:
|
|
256
|
+
# Audios paths in the input are relative to the batch file directory
|
|
257
|
+
inputs = (
|
|
258
|
+
[
|
|
259
|
+
_relative_input(PreprocessedInput.model_validate(i), f.parent)
|
|
260
|
+
for i in read_jsonl(f)
|
|
261
|
+
]
|
|
262
|
+
for f in preprocessed_inputs
|
|
263
|
+
)
|
|
264
|
+
audio_paths, inputs = tee(inputs)
|
|
265
|
+
audio_paths = (i.metadata.preprocessed_file_path for b in audio_paths for i in b)
|
|
266
|
+
# TODO: implement caching
|
|
267
|
+
inference_results = await asyncio.to_thread(
|
|
268
|
+
_transcribe_as_list, inference_runner, list(inputs)
|
|
269
|
+
)
|
|
270
|
+
for res_i, (path, asr_res) in enumerate(
|
|
271
|
+
zip(audio_paths, inference_results, strict=True)
|
|
272
|
+
):
|
|
273
|
+
filename = f"{debuggable_name(path.name)}-transcript.json"
|
|
274
|
+
transcript_path = output_dir / safe_dir(filename) / filename
|
|
275
|
+
transcript_path.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
logger.debug(
|
|
277
|
+
"run inference for %s, writing result to %s", path, transcript_path
|
|
278
|
+
)
|
|
279
|
+
transcript_path.write_text(asr_res.model_dump_json())
|
|
280
|
+
yield transcript_path
|
|
281
|
+
if progress is not None and event_loop is not None:
|
|
282
|
+
await progress(res_i)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _transcribe_as_list(
|
|
286
|
+
inference_runner: InferenceRunner, inputs: Iterable[list[PreprocessedInput]]
|
|
287
|
+
) -> list[ASRResult]:
|
|
288
|
+
return list(inference_runner.process(inputs))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def postprocess_act(
|
|
292
|
+
postprocessor: Postprocessor,
|
|
293
|
+
inference_results: Iterable[ASRResult],
|
|
294
|
+
doc_ids: Iterable[str],
|
|
295
|
+
*,
|
|
296
|
+
artifacts_root: Path,
|
|
297
|
+
project: str,
|
|
298
|
+
event_loop: AbstractEventLoop | None = None,
|
|
299
|
+
progress: ProgressRateHandler | None = None,
|
|
300
|
+
) -> int:
|
|
301
|
+
transcriptions = postprocessor.process(inference_results)
|
|
302
|
+
# Strict is important here !
|
|
303
|
+
n_docs = 0
|
|
304
|
+
for i, (doc_id, asr_result) in enumerate(zip(doc_ids, transcriptions, strict=True)):
|
|
305
|
+
n_docs += 1
|
|
306
|
+
t_path = write_transcription(
|
|
307
|
+
doc_id, asr_result, artifacts_root=artifacts_root, project=project
|
|
308
|
+
)
|
|
309
|
+
logger.debug("wrote transcription for %s", t_path)
|
|
310
|
+
if progress is not None and event_loop is not None:
|
|
311
|
+
event_loop.run_until_complete(progress(i))
|
|
312
|
+
return n_docs
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _preprocess(
|
|
316
|
+
preprocessor: Preprocessor, audios: Iterable[str], output_dir: Path
|
|
317
|
+
) -> Iterable[Path]:
|
|
318
|
+
for batch_i, batch in enumerate(
|
|
319
|
+
preprocessor.process(audios, output_dir=output_dir)
|
|
320
|
+
):
|
|
321
|
+
# TODO: we might to create safe subdirs to avoid creating too many
|
|
322
|
+
# files in the same dir
|
|
323
|
+
batch_file = output_dir / f"{batch_i}.jsonl"
|
|
324
|
+
logger.debug("writing batch to %s", batch_file)
|
|
325
|
+
with batch_file.open("w") as f:
|
|
326
|
+
for processed in batch:
|
|
327
|
+
f.write(processed.model_dump_json() + "\n")
|
|
328
|
+
yield batch_file
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def write_transcription(
|
|
332
|
+
doc_id: str, asr_result: ASRResult, *, artifacts_root: Path, project: str
|
|
333
|
+
) -> Path:
|
|
334
|
+
result = Transcription.from_asr_handler_result(asr_result)
|
|
335
|
+
artifact_bytes = result.model_dump_json().encode()
|
|
336
|
+
artifact = DocArtifact(
|
|
337
|
+
project=project,
|
|
338
|
+
doc_id=doc_id,
|
|
339
|
+
filename=TRANSCRIPTION_METADATA_VALUE,
|
|
340
|
+
metadata_key=TRANSCRIPTION_METADATA_KEY,
|
|
341
|
+
artifact=artifact_bytes,
|
|
342
|
+
)
|
|
343
|
+
# TODO: if transcriptions are too large we could also serialize them
|
|
344
|
+
# as jsonl
|
|
345
|
+
rel_path = write_artifact(artifacts_root, artifact)
|
|
346
|
+
return rel_path
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _relative_input(
|
|
350
|
+
preprocess_input: PreprocessedInput, root: Path
|
|
351
|
+
) -> PreprocessedInput:
|
|
352
|
+
path = root / preprocess_input.metadata.preprocessed_file_path
|
|
353
|
+
update = {"preprocessed_file_path": path}
|
|
354
|
+
metadata = safe_copy(preprocess_input.metadata, update=update)
|
|
355
|
+
return PreprocessedInput(metadata=metadata) # noqa: F821
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def write_audio_batches(
|
|
359
|
+
docs: AsyncIterable[FilesystemDocument], root: Path, batch_size: int
|
|
360
|
+
) -> AsyncIterable[Path]:
|
|
361
|
+
batch_id = 0
|
|
362
|
+
async for batch in async_batches(docs, batch_size):
|
|
363
|
+
batch_path = root / f"{batch_id}.txt"
|
|
364
|
+
with batch_path.open("w") as f:
|
|
365
|
+
for fs_doc in batch:
|
|
366
|
+
f.write(f"{fs_doc.model_dump_json()}\n")
|
|
367
|
+
yield batch_path
|
|
368
|
+
batch_id += 1
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_DOC_TYPE_QUERY = has_type(type_field="type", type_value=ES_DOCUMENT_TYPE)
|
|
372
|
+
_DOC_CONTENT_SOURCES = [DOC_PATH, DOC_ROOT_ID, DOC_LANGUAGE, DOC_METADATA, DOC_PATH]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def _search_audio_paths(
|
|
376
|
+
es_client: ESClient,
|
|
377
|
+
project: str,
|
|
378
|
+
query: dict[str, Any],
|
|
379
|
+
supported_content_types: set[str],
|
|
380
|
+
) -> AsyncGenerator[FilesystemDocument, None]:
|
|
381
|
+
body = _with_audio_content(query, supported_content_types)
|
|
382
|
+
async for page in es_client.poll_search_pages(
|
|
383
|
+
index=project, body=body, sort="_doc:asc", _source_includes=_DOC_CONTENT_SOURCES
|
|
384
|
+
):
|
|
385
|
+
for hit in page[HITS][HITS]:
|
|
386
|
+
yield Document.from_es(hit).to_filesystem()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _content_type_query(supported_content_types: set[str]) -> dict[str, Any]:
|
|
390
|
+
content_type_query = {"terms": {DOC_CONTENT_TYPE: sorted(supported_content_types)}}
|
|
391
|
+
doc_type = has_type(type_field="type", type_value=ES_DOCUMENT_TYPE)
|
|
392
|
+
return and_query(content_type_query, doc_type)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _with_audio_content(
|
|
396
|
+
query: dict[str, Any], supported_content_types: set[str]
|
|
397
|
+
) -> dict[str, Any]:
|
|
398
|
+
type_query = _content_type_query(supported_content_types)
|
|
399
|
+
if not query:
|
|
400
|
+
return type_query
|
|
401
|
+
return and_query(query, type_query[QUERY])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def create_symlinks_for_embedded_audios(
|
|
405
|
+
docs: AsyncIterable[FilesystemDocument], config: ASRWorkerConfig
|
|
406
|
+
) -> AsyncIterable[FilesystemDocument]:
|
|
407
|
+
workdir = config.workdir
|
|
408
|
+
artifacts_root = config.artifacts_root
|
|
409
|
+
symlinks_dir = None
|
|
410
|
+
async for d in docs:
|
|
411
|
+
if d.location == DocumentLocation.ARTIFACTS:
|
|
412
|
+
if symlinks_dir is None:
|
|
413
|
+
symlinks_dir = workdir / d.index / "symlinks"
|
|
414
|
+
symlinks_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
artifact_path = artifacts_root / d.path
|
|
416
|
+
audio_ext = Path(d.resource_name).suffix
|
|
417
|
+
symlink_path = d.path.relative_to(Path(d.index))
|
|
418
|
+
symlink_path = symlinks_dir / f"{symlink_path}{audio_ext}"
|
|
419
|
+
symlink_path.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
with contextlib.suppress(FileExistsError):
|
|
421
|
+
os.symlink(artifact_path, symlink_path)
|
|
422
|
+
symlink = FilesystemDocument(
|
|
423
|
+
path=symlink_path.relative_to(workdir),
|
|
424
|
+
id=d.id,
|
|
425
|
+
location=DocumentLocation.WORKDIR,
|
|
426
|
+
index=d.index,
|
|
427
|
+
resource_name=d.resource_name,
|
|
428
|
+
)
|
|
429
|
+
yield symlink
|
|
430
|
+
else:
|
|
431
|
+
yield d
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
REGISTRY = [
|
|
435
|
+
ASRActivities.search_audio_paths,
|
|
436
|
+
ASRActivities.preprocess,
|
|
437
|
+
ASRActivities.infer,
|
|
438
|
+
ASRActivities.postprocess,
|
|
439
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import datashare_python
|
|
4
|
+
import typer
|
|
5
|
+
from datashare_python.cli import pretty_exc_callback, version_callback
|
|
6
|
+
from datashare_python.cli.utils import AsyncTyper
|
|
7
|
+
from icij_common.logging_utils import setup_loggers
|
|
8
|
+
|
|
9
|
+
import asr_worker
|
|
10
|
+
|
|
11
|
+
from .models import models_app
|
|
12
|
+
|
|
13
|
+
cli_app = AsyncTyper(
|
|
14
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
15
|
+
pretty_exceptions_enable=False,
|
|
16
|
+
)
|
|
17
|
+
cli_app.add_typer(models_app)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli_app.callback()
|
|
21
|
+
def main(
|
|
22
|
+
version: Annotated[ # noqa: ARG001
|
|
23
|
+
bool | None,
|
|
24
|
+
typer.Option("--version", callback=version_callback, is_eager=True),
|
|
25
|
+
] = None,
|
|
26
|
+
*,
|
|
27
|
+
pretty_exceptions: Annotated[ # noqa: ARG001
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--pretty-exceptions", callback=pretty_exc_callback, is_eager=True
|
|
31
|
+
),
|
|
32
|
+
] = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Datashare Python CLI."""
|
|
35
|
+
setup_loggers(["__main__", datashare_python.__name__, asr_worker.__name__])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from ..objects import available_models
|
|
2
|
+
from .utils import AsyncTyper
|
|
3
|
+
|
|
4
|
+
_MODELS = "models"
|
|
5
|
+
|
|
6
|
+
_LIST_AVAILABLE_MODELS_HELP = "list available ASR models by language"
|
|
7
|
+
|
|
8
|
+
models_app = AsyncTyper(name=_MODELS)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@models_app.async_command(help=_LIST_AVAILABLE_MODELS_HELP)
|
|
12
|
+
async def list_available() -> None:
|
|
13
|
+
print(available_models().model_dump_json(indent=2))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncTyper(typer.Typer):
|
|
12
|
+
def async_command(self, *args, **kwargs) -> Callable[[Callable], Callable]:
|
|
13
|
+
def decorator(async_func: Callable) -> Callable:
|
|
14
|
+
@wraps(async_func)
|
|
15
|
+
def sync_func(*_args, **_kwargs) -> Any:
|
|
16
|
+
res = asyncio.run(async_func(*_args, **_kwargs))
|
|
17
|
+
return res
|
|
18
|
+
|
|
19
|
+
self.command(*args, **kwargs)(sync_func)
|
|
20
|
+
return async_func
|
|
21
|
+
|
|
22
|
+
return decorator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def eprint(*args, **kwargs) -> None:
|
|
26
|
+
print(*args, file=sys.stderr, **kwargs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_concurrent(
|
|
30
|
+
fut: asyncio.Future, loop: asyncio.AbstractEventLoop
|
|
31
|
+
) -> concurrent.futures.Future:
|
|
32
|
+
async def wait() -> None:
|
|
33
|
+
await fut
|
|
34
|
+
|
|
35
|
+
return asyncio.run_coroutine_threadsafe(wait(), loop)
|