bodhi-api-sdk 1.0.0__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.
- bodhi/__init__.py +23 -0
- bodhi/audio_processor.py +54 -0
- bodhi/events.py +9 -0
- bodhi/examples/__init__.py +7 -0
- bodhi/examples/pipecat_mic_bot.py +107 -0
- bodhi/examples/pipecat_stream_wav.py +143 -0
- bodhi/integrations/__init__.py +9 -0
- bodhi/integrations/pipecat_stt.py +639 -0
- bodhi/transcription_client.py +161 -0
- bodhi/transcription_config.py +64 -0
- bodhi/transcription_handler.py +459 -0
- bodhi/transcription_response.py +32 -0
- bodhi/utils/error_utils.py +36 -0
- bodhi/utils/exceptions.py +95 -0
- bodhi/utils/logger.py +34 -0
- bodhi/websocket_handler.py +292 -0
- bodhi_api_sdk-1.0.0.dist-info/METADATA +265 -0
- bodhi_api_sdk-1.0.0.dist-info/RECORD +21 -0
- bodhi_api_sdk-1.0.0.dist-info/WHEEL +5 -0
- bodhi_api_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- bodhi_api_sdk-1.0.0.dist-info/top_level.txt +1 -0
bodhi/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bodhi Python SDK - Streaming Speech Recognition
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
|
|
7
|
+
EOF_SIGNAL = '{"eof": 1}'
|
|
8
|
+
|
|
9
|
+
from .transcription_client import BodhiClient
|
|
10
|
+
from .transcription_config import TranscriptionConfig, Hotword
|
|
11
|
+
from .transcription_response import TranscriptionResponse
|
|
12
|
+
from .events import LiveTranscriptionEvents
|
|
13
|
+
from bodhi.utils.error_utils import BodhiErrors
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BodhiClient",
|
|
17
|
+
"TranscriptionConfig",
|
|
18
|
+
"TranscriptionResponse",
|
|
19
|
+
"Hotword",
|
|
20
|
+
"LiveTranscriptionEvents",
|
|
21
|
+
"EOF_SIGNAL",
|
|
22
|
+
BodhiErrors,
|
|
23
|
+
]
|
bodhi/audio_processor.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Audio Processing Module for Bodhi Client"""
|
|
2
|
+
|
|
3
|
+
import wave
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import BinaryIO, Any
|
|
6
|
+
from .utils.logger import logger
|
|
7
|
+
from . import EOF_SIGNAL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AudioProcessor:
|
|
11
|
+
@staticmethod
|
|
12
|
+
async def process_file(
|
|
13
|
+
ws: Any,
|
|
14
|
+
wf: wave.Wave_read,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Process and stream audio file data.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
ws: WebSocket connection
|
|
20
|
+
wf: Wave file object
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
|
|
24
|
+
REALTIME_RESOLUTION = 0.02 # 20ms
|
|
25
|
+
byte_rate = wf.getframerate() * wf.getsampwidth() * wf.getnchannels()
|
|
26
|
+
data = wf.readframes(wf.getnframes())
|
|
27
|
+
audio_cursor = 0
|
|
28
|
+
|
|
29
|
+
while len(data):
|
|
30
|
+
i = int(byte_rate * REALTIME_RESOLUTION)
|
|
31
|
+
chunk, data = data[:i], data[i:]
|
|
32
|
+
if not ws.closed:
|
|
33
|
+
await ws.send_bytes(chunk)
|
|
34
|
+
logger.debug(f"Sent {len(chunk)} bytes of audio data")
|
|
35
|
+
audio_cursor += REALTIME_RESOLUTION
|
|
36
|
+
await asyncio.sleep(REALTIME_RESOLUTION)
|
|
37
|
+
|
|
38
|
+
if not ws.closed:
|
|
39
|
+
await ws.send_str(EOF_SIGNAL)
|
|
40
|
+
logger.debug("Sent EOF signal")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return e
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
async def process_stream(stream: BinaryIO, ws: Any) -> None:
|
|
46
|
+
"""Process and stream generic binary stream data."""
|
|
47
|
+
try:
|
|
48
|
+
data = stream.read()
|
|
49
|
+
if not ws.closed:
|
|
50
|
+
await ws.send_bytes(data)
|
|
51
|
+
logger.debug(f"Sent {len(data)} bytes of stream data")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.exception("Error during stream processing")
|
|
54
|
+
raise e
|
bodhi/events.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#
|
|
2
|
+
# A live Pipecat bot that transcribes your microphone with Bodhi. Transcripts
|
|
3
|
+
# appear in the browser UI (over RTVI) and in this terminal.
|
|
4
|
+
#
|
|
5
|
+
# It needs no OpenAI or Cartesia keys, because there is no LLM or TTS in the
|
|
6
|
+
# pipeline -- it is the smallest thing that exercises a real browser, real VAD
|
|
7
|
+
# and real Bodhi:
|
|
8
|
+
#
|
|
9
|
+
# pip install "bodhi-api-sdk[pipecat]" "pipecat-ai[webrtc,silero,runner]"
|
|
10
|
+
# export BODHI_API_KEY=...
|
|
11
|
+
# python -m bodhi.examples.pipecat_mic_bot
|
|
12
|
+
#
|
|
13
|
+
# Set BODHI_URL to point at another deployment, e.g. wss://stt.navana.ai.
|
|
14
|
+
#
|
|
15
|
+
# Then open http://localhost:7860/client and click Connect.
|
|
16
|
+
#
|
|
17
|
+
# To hear a bot answer back, use the official quickstart
|
|
18
|
+
# (https://docs.pipecat.ai/pipecat/get-started/quickstart) and replace its
|
|
19
|
+
# DeepgramSTTService with the BodhiSTTService built below.
|
|
20
|
+
#
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
|
25
|
+
from pipecat.frames.frames import Frame, InterimTranscriptionFrame, TranscriptionFrame
|
|
26
|
+
from pipecat.pipeline.pipeline import Pipeline
|
|
27
|
+
from pipecat.pipeline.task import PipelineParams
|
|
28
|
+
from pipecat.pipeline.worker import PipelineWorker
|
|
29
|
+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
30
|
+
from pipecat.runner.run import main
|
|
31
|
+
from pipecat.runner.types import RunnerArguments
|
|
32
|
+
from pipecat.runner.utils import create_transport
|
|
33
|
+
from pipecat.transports.base_transport import TransportParams
|
|
34
|
+
from pipecat.workers.runner import WorkerRunner
|
|
35
|
+
|
|
36
|
+
from bodhi.integrations.pipecat_stt import BODHI_DEFAULT_URL, BodhiHotword, BodhiSTTService
|
|
37
|
+
|
|
38
|
+
MODEL = os.getenv("BODHI_MODEL", "hi-general-v2-8khz")
|
|
39
|
+
URL = os.getenv("BODHI_URL", BODHI_DEFAULT_URL)
|
|
40
|
+
|
|
41
|
+
# Comma-separated, each "phrase" or "phrase:score", e.g. "बजाज फिनसर्व:2.0".
|
|
42
|
+
HOTWORDS = [
|
|
43
|
+
BodhiHotword(*(p.split(":")[0], float(p.split(":")[1]) if ":" in p else None))
|
|
44
|
+
for p in filter(None, os.getenv("BODHI_HOTWORDS", "").split(","))
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TranscriptPrinter(FrameProcessor):
|
|
49
|
+
"""Prints transcription frames as they flow past."""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
super().__init__()
|
|
53
|
+
self._started = time.monotonic()
|
|
54
|
+
|
|
55
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
|
56
|
+
"""Print transcription frames, then pass everything along."""
|
|
57
|
+
await super().process_frame(frame, direction)
|
|
58
|
+
|
|
59
|
+
elapsed = time.monotonic() - self._started
|
|
60
|
+
if isinstance(frame, TranscriptionFrame):
|
|
61
|
+
print(f"[{elapsed:6.2f}s] FINAL {frame.text}", flush=True)
|
|
62
|
+
elif isinstance(frame, InterimTranscriptionFrame):
|
|
63
|
+
print(f"[{elapsed:6.2f}s] partial {frame.text}", flush=True)
|
|
64
|
+
|
|
65
|
+
await self.push_frame(frame, direction)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def bot(runner_args: RunnerArguments):
|
|
69
|
+
"""Run the transcription pipeline for one browser connection."""
|
|
70
|
+
api_key = os.getenv("BODHI_API_KEY")
|
|
71
|
+
if not api_key:
|
|
72
|
+
raise SystemExit("set BODHI_API_KEY first")
|
|
73
|
+
|
|
74
|
+
# Whichever leg the client connects on: browser WebRTC, or a websocket.
|
|
75
|
+
def params():
|
|
76
|
+
return TransportParams(
|
|
77
|
+
audio_in_enabled=True,
|
|
78
|
+
# No TTS in this pipeline, so nothing is spoken back. The output
|
|
79
|
+
# leg is still needed: it carries the RTVI messages that put the
|
|
80
|
+
# transcripts on screen.
|
|
81
|
+
audio_out_enabled=True,
|
|
82
|
+
vad_analyzer=SileroVADAnalyzer(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
transport = await create_transport(runner_args, {"webrtc": params, "websocket": params})
|
|
86
|
+
|
|
87
|
+
stt = BodhiSTTService(
|
|
88
|
+
api_key=api_key,
|
|
89
|
+
model=MODEL,
|
|
90
|
+
url=URL,
|
|
91
|
+
settings=BodhiSTTService.Settings(hotwords=HOTWORDS or None),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print(f"transcribing with Bodhi model {MODEL} at {URL}; speak into the browser tab", flush=True)
|
|
95
|
+
|
|
96
|
+
worker = PipelineWorker(
|
|
97
|
+
Pipeline([transport.input(), stt, TranscriptPrinter(), transport.output()]),
|
|
98
|
+
params=PipelineParams(audio_in_sample_rate=16000),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
runner = WorkerRunner(handle_sigint=runner_args.handle_sigint)
|
|
102
|
+
await runner.add_workers(worker)
|
|
103
|
+
await runner.run()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Stream a WAV file through BodhiSTTService in a real Pipecat pipeline and
|
|
3
|
+
# print the transcripts as they arrive.
|
|
4
|
+
#
|
|
5
|
+
# pip install "bodhi-api-sdk[pipecat]"
|
|
6
|
+
# export BODHI_API_KEY=...
|
|
7
|
+
# python -m bodhi.examples.pipecat_stream_wav call.wav --model hi-general-v2-8khz
|
|
8
|
+
#
|
|
9
|
+
# Audio is fed in real time (100 ms at a time), so partials and finals land
|
|
10
|
+
# with the same timing a live call would see.
|
|
11
|
+
#
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import wave
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pipecat.frames.frames import (
|
|
21
|
+
EndFrame,
|
|
22
|
+
Frame,
|
|
23
|
+
InputAudioRawFrame,
|
|
24
|
+
InterimTranscriptionFrame,
|
|
25
|
+
TranscriptionFrame,
|
|
26
|
+
)
|
|
27
|
+
from pipecat.pipeline.pipeline import Pipeline
|
|
28
|
+
from pipecat.pipeline.task import PipelineParams
|
|
29
|
+
from pipecat.pipeline.worker import PipelineWorker
|
|
30
|
+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
31
|
+
from pipecat.workers.runner import WorkerRunner
|
|
32
|
+
|
|
33
|
+
from bodhi.integrations.pipecat_stt import BODHI_DEFAULT_URL, BodhiHotword, BodhiSTTService
|
|
34
|
+
|
|
35
|
+
CHUNK_SECONDS = 0.1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TranscriptPrinter(FrameProcessor):
|
|
39
|
+
"""Prints transcription frames as the pipeline produces them."""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._started = time.monotonic()
|
|
44
|
+
|
|
45
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
|
46
|
+
"""Print transcription frames, then pass everything along."""
|
|
47
|
+
await super().process_frame(frame, direction)
|
|
48
|
+
|
|
49
|
+
elapsed = time.monotonic() - self._started
|
|
50
|
+
if isinstance(frame, TranscriptionFrame):
|
|
51
|
+
print(f"[{elapsed:6.2f}s] FINAL {frame.text}", flush=True)
|
|
52
|
+
elif isinstance(frame, InterimTranscriptionFrame):
|
|
53
|
+
print(f"[{elapsed:6.2f}s] partial {frame.text}", flush=True)
|
|
54
|
+
|
|
55
|
+
await self.push_frame(frame, direction)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_wav(path: Path) -> tuple[bytes, int]:
|
|
59
|
+
"""Read a mono 16-bit WAV file.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
path: Path to the WAV file.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Its PCM bytes and sample rate.
|
|
66
|
+
"""
|
|
67
|
+
with wave.open(str(path), "rb") as wav:
|
|
68
|
+
if wav.getsampwidth() != 2 or wav.getnchannels() != 1:
|
|
69
|
+
raise SystemExit(
|
|
70
|
+
f"{path} is {wav.getnchannels()}ch/{wav.getsampwidth() * 8}-bit; "
|
|
71
|
+
"convert it first: ffmpeg -i in.wav -ac 1 -ar 8000 -sample_fmt s16 out.wav"
|
|
72
|
+
)
|
|
73
|
+
return wav.readframes(wav.getnframes()), wav.getframerate()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def main():
|
|
77
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
78
|
+
parser.add_argument("wav", type=Path, help="mono 16-bit WAV file to stream")
|
|
79
|
+
parser.add_argument("--model", default=os.getenv("BODHI_MODEL", "hi-general-v2-8khz"))
|
|
80
|
+
parser.add_argument("--url", default=os.getenv("BODHI_URL", BODHI_DEFAULT_URL))
|
|
81
|
+
parser.add_argument("--api-key", default=os.getenv("BODHI_API_KEY"))
|
|
82
|
+
parser.add_argument("--hotword", action="append", default=[], metavar="PHRASE[:SCORE]")
|
|
83
|
+
parser.add_argument("--parse-number", action="store_true", help="normalise numbers and dates")
|
|
84
|
+
parser.add_argument("--no-partials", action="store_true", help="finals only")
|
|
85
|
+
args = parser.parse_args()
|
|
86
|
+
|
|
87
|
+
if not args.api_key:
|
|
88
|
+
raise SystemExit("set BODHI_API_KEY (or pass --api-key)")
|
|
89
|
+
|
|
90
|
+
hotwords = []
|
|
91
|
+
for spec in args.hotword:
|
|
92
|
+
phrase, _, score = spec.partition(":")
|
|
93
|
+
hotwords.append(BodhiHotword(phrase, float(score) if score else None))
|
|
94
|
+
|
|
95
|
+
audio, sample_rate = read_wav(args.wav)
|
|
96
|
+
print(
|
|
97
|
+
f"streaming {len(audio) / 2 / sample_rate:.1f}s of {sample_rate} Hz audio "
|
|
98
|
+
f"to {args.url} as {args.model}",
|
|
99
|
+
flush=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
stt = BodhiSTTService(
|
|
103
|
+
api_key=args.api_key,
|
|
104
|
+
model=args.model,
|
|
105
|
+
url=args.url,
|
|
106
|
+
interim_results=not args.no_partials,
|
|
107
|
+
settings=BodhiSTTService.Settings(
|
|
108
|
+
parse_number=args.parse_number,
|
|
109
|
+
hotwords=hotwords or None,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
worker = PipelineWorker(
|
|
114
|
+
Pipeline([stt, TranscriptPrinter()]),
|
|
115
|
+
params=PipelineParams(audio_in_sample_rate=sample_rate),
|
|
116
|
+
cancel_on_idle_timeout=False,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def feed_audio():
|
|
120
|
+
# Let the pipeline start and the websocket handshake finish.
|
|
121
|
+
await asyncio.sleep(0.5)
|
|
122
|
+
chunk_bytes = int(sample_rate * CHUNK_SECONDS) * 2
|
|
123
|
+
for offset in range(0, len(audio), chunk_bytes):
|
|
124
|
+
await worker.queue_frame(
|
|
125
|
+
InputAudioRawFrame(
|
|
126
|
+
audio=audio[offset : offset + chunk_bytes],
|
|
127
|
+
sample_rate=sample_rate,
|
|
128
|
+
num_channels=1,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
await asyncio.sleep(CHUNK_SECONDS)
|
|
132
|
+
# Give Bodhi a moment to endpoint the last utterance.
|
|
133
|
+
await asyncio.sleep(1.5)
|
|
134
|
+
await worker.queue_frame(EndFrame())
|
|
135
|
+
print(f"transaction_id: {stt.transaction_id}", flush=True)
|
|
136
|
+
|
|
137
|
+
runner = WorkerRunner(handle_sigint=False)
|
|
138
|
+
await runner.add_workers(worker)
|
|
139
|
+
await asyncio.gather(runner.run(), feed_audio())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Integrations that plug Bodhi into third-party frameworks.
|
|
2
|
+
|
|
3
|
+
Each module here needs its framework installed; nothing in this package is
|
|
4
|
+
imported by the core SDK. For Pipecat::
|
|
5
|
+
|
|
6
|
+
pip install "bodhi-api-sdk[pipecat]"
|
|
7
|
+
|
|
8
|
+
from bodhi.integrations.pipecat_stt import BodhiSTTService
|
|
9
|
+
"""
|