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 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
+ ]
@@ -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,9 @@
1
+ from enum import Enum
2
+
3
+
4
+ class LiveTranscriptionEvents(Enum):
5
+ Transcript = "Transcript"
6
+ UtteranceEnd = "UtteranceEnd"
7
+ SpeechStarted = "SpeechStarted"
8
+ Error = "Error"
9
+ Close = "Close"
@@ -0,0 +1,7 @@
1
+ """Runnable examples for the Bodhi SDK integrations.
2
+
3
+ Each module runs as a script, so nothing needs cloning:
4
+
5
+ python -m bodhi.examples.pipecat_mic_bot
6
+ python -m bodhi.examples.pipecat_stream_wav call.wav
7
+ """
@@ -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
+ """