From 47305cc061a1e46d8c5d74dfdeddc703902a4415 Mon Sep 17 00:00:00 2001 From: jatinkrmalik Date: Fri, 24 Jul 2026 00:18:13 -0700 Subject: [PATCH 1/3] feat: bind whisper.cpp Parakeet API Bump whisper.cpp to a master revision that includes Parakeet, link the parakeet library from CMake, add minimal pybind11 surface plus ParakeetModel, and cover bindings with unit tests that do not need a large model download. Refs absadiki/pywhispercpp#171 --- CMakeLists.txt | 10 +- README.md | 13 ++ pywhispercpp/__init__.py | 11 ++ pywhispercpp/parakeet_model.py | 234 +++++++++++++++++++++++++++++++++ src/main.cpp | 5 +- src/parakeet_bindings.cpp | 148 +++++++++++++++++++++ src/parakeet_bindings.h | 8 ++ tests/test_parakeet.py | 80 +++++++++++ whisper.cpp | 2 +- 9 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 pywhispercpp/parakeet_model.py create mode 100644 src/parakeet_bindings.cpp create mode 100644 src/parakeet_bindings.h create mode 100644 tests/test_parakeet.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 39c16a8..1704f83 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,15 @@ add_subdirectory(whisper.cpp) pybind11_add_module(_pywhispercpp src/main.cpp + src/parakeet_bindings.cpp ) -target_link_libraries (_pywhispercpp PRIVATE whisper) +# Link both whisper and parakeet (Parakeet is a separate library in whisper.cpp). +# Order can matter for ggml symbols on some linkers: whisper then parakeet. +target_link_libraries(_pywhispercpp PRIVATE whisper parakeet) +# Ensure parakeet.h is visible even if transitive includes change. +target_include_directories(_pywhispercpp PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/whisper.cpp/include + ${CMAKE_CURRENT_SOURCE_DIR}/whisper.cpp/ggml/include +) diff --git a/README.md b/README.md index 018f18e..db8f1fc 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,19 @@ segments = model.transcribe('file.mp3', new_segment_callback=print) * You can pass any `whisper.cpp` [parameter](https://absadiki.github.io/pywhispercpp/#pywhispercpp.constants.PARAMS_SCHEMA) as a keyword argument to the `Model` class or to the `transcribe` function. * Check the [Model](https://absadiki.github.io/pywhispercpp/#pywhispercpp.model.Model) class documentation for more details. +### Parakeet (NVIDIA) + +Parakeet is a separate whisper.cpp API from Whisper. Point `ParakeetModel` at a local GGUF (for example from [ggml-org/parakeet-GGUF](https://huggingface.co/ggml-org/parakeet-GGUF)); there is no automatic download helper yet. + +```python +from pywhispercpp import ParakeetModel + +model = ParakeetModel("/path/to/ggml-parakeet-tdt-0.6b-v3-q5_0.bin", n_threads=4) +segments = model.transcribe("file.wav") +for segment in segments: + print(segment.text) +``` + # Examples ## CLI diff --git a/pywhispercpp/__init__.py b/pywhispercpp/__init__.py index 8b13789..d2ed149 100644 --- a/pywhispercpp/__init__.py +++ b/pywhispercpp/__init__.py @@ -1 +1,12 @@ +""" +pywhispercpp: Python bindings for whisper.cpp (Whisper + Parakeet). +""" +from pywhispercpp.model import Model, Segment +from pywhispercpp.parakeet_model import ParakeetModel + +__all__ = [ + "Model", + "Segment", + "ParakeetModel", +] diff --git a/pywhispercpp/parakeet_model.py b/pywhispercpp/parakeet_model.py new file mode 100644 index 0000000..2f4df4d --- /dev/null +++ b/pywhispercpp/parakeet_model.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +High-level Python API for whisper.cpp Parakeet models. + +Parakeet is a separate C surface from Whisper (`parakeet.h`). Models are GGUF +checkpoints, for example from https://huggingface.co/ggml-org/parakeet-GGUF . + +Example:: + + from pywhispercpp import ParakeetModel + + model = ParakeetModel("/path/to/ggml-parakeet-tdt-0.6b-v3-q5_0.bin", n_threads=4) + segments = model.transcribe("audio.wav") + for seg in segments: + print(seg.t0, seg.t1, seg.text) +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tempfile +import wave +from pathlib import Path +from typing import List, Optional, TypedDict, Union + +import numpy as np + +import _pywhispercpp as pw +from pywhispercpp.model import Segment + +logger = logging.getLogger(__name__) + + +class ParakeetContextParams(TypedDict, total=False): + use_gpu: bool + gpu_device: int + + +_CONTEXT_PARAM_KEYS = frozenset(ParakeetContextParams.__annotations__) + + +class ParakeetModel: + """ + Thin wrapper around the Parakeet C API exposed by ``_pywhispercpp``. + + Unlike :class:`pywhispercpp.model.Model`, this does not download models by + name. Pass a local path to a Parakeet GGUF file (see + ``ggml-org/parakeet-GGUF`` on Hugging Face). + """ + + def __init__( + self, + model_path: str, + n_threads: Optional[int] = None, + context_params: Optional[ParakeetContextParams] = None, + **params, + ): + """ + :param model_path: Path to a Parakeet GGUF model file. + :param n_threads: Threads for inference (default: min(4, CPU count)). + :param context_params: Optional dict with ``use_gpu`` / ``gpu_device``. + :param params: Extra full-params fields (e.g. ``offset_ms``, ``duration_ms``). + """ + self._ctx = None + path = Path(model_path).expanduser() + if not path.is_file(): + raise FileNotFoundError( + f"Parakeet model not found: {model_path}. " + "Download a GGUF from https://huggingface.co/ggml-org/parakeet-GGUF" + ) + self.model_path = str(path.resolve()) + self._context_params = self._resolve_context_params(context_params) + strategy = pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY + self._params = pw.parakeet_full_default_params(strategy) + if n_threads is None: + n_threads = min(4, os.cpu_count() or 4) + self._params.n_threads = int(n_threads) + for key, value in params.items(): + if not hasattr(self._params, key): + raise TypeError(f"Unknown Parakeet full param: {key}") + setattr(self._params, key, value) + self._init_model() + + def transcribe( + self, + media: Union[str, np.ndarray], + **params, + ) -> List[Segment]: + """ + Transcribe a media path or float32 mono PCM at 16 kHz. + + :param media: File path (wav preferred; other formats need ffmpeg) or + 1-D ``numpy.ndarray`` of float32 samples. + :param params: Temporary overrides for full params (e.g. ``n_threads``). + :return: List of :class:`~pywhispercpp.model.Segment` objects. + """ + if isinstance(media, np.ndarray): + audio = np.ascontiguousarray(media, dtype=np.float32).ravel() + else: + if not Path(media).exists(): + raise FileNotFoundError(media) + audio = self._load_audio(str(media)) + + for key, value in params.items(): + if not hasattr(self._params, key): + raise TypeError(f"Unknown Parakeet full param: {key}") + setattr(self._params, key, value) + + logger.info("Transcribing with Parakeet ...") + ret = pw.parakeet_full(self._ctx, self._params, audio, int(audio.size)) + if ret != 0: + raise RuntimeError(f"parakeet_full failed with code {ret}") + + n = pw.parakeet_full_n_segments(self._ctx) + segments: List[Segment] = [] + for i in range(n): + t0 = pw.parakeet_full_get_segment_t0(self._ctx, i) + t1 = pw.parakeet_full_get_segment_t1(self._ctx, i) + raw = pw.parakeet_full_get_segment_text(self._ctx, i) + text = raw.decode("utf-8", errors="replace").strip() + segments.append(Segment(t0, t1, text)) + return segments + + # Alias used by some call sites / docs + transcribe_samples = transcribe + + def get_params(self) -> dict: + res = {} + for name in dir(self._params): + if name.startswith("_"): + continue + try: + res[name] = getattr(self._params, name) + except Exception: + continue + return res + + @staticmethod + def version() -> str: + return pw.parakeet_version() + + @staticmethod + def _resolve_context_params(context_params: Optional[ParakeetContextParams]): + resolved = pw.parakeet_context_default_params() + if context_params is None: + return resolved + if not isinstance(context_params, dict): + raise TypeError("context_params must be a dict or None") + unknown = sorted(set(context_params) - _CONTEXT_PARAM_KEYS) + if unknown: + raise TypeError(f"Unknown context_params keys: {', '.join(unknown)}") + for key, value in context_params.items(): + setattr(resolved, key, value) + return resolved + + def _init_model(self) -> None: + logger.info("Initializing Parakeet model from %s ...", self.model_path) + self._ctx = pw.parakeet_init_from_file_with_params( + self.model_path, self._context_params + ) + # pybind returns an opaque wrapper; a failed load leaves a null internal ptr. + # Exercise free/n_segments carefully after a failed load. + if self._ctx is None: + raise RuntimeError(f"Failed to load Parakeet model: {self.model_path}") + + @staticmethod + def _load_audio(media_file_path: str) -> np.ndarray: + def wav_to_np(file_path: str) -> np.ndarray: + with wave.open(file_path, "rb") as wf: + num_channels = wf.getnchannels() + sample_width = wf.getsampwidth() + sample_rate = wf.getframerate() + num_frames = wf.getnframes() + + if num_channels not in (1, 2): + raise Exception("WAV file must be mono or stereo") + if sample_rate != pw.PARAKEET_SAMPLE_RATE: + raise Exception(f"WAV file must be {pw.PARAKEET_SAMPLE_RATE} Hz") + if sample_width != 2: + raise Exception("WAV file must be 16-bit") + + raw = wf.readframes(num_frames) + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) + if num_channels == 1: + return audio / 32768.0 + audio = audio.reshape(-1, 2) + return (audio[:, 0] + audio[:, 1]) / 65536.0 + + if media_file_path.endswith(".wav"): + return wav_to_np(media_file_path) + + if shutil.which("ffmpeg") is None: + raise Exception( + "FFMPEG is not installed or not in PATH. " + "Install it, or provide a 16 kHz WAV or a NumPy array." + ) + + temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + temp_file_path = temp_file.name + temp_file.close() + try: + subprocess.run( + [ + "ffmpeg", + "-i", + media_file_path, + "-ac", + "1", + "-ar", + str(pw.PARAKEET_SAMPLE_RATE), + temp_file_path, + "-y", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return wav_to_np(temp_file_path) + finally: + os.remove(temp_file_path) + + def __del__(self): + ctx = getattr(self, "_ctx", None) + if ctx is not None: + try: + pw.parakeet_free(ctx) + except Exception: + pass + self._ctx = None diff --git a/src/main.cpp b/src/main.cpp index 6bc3c00..2673053 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include #include "whisper.h" +#include "parakeet_bindings.h" #define STRINGIFY(x) #x @@ -1384,8 +1385,8 @@ PYBIND11_MODULE(_pywhispercpp, m) { m.def("whisper_vad_free_segments", &whisper_vad_free_segments_wrapper); m.def("whisper_vad_free", &whisper_vad_free_wrapper); - - + // Parakeet (separate C API in whisper.cpp; see include/parakeet.h) + init_parakeet_bindings(m); #ifdef VERSION_INFO m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); diff --git a/src/parakeet_bindings.cpp b/src/parakeet_bindings.cpp new file mode 100644 index 0000000..8c52418 --- /dev/null +++ b/src/parakeet_bindings.cpp @@ -0,0 +1,148 @@ +/** + * Python bindings for whisper.cpp Parakeet API (parakeet.h). + */ + +#include +#include +#include + +#include "parakeet.h" + +#include +#include + +namespace py = pybind11; + +// Opaque context wrapper (parakeet_context is incomplete in the public header). +struct parakeet_context_wrapper { + parakeet_context * ptr; +}; + +static py::object parakeet_init_from_file_with_params_wrapper( + const char * path_model, + struct parakeet_context_params cparams) { + struct parakeet_context * ctx = parakeet_init_from_file_with_params(path_model, cparams); + if (!ctx) { + return py::none(); + } + struct parakeet_context_wrapper w; + w.ptr = ctx; + return py::cast(w); +} + +static void parakeet_free_wrapper(py::object ctx_obj) { + if (ctx_obj.is_none()) { + return; + } + auto * ctx_w = ctx_obj.cast(); + if (ctx_w && ctx_w->ptr) { + parakeet_free(ctx_w->ptr); + ctx_w->ptr = nullptr; + } +} + +static int parakeet_full_wrapper( + struct parakeet_context_wrapper * ctx_w, + struct parakeet_full_params params, + py::array_t samples, + int n_samples) { + if (!ctx_w || !ctx_w->ptr) { + return -1; + } + py::buffer_info buf = samples.request(); + float * samples_ptr = static_cast(buf.ptr); + + py::gil_scoped_release release; + return parakeet_full(ctx_w->ptr, params, samples_ptr, n_samples); +} + +static int parakeet_full_n_segments_wrapper(struct parakeet_context_wrapper * ctx_w) { + if (!ctx_w || !ctx_w->ptr) { + return 0; + } + py::gil_scoped_release release; + return parakeet_full_n_segments(ctx_w->ptr); +} + +static int64_t parakeet_full_get_segment_t0_wrapper( + struct parakeet_context_wrapper * ctx_w, int i_segment) { + return parakeet_full_get_segment_t0(ctx_w->ptr, i_segment); +} + +static int64_t parakeet_full_get_segment_t1_wrapper( + struct parakeet_context_wrapper * ctx_w, int i_segment) { + return parakeet_full_get_segment_t1(ctx_w->ptr, i_segment); +} + +static py::bytes parakeet_full_get_segment_text_wrapper( + struct parakeet_context_wrapper * ctx_w, int i_segment) { + const char * c_array = parakeet_full_get_segment_text(ctx_w->ptr, i_segment); + if (!c_array) { + return py::bytes("", 0); + } + size_t length = strlen(c_array); + return py::bytes(c_array, length); +} + +void init_parakeet_bindings(py::module_ & m) { + m.attr("PARAKEET_SAMPLE_RATE") = PARAKEET_SAMPLE_RATE; + m.attr("PARAKEET_HOP_LENGTH") = PARAKEET_HOP_LENGTH; + + py::enum_(m, "parakeet_sampling_strategy") + .value("PARAKEET_SAMPLING_GREEDY", parakeet_sampling_strategy::PARAKEET_SAMPLING_GREEDY) + .export_values(); + + py::class_(m, "parakeet_context"); + + py::class_(m, "parakeet_context_params") + .def(py::init<>()) + .def_readwrite("use_gpu", ¶keet_context_params::use_gpu) + .def_readwrite("gpu_device", ¶keet_context_params::gpu_device); + + py::class_(m, "parakeet_full_params") + .def(py::init<>()) + .def_readwrite("strategy", ¶keet_full_params::strategy) + .def_readwrite("n_threads", ¶keet_full_params::n_threads) + .def_readwrite("offset_ms", ¶keet_full_params::offset_ms) + .def_readwrite("duration_ms", ¶keet_full_params::duration_ms) + .def_readwrite("no_context", ¶keet_full_params::no_context) + .def_readwrite("audio_ctx", ¶keet_full_params::audio_ctx); + + m.def("parakeet_version", ¶keet_version, + "Return the Parakeet library version string."); + + m.def("parakeet_context_default_params", ¶keet_context_default_params, + "Return default Parakeet context parameters."); + + m.def("parakeet_full_default_params", ¶keet_full_default_params, + "Return default Parakeet full-params for the given sampling strategy.", + py::arg("strategy")); + + m.def("parakeet_init_from_file_with_params", + ¶keet_init_from_file_with_params_wrapper, + "Load a Parakeet GGUF model from path. Returns a context (ptr may be null on failure).", + py::arg("path_model"), py::arg("params"), + py::call_guard()); + + m.def("parakeet_free", ¶keet_free_wrapper, + "Free memory allocated for a Parakeet context."); + + m.def("parakeet_full", ¶keet_full_wrapper, + "Run full Parakeet inference on float32 PCM samples (16 kHz mono).", + py::arg("ctx"), py::arg("params"), py::arg("samples"), py::arg("n_samples")); + + m.def("parakeet_full_n_segments", ¶keet_full_n_segments_wrapper, + "Number of text segments produced by the last parakeet_full() call."); + + m.def("parakeet_full_get_segment_t0", ¶keet_full_get_segment_t0_wrapper, + "Start time of segment i (centiseconds, same convention as whisper)."); + + m.def("parakeet_full_get_segment_t1", ¶keet_full_get_segment_t1_wrapper, + "End time of segment i (centiseconds, same convention as whisper)."); + + m.def("parakeet_full_get_segment_text", ¶keet_full_get_segment_text_wrapper, + "Text of segment i as bytes (decode as UTF-8)."); + + m.def("parakeet_print_system_info", ¶keet_print_system_info, + "Print system / backend information for Parakeet."); +} diff --git a/src/parakeet_bindings.h b/src/parakeet_bindings.h new file mode 100644 index 0000000..890c279 --- /dev/null +++ b/src/parakeet_bindings.h @@ -0,0 +1,8 @@ +/** + * Declares Parakeet pybind11 registration for _pywhispercpp. + */ +#pragma once + +#include + +void init_parakeet_bindings(pybind11::module_ & m); diff --git a/tests/test_parakeet.py b/tests/test_parakeet.py new file mode 100644 index 0000000..dfa1c7f --- /dev/null +++ b/tests/test_parakeet.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Parakeet binding smoke tests that do not require a multi-hundred-MB model download. +""" + +import unittest +from pathlib import Path +from unittest import TestCase + +import _pywhispercpp as pw + + +class TestParakeetBindings(TestCase): + def test_import_and_constants(self): + self.assertEqual(pw.PARAKEET_SAMPLE_RATE, 16000) + self.assertEqual(pw.PARAKEET_HOP_LENGTH, 160) + + def test_parakeet_version(self): + version = pw.parakeet_version() + self.assertIsInstance(version, str) + self.assertGreater(len(version), 0) + + def test_context_default_params(self): + params = pw.parakeet_context_default_params() + self.assertIsInstance(params.use_gpu, bool) + self.assertIsInstance(params.gpu_device, int) + params.use_gpu = False + params.gpu_device = 0 + self.assertFalse(params.use_gpu) + self.assertEqual(params.gpu_device, 0) + + def test_full_default_params(self): + params = pw.parakeet_full_default_params( + pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY + ) + self.assertIsInstance(params.n_threads, int) + self.assertGreater(params.n_threads, 0) + params.n_threads = 2 + self.assertEqual(params.n_threads, 2) + self.assertIsInstance(params.offset_ms, int) + self.assertIsInstance(params.duration_ms, int) + self.assertIsInstance(params.no_context, bool) + + def test_sampling_strategy_enum(self): + self.assertEqual( + int(pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY), + 0, + ) + + def test_init_missing_file_returns_none(self): + params = pw.parakeet_context_default_params() + params.use_gpu = False + ctx = pw.parakeet_init_from_file_with_params( + "/nonexistent/path/parakeet-model.bin", + params, + ) + self.assertIsNone(ctx) + + def test_free_none_safe(self): + # free(None) must not segfault. + pw.parakeet_free(None) + + def test_python_package_export(self): + from pywhispercpp import ParakeetModel, Segment, Model + + self.assertTrue(callable(ParakeetModel)) + self.assertTrue(callable(Model)) + self.assertTrue(callable(Segment)) + + def test_parakeet_model_missing_path(self): + from pywhispercpp import ParakeetModel + + with self.assertRaises(FileNotFoundError): + ParakeetModel("/no/such/parakeet.gguf") + + +if __name__ == "__main__": + unittest.main() diff --git a/whisper.cpp b/whisper.cpp index 9386f23..080bbbe 160000 --- a/whisper.cpp +++ b/whisper.cpp @@ -1 +1 @@ -Subproject commit 9386f239401074690479731c1e41683fbbeac557 +Subproject commit 080bbbe85230f624f0b52127f1ae1218247989f9 From 9fbee3e48eb216a350a17356333e226db7d66051 Mon Sep 17 00:00:00 2001 From: jatinkrmalik Date: Fri, 24 Jul 2026 00:20:51 -0700 Subject: [PATCH 2/3] refactor: shrink Parakeet bindings after ponytail review Drop unused aliases/helpers, reuse Model._load_audio, trim pybind docstrings and optional exports. Minimal surface unchanged: init/free/full/segments + thin ParakeetModel. --- CMakeLists.txt | 4 - pywhispercpp/__init__.py | 10 +- pywhispercpp/parakeet_model.py | 184 +++++++-------------------------- src/main.cpp | 1 - src/parakeet_bindings.cpp | 45 ++------ src/parakeet_bindings.h | 3 - tests/test_parakeet.py | 19 +--- 7 files changed, 48 insertions(+), 218 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1704f83..9024c4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,11 +9,7 @@ pybind11_add_module(_pywhispercpp src/parakeet_bindings.cpp ) -# Link both whisper and parakeet (Parakeet is a separate library in whisper.cpp). -# Order can matter for ggml symbols on some linkers: whisper then parakeet. target_link_libraries(_pywhispercpp PRIVATE whisper parakeet) - -# Ensure parakeet.h is visible even if transitive includes change. target_include_directories(_pywhispercpp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/whisper.cpp/include ${CMAKE_CURRENT_SOURCE_DIR}/whisper.cpp/ggml/include diff --git a/pywhispercpp/__init__.py b/pywhispercpp/__init__.py index d2ed149..22f6f85 100644 --- a/pywhispercpp/__init__.py +++ b/pywhispercpp/__init__.py @@ -1,12 +1,4 @@ -""" -pywhispercpp: Python bindings for whisper.cpp (Whisper + Parakeet). -""" - from pywhispercpp.model import Model, Segment from pywhispercpp.parakeet_model import ParakeetModel -__all__ = [ - "Model", - "Segment", - "ParakeetModel", -] +__all__ = ["Model", "Segment", "ParakeetModel"] diff --git a/pywhispercpp/parakeet_model.py b/pywhispercpp/parakeet_model.py index 2f4df4d..8b33275 100644 --- a/pywhispercpp/parakeet_model.py +++ b/pywhispercpp/parakeet_model.py @@ -2,69 +2,53 @@ # -*- coding: utf-8 -*- """ -High-level Python API for whisper.cpp Parakeet models. +Thin Python helper for whisper.cpp Parakeet (`parakeet.h`). -Parakeet is a separate C surface from Whisper (`parakeet.h`). Models are GGUF -checkpoints, for example from https://huggingface.co/ggml-org/parakeet-GGUF . +Models are local GGUF files (e.g. https://huggingface.co/ggml-org/parakeet-GGUF). Example:: from pywhispercpp import ParakeetModel model = ParakeetModel("/path/to/ggml-parakeet-tdt-0.6b-v3-q5_0.bin", n_threads=4) - segments = model.transcribe("audio.wav") - for seg in segments: - print(seg.t0, seg.t1, seg.text) + for seg in model.transcribe("audio.wav"): + print(seg.text) """ from __future__ import annotations -import logging import os -import shutil -import subprocess -import tempfile -import wave from pathlib import Path -from typing import List, Optional, TypedDict, Union +from typing import List, Optional, Union import numpy as np import _pywhispercpp as pw -from pywhispercpp.model import Segment - -logger = logging.getLogger(__name__) - - -class ParakeetContextParams(TypedDict, total=False): - use_gpu: bool - gpu_device: int - - -_CONTEXT_PARAM_KEYS = frozenset(ParakeetContextParams.__annotations__) +from pywhispercpp.model import Model, Segment class ParakeetModel: """ - Thin wrapper around the Parakeet C API exposed by ``_pywhispercpp``. + Load a local Parakeet GGUF and run ``parakeet_full``. - Unlike :class:`pywhispercpp.model.Model`, this does not download models by - name. Pass a local path to a Parakeet GGUF file (see - ``ggml-org/parakeet-GGUF`` on Hugging Face). + Reuses :class:`~pywhispercpp.model.Segment` and ``Model._load_audio`` so we + do not duplicate wav/ffmpeg decoding. """ def __init__( self, model_path: str, n_threads: Optional[int] = None, - context_params: Optional[ParakeetContextParams] = None, + use_gpu: bool = True, + gpu_device: int = 0, **params, ): """ :param model_path: Path to a Parakeet GGUF model file. - :param n_threads: Threads for inference (default: min(4, CPU count)). - :param context_params: Optional dict with ``use_gpu`` / ``gpu_device``. - :param params: Extra full-params fields (e.g. ``offset_ms``, ``duration_ms``). + :param n_threads: Inference threads (default min(4, CPU count)). + :param use_gpu: Forwarded to ``parakeet_context_params.use_gpu``. + :param gpu_device: Forwarded to ``parakeet_context_params.gpu_device``. + :param params: Extra fields on ``parakeet_full_params`` (e.g. ``offset_ms``). """ self._ctx = None path = Path(model_path).expanduser() @@ -74,17 +58,23 @@ def __init__( "Download a GGUF from https://huggingface.co/ggml-org/parakeet-GGUF" ) self.model_path = str(path.resolve()) - self._context_params = self._resolve_context_params(context_params) - strategy = pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY - self._params = pw.parakeet_full_default_params(strategy) - if n_threads is None: - n_threads = min(4, os.cpu_count() or 4) - self._params.n_threads = int(n_threads) + + cparams = pw.parakeet_context_default_params() + cparams.use_gpu = use_gpu + cparams.gpu_device = gpu_device + + self._params = pw.parakeet_full_default_params( + pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY + ) + self._params.n_threads = int( + n_threads if n_threads is not None else min(4, os.cpu_count() or 4) + ) for key, value in params.items(): - if not hasattr(self._params, key): - raise TypeError(f"Unknown Parakeet full param: {key}") setattr(self._params, key, value) - self._init_model() + + self._ctx = pw.parakeet_init_from_file_with_params(self.model_path, cparams) + if self._ctx is None: + raise RuntimeError(f"Failed to load Parakeet model: {self.model_path}") def transcribe( self, @@ -95,23 +85,20 @@ def transcribe( Transcribe a media path or float32 mono PCM at 16 kHz. :param media: File path (wav preferred; other formats need ffmpeg) or - 1-D ``numpy.ndarray`` of float32 samples. - :param params: Temporary overrides for full params (e.g. ``n_threads``). - :return: List of :class:`~pywhispercpp.model.Segment` objects. + 1-D float32 ``numpy.ndarray``. + :param params: Overrides for ``parakeet_full_params`` for this call. + :return: List of :class:`~pywhispercpp.model.Segment`. """ if isinstance(media, np.ndarray): audio = np.ascontiguousarray(media, dtype=np.float32).ravel() else: if not Path(media).exists(): raise FileNotFoundError(media) - audio = self._load_audio(str(media)) + audio = Model._load_audio(str(media)) for key, value in params.items(): - if not hasattr(self._params, key): - raise TypeError(f"Unknown Parakeet full param: {key}") setattr(self._params, key, value) - logger.info("Transcribing with Parakeet ...") ret = pw.parakeet_full(self._ctx, self._params, audio, int(audio.size)) if ret != 0: raise RuntimeError(f"parakeet_full failed with code {ret}") @@ -121,109 +108,12 @@ def transcribe( for i in range(n): t0 = pw.parakeet_full_get_segment_t0(self._ctx, i) t1 = pw.parakeet_full_get_segment_t1(self._ctx, i) - raw = pw.parakeet_full_get_segment_text(self._ctx, i) - text = raw.decode("utf-8", errors="replace").strip() + text = pw.parakeet_full_get_segment_text(self._ctx, i).decode( + "utf-8", errors="replace" + ).strip() segments.append(Segment(t0, t1, text)) return segments - # Alias used by some call sites / docs - transcribe_samples = transcribe - - def get_params(self) -> dict: - res = {} - for name in dir(self._params): - if name.startswith("_"): - continue - try: - res[name] = getattr(self._params, name) - except Exception: - continue - return res - - @staticmethod - def version() -> str: - return pw.parakeet_version() - - @staticmethod - def _resolve_context_params(context_params: Optional[ParakeetContextParams]): - resolved = pw.parakeet_context_default_params() - if context_params is None: - return resolved - if not isinstance(context_params, dict): - raise TypeError("context_params must be a dict or None") - unknown = sorted(set(context_params) - _CONTEXT_PARAM_KEYS) - if unknown: - raise TypeError(f"Unknown context_params keys: {', '.join(unknown)}") - for key, value in context_params.items(): - setattr(resolved, key, value) - return resolved - - def _init_model(self) -> None: - logger.info("Initializing Parakeet model from %s ...", self.model_path) - self._ctx = pw.parakeet_init_from_file_with_params( - self.model_path, self._context_params - ) - # pybind returns an opaque wrapper; a failed load leaves a null internal ptr. - # Exercise free/n_segments carefully after a failed load. - if self._ctx is None: - raise RuntimeError(f"Failed to load Parakeet model: {self.model_path}") - - @staticmethod - def _load_audio(media_file_path: str) -> np.ndarray: - def wav_to_np(file_path: str) -> np.ndarray: - with wave.open(file_path, "rb") as wf: - num_channels = wf.getnchannels() - sample_width = wf.getsampwidth() - sample_rate = wf.getframerate() - num_frames = wf.getnframes() - - if num_channels not in (1, 2): - raise Exception("WAV file must be mono or stereo") - if sample_rate != pw.PARAKEET_SAMPLE_RATE: - raise Exception(f"WAV file must be {pw.PARAKEET_SAMPLE_RATE} Hz") - if sample_width != 2: - raise Exception("WAV file must be 16-bit") - - raw = wf.readframes(num_frames) - audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) - if num_channels == 1: - return audio / 32768.0 - audio = audio.reshape(-1, 2) - return (audio[:, 0] + audio[:, 1]) / 65536.0 - - if media_file_path.endswith(".wav"): - return wav_to_np(media_file_path) - - if shutil.which("ffmpeg") is None: - raise Exception( - "FFMPEG is not installed or not in PATH. " - "Install it, or provide a 16 kHz WAV or a NumPy array." - ) - - temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - temp_file_path = temp_file.name - temp_file.close() - try: - subprocess.run( - [ - "ffmpeg", - "-i", - media_file_path, - "-ac", - "1", - "-ar", - str(pw.PARAKEET_SAMPLE_RATE), - temp_file_path, - "-y", - ], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return wav_to_np(temp_file_path) - finally: - os.remove(temp_file_path) - def __del__(self): ctx = getattr(self, "_ctx", None) if ctx is not None: diff --git a/src/main.cpp b/src/main.cpp index 2673053..feb94b9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1385,7 +1385,6 @@ PYBIND11_MODULE(_pywhispercpp, m) { m.def("whisper_vad_free_segments", &whisper_vad_free_segments_wrapper); m.def("whisper_vad_free", &whisper_vad_free_wrapper); - // Parakeet (separate C API in whisper.cpp; see include/parakeet.h) init_parakeet_bindings(m); #ifdef VERSION_INFO diff --git a/src/parakeet_bindings.cpp b/src/parakeet_bindings.cpp index 8c52418..cbbb752 100644 --- a/src/parakeet_bindings.cpp +++ b/src/parakeet_bindings.cpp @@ -4,12 +4,10 @@ #include #include -#include #include "parakeet.h" #include -#include namespace py = pybind11; @@ -80,13 +78,11 @@ static py::bytes parakeet_full_get_segment_text_wrapper( if (!c_array) { return py::bytes("", 0); } - size_t length = strlen(c_array); - return py::bytes(c_array, length); + return py::bytes(c_array, strlen(c_array)); } void init_parakeet_bindings(py::module_ & m) { m.attr("PARAKEET_SAMPLE_RATE") = PARAKEET_SAMPLE_RATE; - m.attr("PARAKEET_HOP_LENGTH") = PARAKEET_HOP_LENGTH; py::enum_(m, "parakeet_sampling_strategy") .value("PARAKEET_SAMPLING_GREEDY", parakeet_sampling_strategy::PARAKEET_SAMPLING_GREEDY) @@ -108,41 +104,18 @@ void init_parakeet_bindings(py::module_ & m) { .def_readwrite("no_context", ¶keet_full_params::no_context) .def_readwrite("audio_ctx", ¶keet_full_params::audio_ctx); - m.def("parakeet_version", ¶keet_version, - "Return the Parakeet library version string."); - - m.def("parakeet_context_default_params", ¶keet_context_default_params, - "Return default Parakeet context parameters."); - - m.def("parakeet_full_default_params", ¶keet_full_default_params, - "Return default Parakeet full-params for the given sampling strategy.", - py::arg("strategy")); - + m.def("parakeet_version", ¶keet_version); + m.def("parakeet_context_default_params", ¶keet_context_default_params); + m.def("parakeet_full_default_params", ¶keet_full_default_params, py::arg("strategy")); m.def("parakeet_init_from_file_with_params", ¶keet_init_from_file_with_params_wrapper, - "Load a Parakeet GGUF model from path. Returns a context (ptr may be null on failure).", py::arg("path_model"), py::arg("params"), py::call_guard()); - - m.def("parakeet_free", ¶keet_free_wrapper, - "Free memory allocated for a Parakeet context."); - + m.def("parakeet_free", ¶keet_free_wrapper); m.def("parakeet_full", ¶keet_full_wrapper, - "Run full Parakeet inference on float32 PCM samples (16 kHz mono).", py::arg("ctx"), py::arg("params"), py::arg("samples"), py::arg("n_samples")); - - m.def("parakeet_full_n_segments", ¶keet_full_n_segments_wrapper, - "Number of text segments produced by the last parakeet_full() call."); - - m.def("parakeet_full_get_segment_t0", ¶keet_full_get_segment_t0_wrapper, - "Start time of segment i (centiseconds, same convention as whisper)."); - - m.def("parakeet_full_get_segment_t1", ¶keet_full_get_segment_t1_wrapper, - "End time of segment i (centiseconds, same convention as whisper)."); - - m.def("parakeet_full_get_segment_text", ¶keet_full_get_segment_text_wrapper, - "Text of segment i as bytes (decode as UTF-8)."); - - m.def("parakeet_print_system_info", ¶keet_print_system_info, - "Print system / backend information for Parakeet."); + m.def("parakeet_full_n_segments", ¶keet_full_n_segments_wrapper); + m.def("parakeet_full_get_segment_t0", ¶keet_full_get_segment_t0_wrapper); + m.def("parakeet_full_get_segment_t1", ¶keet_full_get_segment_t1_wrapper); + m.def("parakeet_full_get_segment_text", ¶keet_full_get_segment_text_wrapper); } diff --git a/src/parakeet_bindings.h b/src/parakeet_bindings.h index 890c279..bd9f4a8 100644 --- a/src/parakeet_bindings.h +++ b/src/parakeet_bindings.h @@ -1,6 +1,3 @@ -/** - * Declares Parakeet pybind11 registration for _pywhispercpp. - */ #pragma once #include diff --git a/tests/test_parakeet.py b/tests/test_parakeet.py index dfa1c7f..900b38a 100644 --- a/tests/test_parakeet.py +++ b/tests/test_parakeet.py @@ -1,12 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -""" -Parakeet binding smoke tests that do not require a multi-hundred-MB model download. -""" +"""Parakeet binding smoke tests (no large model download).""" import unittest -from pathlib import Path from unittest import TestCase import _pywhispercpp as pw @@ -15,7 +12,6 @@ class TestParakeetBindings(TestCase): def test_import_and_constants(self): self.assertEqual(pw.PARAKEET_SAMPLE_RATE, 16000) - self.assertEqual(pw.PARAKEET_HOP_LENGTH, 160) def test_parakeet_version(self): version = pw.parakeet_version() @@ -24,8 +20,6 @@ def test_parakeet_version(self): def test_context_default_params(self): params = pw.parakeet_context_default_params() - self.assertIsInstance(params.use_gpu, bool) - self.assertIsInstance(params.gpu_device, int) params.use_gpu = False params.gpu_device = 0 self.assertFalse(params.use_gpu) @@ -35,19 +29,9 @@ def test_full_default_params(self): params = pw.parakeet_full_default_params( pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY ) - self.assertIsInstance(params.n_threads, int) self.assertGreater(params.n_threads, 0) params.n_threads = 2 self.assertEqual(params.n_threads, 2) - self.assertIsInstance(params.offset_ms, int) - self.assertIsInstance(params.duration_ms, int) - self.assertIsInstance(params.no_context, bool) - - def test_sampling_strategy_enum(self): - self.assertEqual( - int(pw.parakeet_sampling_strategy.PARAKEET_SAMPLING_GREEDY), - 0, - ) def test_init_missing_file_returns_none(self): params = pw.parakeet_context_default_params() @@ -59,7 +43,6 @@ def test_init_missing_file_returns_none(self): self.assertIsNone(ctx) def test_free_none_safe(self): - # free(None) must not segfault. pw.parakeet_free(None) def test_python_package_export(self): From 1577f15b536f706f01630763190d8ce0862d1f4c Mon Sep 17 00:00:00 2001 From: jatinkrmalik Date: Fri, 24 Jul 2026 00:41:20 -0700 Subject: [PATCH 3/3] fix: hold GIL when returning from Parakeet model init call_guard gil_scoped_release around the whole init wrapper meant py::cast ran without the GIL and SIGSEGV'd after a successful GGUF load. Release only during the C++ load, then construct the Python object. --- src/parakeet_bindings.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/parakeet_bindings.cpp b/src/parakeet_bindings.cpp index cbbb752..79e954b 100644 --- a/src/parakeet_bindings.cpp +++ b/src/parakeet_bindings.cpp @@ -19,7 +19,12 @@ struct parakeet_context_wrapper { static py::object parakeet_init_from_file_with_params_wrapper( const char * path_model, struct parakeet_context_params cparams) { - struct parakeet_context * ctx = parakeet_init_from_file_with_params(path_model, cparams); + // Release the GIL only for the native load. py::cast / py::none need the GIL. + struct parakeet_context * ctx; + { + py::gil_scoped_release release; + ctx = parakeet_init_from_file_with_params(path_model, cparams); + } if (!ctx) { return py::none(); } @@ -107,10 +112,10 @@ void init_parakeet_bindings(py::module_ & m) { m.def("parakeet_version", ¶keet_version); m.def("parakeet_context_default_params", ¶keet_context_default_params); m.def("parakeet_full_default_params", ¶keet_full_default_params, py::arg("strategy")); + // No call_guard gil_scoped_release: this binding returns py::object via py::cast. m.def("parakeet_init_from_file_with_params", ¶keet_init_from_file_with_params_wrapper, - py::arg("path_model"), py::arg("params"), - py::call_guard()); + py::arg("path_model"), py::arg("params")); m.def("parakeet_free", ¶keet_free_wrapper); m.def("parakeet_full", ¶keet_full_wrapper, py::arg("ctx"), py::arg("params"), py::arg("samples"), py::arg("n_samples"));