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.
Files changed (58) hide show
  1. scribed/__init__.py +173 -0
  2. scribed/__main__.py +45 -0
  3. scribed/backends/__init__.py +13 -0
  4. scribed/backends/_template/__init__.py +1 -0
  5. scribed/backends/_template/adapter.py +48 -0
  6. scribed/backends/_template/config.py +33 -0
  7. scribed/backends/assemblyai/__init__.py +1 -0
  8. scribed/backends/assemblyai/adapter.py +117 -0
  9. scribed/backends/assemblyai/config.py +37 -0
  10. scribed/backends/deepgram/__init__.py +1 -0
  11. scribed/backends/deepgram/adapter.py +207 -0
  12. scribed/backends/deepgram/config.py +34 -0
  13. scribed/backends/elevenlabs/__init__.py +1 -0
  14. scribed/backends/elevenlabs/adapter.py +154 -0
  15. scribed/backends/elevenlabs/config.py +36 -0
  16. scribed/backends/faster_whisper/__init__.py +1 -0
  17. scribed/backends/faster_whisper/adapter.py +85 -0
  18. scribed/backends/faster_whisper/config.py +42 -0
  19. scribed/backends/google_speech/__init__.py +1 -0
  20. scribed/backends/google_speech/adapter.py +152 -0
  21. scribed/backends/google_speech/config.py +46 -0
  22. scribed/backends/groq/__init__.py +1 -0
  23. scribed/backends/groq/adapter.py +92 -0
  24. scribed/backends/groq/config.py +35 -0
  25. scribed/backends/openai/__init__.py +1 -0
  26. scribed/backends/openai/adapter.py +91 -0
  27. scribed/backends/openai/config.py +28 -0
  28. scribed/backends/vosk/__init__.py +1 -0
  29. scribed/backends/vosk/adapter.py +158 -0
  30. scribed/backends/vosk/config.py +39 -0
  31. scribed/backends/whisper/__init__.py +1 -0
  32. scribed/backends/whisper/adapter.py +85 -0
  33. scribed/backends/whisper/config.py +38 -0
  34. scribed/backends/whispercpp/__init__.py +1 -0
  35. scribed/backends/whispercpp/adapter.py +88 -0
  36. scribed/backends/whispercpp/config.py +41 -0
  37. scribed/base.py +352 -0
  38. scribed/catalog.py +325 -0
  39. scribed/credentials.py +244 -0
  40. scribed/data/SCHEMA.md +65 -0
  41. scribed/data/backends.json +622 -0
  42. scribed/data/skills/scribed/SKILL.md +57 -0
  43. scribed/data/skills/scribed-add-backend/SKILL.md +102 -0
  44. scribed/data/skills/scribed-choose-backend/SKILL.md +51 -0
  45. scribed/data/skills/scribed-install-backend/SKILL.md +67 -0
  46. scribed/install.py +343 -0
  47. scribed/make_backend.py +375 -0
  48. scribed/registry.py +221 -0
  49. scribed/services.py +122 -0
  50. scribed/status.py +303 -0
  51. scribed/tools.py +269 -0
  52. scribed/translation.py +107 -0
  53. scribed/util.py +189 -0
  54. scribed-0.0.1.dist-info/METADATA +213 -0
  55. scribed-0.0.1.dist-info/RECORD +58 -0
  56. scribed-0.0.1.dist-info/WHEEL +4 -0
  57. scribed-0.0.1.dist-info/entry_points.txt +2 -0
  58. 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
+ )