scribed 0.0.1__py3-none-any.whl
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.
- scribed/__init__.py +173 -0
- scribed/__main__.py +45 -0
- scribed/backends/__init__.py +13 -0
- scribed/backends/_template/__init__.py +1 -0
- scribed/backends/_template/adapter.py +48 -0
- scribed/backends/_template/config.py +33 -0
- scribed/backends/assemblyai/__init__.py +1 -0
- scribed/backends/assemblyai/adapter.py +117 -0
- scribed/backends/assemblyai/config.py +37 -0
- scribed/backends/deepgram/__init__.py +1 -0
- scribed/backends/deepgram/adapter.py +207 -0
- scribed/backends/deepgram/config.py +34 -0
- scribed/backends/elevenlabs/__init__.py +1 -0
- scribed/backends/elevenlabs/adapter.py +154 -0
- scribed/backends/elevenlabs/config.py +36 -0
- scribed/backends/faster_whisper/__init__.py +1 -0
- scribed/backends/faster_whisper/adapter.py +85 -0
- scribed/backends/faster_whisper/config.py +42 -0
- scribed/backends/google_speech/__init__.py +1 -0
- scribed/backends/google_speech/adapter.py +152 -0
- scribed/backends/google_speech/config.py +46 -0
- scribed/backends/groq/__init__.py +1 -0
- scribed/backends/groq/adapter.py +92 -0
- scribed/backends/groq/config.py +35 -0
- scribed/backends/openai/__init__.py +1 -0
- scribed/backends/openai/adapter.py +91 -0
- scribed/backends/openai/config.py +28 -0
- scribed/backends/vosk/__init__.py +1 -0
- scribed/backends/vosk/adapter.py +158 -0
- scribed/backends/vosk/config.py +39 -0
- scribed/backends/whisper/__init__.py +1 -0
- scribed/backends/whisper/adapter.py +85 -0
- scribed/backends/whisper/config.py +38 -0
- scribed/backends/whispercpp/__init__.py +1 -0
- scribed/backends/whispercpp/adapter.py +88 -0
- scribed/backends/whispercpp/config.py +41 -0
- scribed/base.py +352 -0
- scribed/catalog.py +325 -0
- scribed/credentials.py +244 -0
- scribed/data/SCHEMA.md +65 -0
- scribed/data/backends.json +622 -0
- scribed/data/skills/scribed/SKILL.md +57 -0
- scribed/data/skills/scribed-add-backend/SKILL.md +102 -0
- scribed/data/skills/scribed-choose-backend/SKILL.md +51 -0
- scribed/data/skills/scribed-install-backend/SKILL.md +67 -0
- scribed/install.py +343 -0
- scribed/make_backend.py +375 -0
- scribed/registry.py +221 -0
- scribed/services.py +122 -0
- scribed/status.py +303 -0
- scribed/tools.py +269 -0
- scribed/translation.py +107 -0
- scribed/util.py +189 -0
- scribed-0.0.1.dist-info/METADATA +213 -0
- scribed-0.0.1.dist-info/RECORD +58 -0
- scribed-0.0.1.dist-info/WHEEL +4 -0
- scribed-0.0.1.dist-info/entry_points.txt +2 -0
- scribed-0.0.1.dist-info/licenses/LICENSE +21 -0
scribed/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""scribed — one facade over many speech-to-text engines, plus a ledger to choose.
|
|
2
|
+
|
|
3
|
+
Transcription ("turn this audio into text") is solved a dozen different ways:
|
|
4
|
+
local engines (Whisper, faster-whisper, whisper.cpp, Vosk), fast cheap cloud APIs
|
|
5
|
+
(Groq, OpenAI), and feature-rich premium services (Deepgram, AssemblyAI, Google,
|
|
6
|
+
ElevenLabs) — each with its own install, API, pricing, latency, language coverage,
|
|
7
|
+
diarization support, and quirks. scribed gives you:
|
|
8
|
+
|
|
9
|
+
1. **A uniform facade.** Call :func:`transcribe` and get the same
|
|
10
|
+
:class:`~scribed.base.Transcript` back no matter which backend ran::
|
|
11
|
+
|
|
12
|
+
import scribed
|
|
13
|
+
t = scribed.transcribe("talk.mp3") # default (first installed) backend
|
|
14
|
+
print(t) # -> the transcript text
|
|
15
|
+
t = scribed.transcribe("talk.mp3", backend="deepgram", diarize=True)
|
|
16
|
+
print(t.srt) # -> SRT subtitles
|
|
17
|
+
for seg in t:
|
|
18
|
+
print(seg.start, seg.speaker, seg.text)
|
|
19
|
+
|
|
20
|
+
Convenience: :func:`transcribe_text` returns just the string.
|
|
21
|
+
|
|
22
|
+
2. **A ledger / gallery** of *every* engine we researched — not only the ones
|
|
23
|
+
with a working facade — so you can choose with eyes open::
|
|
24
|
+
|
|
25
|
+
scribed.catalog # the whole ledger
|
|
26
|
+
scribed.find(is_local=True, open_source=True) # filter it
|
|
27
|
+
scribed.find(diarization="yes", is_remote=True)
|
|
28
|
+
scribed.catalog.to_dataframe() # browse as a table
|
|
29
|
+
|
|
30
|
+
The ledger lives in data (``scribed/data/backends.json``), not code.
|
|
31
|
+
|
|
32
|
+
3. **Tools to build new facades.** The catalog is large; scribed ships a facade
|
|
33
|
+
for a curated subset and gives you the machinery (and a SKILL) to add any
|
|
34
|
+
other one in minutes::
|
|
35
|
+
|
|
36
|
+
from scribed.make_backend import scaffold_backend, validate_adapter
|
|
37
|
+
scaffold_backend("speechmatics") # generate a backend package from the ledger
|
|
38
|
+
validate_adapter("faster-whisper") # smoke-test an adapter end to end
|
|
39
|
+
|
|
40
|
+
Three tiers of access, from simplest to most powerful::
|
|
41
|
+
|
|
42
|
+
scribed.transcribe(audio) # facade, default backend
|
|
43
|
+
scribed.services.deepgram.transcribe(audio, diarize=True) # pick a backend
|
|
44
|
+
scribed.services.deepgram.adapter # raw engine adapter
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from scribed.base import (
|
|
48
|
+
AudioInput,
|
|
49
|
+
LEVELS,
|
|
50
|
+
Segment,
|
|
51
|
+
TimeSpan,
|
|
52
|
+
Transcript,
|
|
53
|
+
Word,
|
|
54
|
+
)
|
|
55
|
+
from scribed.catalog import BackendInfo, Catalog, catalog
|
|
56
|
+
from scribed.registry import (
|
|
57
|
+
get_config,
|
|
58
|
+
get_default_backend,
|
|
59
|
+
list_backends,
|
|
60
|
+
register_backend,
|
|
61
|
+
)
|
|
62
|
+
from scribed.services import ServiceCollection
|
|
63
|
+
from scribed.make_backend import (
|
|
64
|
+
BaseTranscriberAdapter,
|
|
65
|
+
make_segment,
|
|
66
|
+
make_word,
|
|
67
|
+
scaffold_backend,
|
|
68
|
+
validate_adapter,
|
|
69
|
+
)
|
|
70
|
+
from scribed.install import (
|
|
71
|
+
Requirements,
|
|
72
|
+
available_backends,
|
|
73
|
+
check,
|
|
74
|
+
doctor,
|
|
75
|
+
install,
|
|
76
|
+
requirements,
|
|
77
|
+
)
|
|
78
|
+
from scribed.status import (
|
|
79
|
+
backend_ids,
|
|
80
|
+
backend_info,
|
|
81
|
+
is_set_up,
|
|
82
|
+
is_tested,
|
|
83
|
+
names_with_sites,
|
|
84
|
+
status_table,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"transcribe",
|
|
89
|
+
"transcribe_text",
|
|
90
|
+
"services",
|
|
91
|
+
"catalog",
|
|
92
|
+
"find",
|
|
93
|
+
"list_backends",
|
|
94
|
+
"register_backend",
|
|
95
|
+
"get_default_backend",
|
|
96
|
+
"get_config",
|
|
97
|
+
"Transcript",
|
|
98
|
+
"Segment",
|
|
99
|
+
"Word",
|
|
100
|
+
"TimeSpan",
|
|
101
|
+
"AudioInput",
|
|
102
|
+
"LEVELS",
|
|
103
|
+
"BackendInfo",
|
|
104
|
+
"Catalog",
|
|
105
|
+
"ServiceCollection",
|
|
106
|
+
"BaseTranscriberAdapter",
|
|
107
|
+
"make_segment",
|
|
108
|
+
"make_word",
|
|
109
|
+
"scaffold_backend",
|
|
110
|
+
"validate_adapter",
|
|
111
|
+
"requirements",
|
|
112
|
+
"check",
|
|
113
|
+
"doctor",
|
|
114
|
+
"install",
|
|
115
|
+
"available_backends",
|
|
116
|
+
"Requirements",
|
|
117
|
+
"backend_ids",
|
|
118
|
+
"backend_info",
|
|
119
|
+
"status_table",
|
|
120
|
+
"names_with_sites",
|
|
121
|
+
"is_set_up",
|
|
122
|
+
"is_tested",
|
|
123
|
+
"__version__",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Derive the version from installed package metadata (the pyproject SSOT, which CI
|
|
127
|
+
# auto-bumps) so __version__ never drifts from a hardcoded literal.
|
|
128
|
+
from importlib.metadata import PackageNotFoundError as _PNFE, version as _version
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
__version__ = _version("scribed")
|
|
132
|
+
except _PNFE: # running from a source tree without install metadata
|
|
133
|
+
__version__ = "0.0.0+source"
|
|
134
|
+
del _version, _PNFE
|
|
135
|
+
|
|
136
|
+
#: Singleton service collection for per-backend access (``services.deepgram``).
|
|
137
|
+
services = ServiceCollection()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def transcribe(audio: AudioInput, *, backend: str = None, **kwargs) -> Transcript:
|
|
141
|
+
"""Transcribe audio with any backend, returning a normalized result.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
audio: A path, ``http(s)`` URL, ``bytes``, file-like object, or numpy
|
|
145
|
+
waveform.
|
|
146
|
+
backend: Backend id (see :func:`list_backends`). Defaults to the first
|
|
147
|
+
*installed* implemented backend (see :func:`get_default_backend`).
|
|
148
|
+
**kwargs: Normalized, backend-translated options (e.g. ``language``,
|
|
149
|
+
``diarize``, ``word_timestamps``). Unknown options for the chosen
|
|
150
|
+
backend are warned about and dropped.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A :class:`~scribed.base.Transcript` (``str(t)`` is the text; ``t.srt`` /
|
|
154
|
+
``t.vtt`` give subtitles; iterate it for segments).
|
|
155
|
+
"""
|
|
156
|
+
backend = backend or get_default_backend()
|
|
157
|
+
return services[backend].transcribe(audio, **kwargs)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def transcribe_text(audio: AudioInput, *, backend: str = None, **kwargs) -> str:
|
|
161
|
+
"""Like :func:`transcribe` but returns just the transcript text string."""
|
|
162
|
+
return transcribe(audio, backend=backend, **kwargs).text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find(**criteria) -> Catalog:
|
|
166
|
+
"""Filter the ledger; shorthand for :meth:`scribed.catalog.Catalog.filter`.
|
|
167
|
+
|
|
168
|
+
Example::
|
|
169
|
+
|
|
170
|
+
scribed.find(is_local=True, open_source=True, diarization="yes")
|
|
171
|
+
scribed.find(implemented=True) # only backends scribed can run now
|
|
172
|
+
"""
|
|
173
|
+
return catalog.filter(**criteria)
|
scribed/__main__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""scribed command-line interface.
|
|
3
|
+
|
|
4
|
+
Exposes the tools in :mod:`scribed.tools` as subcommands via ``argh``::
|
|
5
|
+
|
|
6
|
+
scribed transcribe talk.mp3 --backend faster-whisper --output srt
|
|
7
|
+
scribed backends --capability diarize
|
|
8
|
+
scribed find --local --free --diarization
|
|
9
|
+
scribed info deepgram
|
|
10
|
+
scribed scaffold speechmatics
|
|
11
|
+
scribed validate faster-whisper
|
|
12
|
+
scribed status
|
|
13
|
+
|
|
14
|
+
The CLI dependency (``argh``) is optional — ``import scribed`` stays dependency-free.
|
|
15
|
+
Install it with ``pip install 'scribed[cli]'``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
try:
|
|
21
|
+
import argh
|
|
22
|
+
except ImportError: # pragma: no cover - exercised only without the extra
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
sys.exit(
|
|
26
|
+
"The scribed CLI requires 'argh'. Install it with: pip install 'scribed[cli]'"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from scribed.tools import _dispatch_funcs
|
|
30
|
+
|
|
31
|
+
parser = argh.ArghParser()
|
|
32
|
+
parser.add_commands(_dispatch_funcs)
|
|
33
|
+
|
|
34
|
+
try: # optional shell tab-completion
|
|
35
|
+
import argcomplete
|
|
36
|
+
|
|
37
|
+
argcomplete.autocomplete(parser)
|
|
38
|
+
except ImportError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
parser.dispatch()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Implemented speech-to-text backends.
|
|
2
|
+
|
|
3
|
+
Each real backend is a subpackage with a ``config.py`` (``BACKEND_CONFIG``) and an
|
|
4
|
+
``adapter.py`` (``Adapter`` with a ``transcribe`` method). The registry
|
|
5
|
+
(:mod:`scribed.registry`) discovers them automatically. Subpackages whose name
|
|
6
|
+
starts with ``_`` (e.g. :mod:`scribed.backends._template`) are scaffolding
|
|
7
|
+
helpers, not real backends, and are skipped by discovery.
|
|
8
|
+
|
|
9
|
+
To add a backend, scaffold one from the template::
|
|
10
|
+
|
|
11
|
+
from scribed.make_backend import scaffold_backend
|
|
12
|
+
scaffold_backend("speechmatics") # creates scribed/backends/speechmatics/ from the template
|
|
13
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Template backend for scribed (copy-me)."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Adapter for the Template backend (copy-me).
|
|
2
|
+
|
|
3
|
+
An adapter turns scribed's normalized request into a native engine call and the
|
|
4
|
+
native response into a :class:`scribed.base.Transcript`. Subclassing
|
|
5
|
+
:class:`scribed.make_backend.BaseTranscriberAdapter` gives you kwarg translation
|
|
6
|
+
for free: implement :meth:`_transcribe`, which receives the audio and the
|
|
7
|
+
already-translated *native* kwargs, and return a ``Transcript``.
|
|
8
|
+
|
|
9
|
+
Three jobs in ``_transcribe``:
|
|
10
|
+
|
|
11
|
+
1. Get the audio into the form the engine wants
|
|
12
|
+
(``scribed.util.ensure_file_path`` / ``load_audio_bytes`` / ``to_waveform``).
|
|
13
|
+
2. Call the engine (import it *inside* ``_transcribe`` so importing scribed stays light).
|
|
14
|
+
3. Normalize the output — use ``scribed.make_backend.make_segment`` /
|
|
15
|
+
``Transcript.from_segments`` / ``Transcript.from_text``, and stash the engine's
|
|
16
|
+
native response in ``raw=``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from scribed.base import Transcript # noqa: F401 (commonly needed)
|
|
20
|
+
from scribed.make_backend import ( # noqa: F401
|
|
21
|
+
BaseTranscriberAdapter,
|
|
22
|
+
make_segment,
|
|
23
|
+
make_word,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Adapter(BaseTranscriberAdapter):
|
|
28
|
+
"""Template adapter — replace the body of ``_transcribe``."""
|
|
29
|
+
|
|
30
|
+
def _transcribe(self, audio, **native_kwargs) -> Transcript:
|
|
31
|
+
# 1. import the engine here (lazy)
|
|
32
|
+
# import the_engine
|
|
33
|
+
#
|
|
34
|
+
# 2. normalize the input and call the engine
|
|
35
|
+
# from scribed.util import ensure_file_path, cleanup_temp
|
|
36
|
+
# path, is_temp = ensure_file_path(audio)
|
|
37
|
+
# try:
|
|
38
|
+
# native = the_engine.transcribe(path, **native_kwargs)
|
|
39
|
+
# finally:
|
|
40
|
+
# cleanup_temp(path, is_temp)
|
|
41
|
+
#
|
|
42
|
+
# 3. build a normalized result
|
|
43
|
+
# segments = [
|
|
44
|
+
# make_segment(s.text, start=s.start, end=s.end, confidence=s.conf)
|
|
45
|
+
# for s in native.segments
|
|
46
|
+
# ]
|
|
47
|
+
# return Transcript.from_segments(segments, backend=self.backend_id, raw=native)
|
|
48
|
+
raise NotImplementedError("Implement _transcribe for this backend.")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Configuration for the Template backend (copy-me).
|
|
2
|
+
|
|
3
|
+
``scaffold_backend`` rewrites the lines tagged ``# TEMPLATE`` from a ledger entry.
|
|
4
|
+
Everything else you fill in by hand: the ``param_map`` is where you map scribed's
|
|
5
|
+
normalized argument names (``language``, ``diarize``, ``word_timestamps``, ...)
|
|
6
|
+
onto the engine's native parameter names. See ``scribed/data/SCHEMA.md`` for field
|
|
7
|
+
meanings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
BACKEND_CONFIG = {
|
|
11
|
+
"id": "__template__", # TEMPLATE
|
|
12
|
+
"name": "__template__", # TEMPLATE
|
|
13
|
+
"display_name": "Template Backend", # TEMPLATE
|
|
14
|
+
"pip_install": "PACKAGE", # TEMPLATE e.g. "faster-whisper"
|
|
15
|
+
"import_name": "PACKAGE", # TEMPLATE module used to probe availability
|
|
16
|
+
"license": "unknown", # TEMPLATE
|
|
17
|
+
"is_local": False, # TEMPLATE
|
|
18
|
+
"is_remote": False, # TEMPLATE
|
|
19
|
+
# Capabilities BEYOND the implied primary "transcribe" (audio -> text):
|
|
20
|
+
# e.g. "diarize", "stream", "translate", "word_timestamps".
|
|
21
|
+
"capabilities": [],
|
|
22
|
+
# Capabilities this backend should be the *default* for (usually
|
|
23
|
+
# ["transcribe"] for your first/most general engine).
|
|
24
|
+
"default_for": [],
|
|
25
|
+
# For remote backends: the env var(s) holding the credential, else "".
|
|
26
|
+
"api_env_var": "",
|
|
27
|
+
"description": "One-line description of the engine.", # TEMPLATE
|
|
28
|
+
# Map normalized kwarg -> native kwarg config (or None if unsupported):
|
|
29
|
+
# {"native_name": "language", "default": None, "coerce": <callable>}
|
|
30
|
+
"param_map": {
|
|
31
|
+
"language": {"native_name": "language"},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AssemblyAI backend for scribed."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Adapter for the AssemblyAI (Universal) transcription backend.
|
|
2
|
+
|
|
3
|
+
Maps scribed's normalized request onto ``assemblyai.Transcriber.transcribe`` and
|
|
4
|
+
the returned transcript onto a :class:`scribed.base.Transcript`. When speaker
|
|
5
|
+
labels are requested AssemblyAI returns ``utterances`` (one diarized speaker
|
|
6
|
+
turn each, with nested words); otherwise we build a single segment from the full
|
|
7
|
+
text and its word stream. AssemblyAI reports times in **milliseconds**, so we
|
|
8
|
+
divide by 1000 to get scribed's seconds. The SDK and credential are resolved
|
|
9
|
+
lazily so ``import scribed`` stays dependency-free.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from scribed.base import Transcript
|
|
15
|
+
from scribed.make_backend import BaseTranscriberAdapter, make_segment, make_word
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ms_to_s(value):
|
|
19
|
+
"""Convert an AssemblyAI millisecond time to seconds (``None`` passes through)."""
|
|
20
|
+
return None if value is None else float(value) / 1000.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _speaker_str(speaker):
|
|
24
|
+
"""Normalize a speaker label to a non-empty ``str`` or ``None``."""
|
|
25
|
+
s = str(speaker) if speaker is not None else ""
|
|
26
|
+
return s or None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Adapter(BaseTranscriberAdapter):
|
|
30
|
+
"""AssemblyAI (Universal) transcription adapter."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: dict):
|
|
33
|
+
super().__init__(config)
|
|
34
|
+
self._configured = False
|
|
35
|
+
|
|
36
|
+
def _ensure_configured(self):
|
|
37
|
+
"""Set the SDK's global API key once (lazy import + lazy credential)."""
|
|
38
|
+
if not self._configured:
|
|
39
|
+
import assemblyai as aai
|
|
40
|
+
|
|
41
|
+
from scribed.credentials import resolve_credential
|
|
42
|
+
|
|
43
|
+
key = resolve_credential(
|
|
44
|
+
"assemblyai",
|
|
45
|
+
env_var=self.config.get("api_env_var") or None,
|
|
46
|
+
required=True,
|
|
47
|
+
)
|
|
48
|
+
aai.settings.api_key = key
|
|
49
|
+
self._configured = True
|
|
50
|
+
|
|
51
|
+
def _transcribe(self, audio, **native_kwargs) -> Transcript:
|
|
52
|
+
import assemblyai as aai
|
|
53
|
+
|
|
54
|
+
from scribed.util import cleanup_temp, ensure_file_path
|
|
55
|
+
|
|
56
|
+
self._ensure_configured()
|
|
57
|
+
|
|
58
|
+
config = aai.TranscriptionConfig(**native_kwargs)
|
|
59
|
+
transcriber = aai.Transcriber()
|
|
60
|
+
|
|
61
|
+
# The SDK accepts a local path (it uploads) or a URL; give it a path.
|
|
62
|
+
path, is_temp = ensure_file_path(audio)
|
|
63
|
+
try:
|
|
64
|
+
t = transcriber.transcribe(path, config=config)
|
|
65
|
+
finally:
|
|
66
|
+
cleanup_temp(path, is_temp)
|
|
67
|
+
|
|
68
|
+
# Surface a failed transcription as an exception (validate_adapter catches it).
|
|
69
|
+
status = getattr(t, "status", None)
|
|
70
|
+
error = getattr(t, "error", None)
|
|
71
|
+
if error or (status is not None and str(status).lower().endswith("error")):
|
|
72
|
+
raise RuntimeError(f"AssemblyAI transcription failed: {error or status}")
|
|
73
|
+
|
|
74
|
+
language = getattr(t, "language_code", None)
|
|
75
|
+
utterances = getattr(t, "utterances", None)
|
|
76
|
+
|
|
77
|
+
if utterances:
|
|
78
|
+
segments = [
|
|
79
|
+
make_segment(
|
|
80
|
+
u.text or "",
|
|
81
|
+
start=_ms_to_s(u.start),
|
|
82
|
+
end=_ms_to_s(u.end),
|
|
83
|
+
confidence=u.confidence,
|
|
84
|
+
speaker=_speaker_str(u.speaker),
|
|
85
|
+
words=[
|
|
86
|
+
make_word(
|
|
87
|
+
w.text,
|
|
88
|
+
start=_ms_to_s(w.start),
|
|
89
|
+
end=_ms_to_s(w.end),
|
|
90
|
+
confidence=w.confidence,
|
|
91
|
+
speaker=_speaker_str(getattr(w, "speaker", None)),
|
|
92
|
+
)
|
|
93
|
+
for w in (getattr(u, "words", None) or [])
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
for u in utterances
|
|
97
|
+
]
|
|
98
|
+
else:
|
|
99
|
+
words = [
|
|
100
|
+
make_word(
|
|
101
|
+
w.text,
|
|
102
|
+
start=_ms_to_s(w.start),
|
|
103
|
+
end=_ms_to_s(w.end),
|
|
104
|
+
confidence=w.confidence,
|
|
105
|
+
speaker=_speaker_str(getattr(w, "speaker", None)),
|
|
106
|
+
)
|
|
107
|
+
for w in (getattr(t, "words", None) or [])
|
|
108
|
+
]
|
|
109
|
+
segments = [make_segment(t.text or "", words=words)]
|
|
110
|
+
|
|
111
|
+
return Transcript.from_segments(
|
|
112
|
+
segments,
|
|
113
|
+
backend=self.backend_id,
|
|
114
|
+
raw=t,
|
|
115
|
+
text=getattr(t, "text", None),
|
|
116
|
+
language=language,
|
|
117
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration for the AssemblyAI (Universal) transcription backend.
|
|
2
|
+
|
|
3
|
+
AssemblyAI is a hosted speech-to-text API built on its Universal model. It
|
|
4
|
+
provides speaker diarization (``speaker_labels``), word-level timestamps, and a
|
|
5
|
+
suite of audio-intelligence features (sentiment, topics, summarization, ...).
|
|
6
|
+
The SDK uploads local files for you, so a transcribe call accepts either a local
|
|
7
|
+
file path or a publicly reachable URL.
|
|
8
|
+
|
|
9
|
+
The two normalized params scribed exposes here map onto
|
|
10
|
+
``assemblyai.TranscriptionConfig`` fields: ``language`` -> ``language_code`` and
|
|
11
|
+
``diarize`` -> ``speaker_labels``. Additional native config (sentiment_analysis,
|
|
12
|
+
iab_categories, summarization, ...) can be passed through and is forwarded to
|
|
13
|
+
``TranscriptionConfig`` verbatim.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
BACKEND_CONFIG = {
|
|
17
|
+
"id": "assemblyai",
|
|
18
|
+
"name": "AssemblyAI",
|
|
19
|
+
"display_name": "AssemblyAI (Universal)",
|
|
20
|
+
"pip_install": "assemblyai",
|
|
21
|
+
"import_name": "assemblyai",
|
|
22
|
+
"license": "proprietary",
|
|
23
|
+
"is_local": False,
|
|
24
|
+
"is_remote": True,
|
|
25
|
+
"capabilities": ["diarize", "word_timestamps"],
|
|
26
|
+
"default_for": [],
|
|
27
|
+
"api_env_var": "ASSEMBLYAI_API_KEY",
|
|
28
|
+
"description": (
|
|
29
|
+
"AssemblyAI hosted transcription (Universal model) with speaker "
|
|
30
|
+
"diarization, word timestamps, and audio intelligence."
|
|
31
|
+
),
|
|
32
|
+
"param_map": {
|
|
33
|
+
# Feed native kwargs to assemblyai.TranscriptionConfig(**native_kwargs).
|
|
34
|
+
"language": {"native_name": "language_code"}, # None => auto-detect
|
|
35
|
+
"diarize": {"native_name": "speaker_labels"}, # bool
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deepgram (Nova-3) backend for scribed."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Adapter for the Deepgram (Nova-3) transcription backend.
|
|
2
|
+
|
|
3
|
+
Maps scribed's normalized request onto Deepgram's pre-recorded REST endpoint and
|
|
4
|
+
the returned JSON onto a :class:`scribed.base.Transcript`. The SDK and credential
|
|
5
|
+
are resolved lazily so ``import scribed`` stays dependency-free.
|
|
6
|
+
|
|
7
|
+
When ``utterances=True`` (always, here) Deepgram returns
|
|
8
|
+
``results.utterances`` — one diarized speaker turn each, with nested words — and
|
|
9
|
+
we build one :class:`~scribed.base.Segment` per utterance (carrying
|
|
10
|
+
``speaker=str(utt.speaker)``). When utterances are absent we fall back to a single
|
|
11
|
+
segment built from ``channels[0].alternatives[0]``. Deepgram reports times in
|
|
12
|
+
**seconds** and confidence already in ``[0, 1]``, so no rescaling is needed.
|
|
13
|
+
|
|
14
|
+
The Deepgram Python SDK surface has shifted across major versions, so both the
|
|
15
|
+
SDK accessor and the response parsing here are deliberately defensive: we try the
|
|
16
|
+
v3 ``listen.rest.v("1")`` accessor first, fall back to the older
|
|
17
|
+
``listen.prerecorded.v("1")`` one, and read response fields through ``.to_dict()``
|
|
18
|
+
(falling back to ``getattr``) so plain dicts and SDK objects are handled alike.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from scribed.base import Transcript
|
|
24
|
+
from scribed.make_backend import BaseTranscriberAdapter, make_segment, make_word
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get(obj, key, default=None):
|
|
28
|
+
"""Read ``key`` from a plain dict or a pydantic-ish SDK object."""
|
|
29
|
+
if obj is None:
|
|
30
|
+
return default
|
|
31
|
+
if isinstance(obj, dict):
|
|
32
|
+
return obj.get(key, default)
|
|
33
|
+
return getattr(obj, key, default)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_dict(resp):
|
|
37
|
+
"""Best-effort convert a Deepgram response object to a plain dict.
|
|
38
|
+
|
|
39
|
+
The SDK returns rich objects across versions; ``.to_dict()`` gives a stable,
|
|
40
|
+
uniform shape to parse. Falls back to the object itself (handled by ``_get``)
|
|
41
|
+
when no converter is available.
|
|
42
|
+
"""
|
|
43
|
+
for attr in ("to_dict", "to_json"):
|
|
44
|
+
fn = getattr(resp, attr, None)
|
|
45
|
+
if callable(fn):
|
|
46
|
+
try:
|
|
47
|
+
out = fn()
|
|
48
|
+
except Exception: # noqa: BLE001 - fall through to next strategy
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(out, dict):
|
|
51
|
+
return out
|
|
52
|
+
if isinstance(out, str):
|
|
53
|
+
import json
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(out)
|
|
57
|
+
except Exception: # noqa: BLE001
|
|
58
|
+
continue
|
|
59
|
+
return resp
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _speaker_str(speaker):
|
|
63
|
+
"""Normalize a Deepgram (int) speaker label to a non-empty ``str`` or ``None``."""
|
|
64
|
+
if speaker is None:
|
|
65
|
+
return None
|
|
66
|
+
s = str(speaker)
|
|
67
|
+
return s or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Adapter(BaseTranscriberAdapter):
|
|
71
|
+
"""Deepgram (Nova-3) transcription adapter."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, config: dict):
|
|
74
|
+
super().__init__(config)
|
|
75
|
+
self._client = None
|
|
76
|
+
|
|
77
|
+
def _get_client(self):
|
|
78
|
+
if self._client is None:
|
|
79
|
+
from deepgram import DeepgramClient
|
|
80
|
+
|
|
81
|
+
from scribed.credentials import resolve_credential
|
|
82
|
+
|
|
83
|
+
key = resolve_credential(
|
|
84
|
+
"deepgram",
|
|
85
|
+
env_var=self.config.get("api_env_var") or None,
|
|
86
|
+
required=True,
|
|
87
|
+
)
|
|
88
|
+
self._client = DeepgramClient(key)
|
|
89
|
+
return self._client
|
|
90
|
+
|
|
91
|
+
def _call_transcribe_file(self, client, payload, options):
|
|
92
|
+
"""Call the SDK's pre-recorded transcribe across known accessor paths.
|
|
93
|
+
|
|
94
|
+
v3 exposes ``listen.rest.v("1").transcribe_file``; older versions expose
|
|
95
|
+
``listen.prerecorded.v("1").transcribe_file``. Try them in turn.
|
|
96
|
+
"""
|
|
97
|
+
listen = client.listen
|
|
98
|
+
for accessor in ("rest", "prerecorded"):
|
|
99
|
+
namespace = getattr(listen, accessor, None)
|
|
100
|
+
if namespace is None:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
versioned = namespace.v("1")
|
|
104
|
+
except Exception: # noqa: BLE001 - try the next accessor
|
|
105
|
+
continue
|
|
106
|
+
return versioned.transcribe_file(payload, options)
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
"Could not find a Deepgram pre-recorded transcribe accessor "
|
|
109
|
+
"(tried client.listen.rest.v('1') and "
|
|
110
|
+
"client.listen.prerecorded.v('1')). The installed deepgram-sdk "
|
|
111
|
+
"version may have an incompatible API surface."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _transcribe(
|
|
115
|
+
self, audio, *, model="nova-3", diarize=False, language=None, **native_kwargs
|
|
116
|
+
) -> Transcript:
|
|
117
|
+
from deepgram import PrerecordedOptions
|
|
118
|
+
|
|
119
|
+
from scribed.util import load_audio_bytes
|
|
120
|
+
|
|
121
|
+
client = self._get_client()
|
|
122
|
+
audio_bytes = load_audio_bytes(audio)
|
|
123
|
+
payload = {"buffer": audio_bytes}
|
|
124
|
+
|
|
125
|
+
options = PrerecordedOptions(
|
|
126
|
+
model=model,
|
|
127
|
+
smart_format=True,
|
|
128
|
+
punctuate=True,
|
|
129
|
+
diarize=bool(diarize),
|
|
130
|
+
utterances=True,
|
|
131
|
+
language=language,
|
|
132
|
+
**native_kwargs,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
resp = self._call_transcribe_file(client, payload, options)
|
|
136
|
+
data = _as_dict(resp)
|
|
137
|
+
|
|
138
|
+
results = _get(data, "results")
|
|
139
|
+
channels = _get(results, "channels") or []
|
|
140
|
+
alternative = None
|
|
141
|
+
if channels:
|
|
142
|
+
alternatives = _get(channels[0], "alternatives") or []
|
|
143
|
+
if alternatives:
|
|
144
|
+
alternative = alternatives[0]
|
|
145
|
+
|
|
146
|
+
language_out = None
|
|
147
|
+
meta = _get(data, "metadata")
|
|
148
|
+
if meta is not None:
|
|
149
|
+
detected = _get(meta, "detected_language") or _get(meta, "language")
|
|
150
|
+
language_out = detected or language
|
|
151
|
+
else:
|
|
152
|
+
language_out = language
|
|
153
|
+
|
|
154
|
+
utterances = _get(results, "utterances")
|
|
155
|
+
|
|
156
|
+
if utterances:
|
|
157
|
+
segments = [
|
|
158
|
+
make_segment(
|
|
159
|
+
_get(u, "transcript") or "",
|
|
160
|
+
start=_get(u, "start"),
|
|
161
|
+
end=_get(u, "end"),
|
|
162
|
+
confidence=_get(u, "confidence"),
|
|
163
|
+
speaker=_speaker_str(_get(u, "speaker")),
|
|
164
|
+
words=[
|
|
165
|
+
make_word(
|
|
166
|
+
_get(w, "punctuated_word") or _get(w, "word") or "",
|
|
167
|
+
start=_get(w, "start"),
|
|
168
|
+
end=_get(w, "end"),
|
|
169
|
+
confidence=_get(w, "confidence"),
|
|
170
|
+
speaker=_speaker_str(_get(w, "speaker")),
|
|
171
|
+
)
|
|
172
|
+
for w in (_get(u, "words") or [])
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
for u in utterances
|
|
176
|
+
]
|
|
177
|
+
text = " ".join(
|
|
178
|
+
(_get(u, "transcript") or "").strip() for u in utterances
|
|
179
|
+
).strip()
|
|
180
|
+
else:
|
|
181
|
+
transcript_text = _get(alternative, "transcript") or ""
|
|
182
|
+
words = [
|
|
183
|
+
make_word(
|
|
184
|
+
_get(w, "punctuated_word") or _get(w, "word") or "",
|
|
185
|
+
start=_get(w, "start"),
|
|
186
|
+
end=_get(w, "end"),
|
|
187
|
+
confidence=_get(w, "confidence"),
|
|
188
|
+
speaker=_speaker_str(_get(w, "speaker")),
|
|
189
|
+
)
|
|
190
|
+
for w in (_get(alternative, "words") or [])
|
|
191
|
+
]
|
|
192
|
+
segments = [
|
|
193
|
+
make_segment(
|
|
194
|
+
transcript_text,
|
|
195
|
+
confidence=_get(alternative, "confidence"),
|
|
196
|
+
words=words,
|
|
197
|
+
)
|
|
198
|
+
]
|
|
199
|
+
text = transcript_text
|
|
200
|
+
|
|
201
|
+
return Transcript.from_segments(
|
|
202
|
+
segments,
|
|
203
|
+
backend=self.backend_id,
|
|
204
|
+
raw=resp,
|
|
205
|
+
text=text,
|
|
206
|
+
language=language_out,
|
|
207
|
+
)
|