lollms-client 0.15.1__py3-none-any.whl → 0.16.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.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

@@ -0,0 +1,380 @@
1
+ # lollms_client/stt_bindings/whispercpp/__init__.py
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Optional, List, Union, Dict, Any
8
+
9
+ from ascii_colors import trace_exception, ASCIIColors
10
+ # No pipmaster needed here as whisper.cpp is a C++ executable.
11
+ # Python dependencies are assumed to be handled by the environment or a higher level.
12
+
13
+ from lollms_client.lollms_stt_binding import LollmsSTTBinding
14
+
15
+ BindingName = "WhisperCppSTTBinding"
16
+
17
+ DEFAULT_WHISPERCPP_EXE_NAMES = ["main", "whisper-cli", "whisper"] # Common names for the executable
18
+
19
+ class WhisperCppSTTBinding(LollmsSTTBinding):
20
+ def __init__(self,
21
+ model_path: Union[str, Path], # Path to the GGUF Whisper model
22
+ whispercpp_exe_path: Optional[Union[str, Path]] = None, # Path to whisper.cpp executable
23
+ ffmpeg_path: Optional[Union[str, Path]] = None, # Path to ffmpeg executable (if not in PATH)
24
+ models_search_path: Optional[Union[str, Path]] = None, # Optional dir to scan for more models
25
+ default_language: str = "auto",
26
+ n_threads: int = 4,
27
+ # Catch LollmsSTTBinding standard args even if not directly used by this local binding
28
+ host_address: Optional[str] = None, # Not used for local binding
29
+ service_key: Optional[str] = None, # Not used for local binding
30
+ verify_ssl_certificate: bool = True, # Not used for local binding
31
+ **kwargs): # Catch-all for future compatibility or specific whisper.cpp params
32
+
33
+ super().__init__(binding_name="whispercpp")
34
+
35
+ # --- Validate FFMPEG ---
36
+ self.ffmpeg_exe = None
37
+ if ffmpeg_path:
38
+ resolved_ffmpeg_path = Path(ffmpeg_path)
39
+ if resolved_ffmpeg_path.is_file() and os.access(resolved_ffmpeg_path, os.X_OK):
40
+ self.ffmpeg_exe = str(resolved_ffmpeg_path)
41
+ else:
42
+ raise FileNotFoundError(f"Provided ffmpeg_path '{ffmpeg_path}' not found or not executable.")
43
+ else:
44
+ self.ffmpeg_exe = shutil.which("ffmpeg")
45
+
46
+ if not self.ffmpeg_exe:
47
+ ASCIIColors.warning("ffmpeg not found in PATH or explicitly provided. Audio conversion will not be possible for non-WAV files or incompatible WAV files.")
48
+ ASCIIColors.warning("Please install ffmpeg and ensure it's in your system's PATH, or provide ffmpeg_path argument.")
49
+ # Not raising an error here, as user might provide perfectly formatted WAV files.
50
+
51
+ # --- Validate Whisper.cpp Executable ---
52
+ self.whispercpp_exe = None
53
+ if whispercpp_exe_path:
54
+ resolved_wcpp_path = Path(whispercpp_exe_path)
55
+ if resolved_wcpp_path.is_file() and os.access(resolved_wcpp_path, os.X_OK):
56
+ self.whispercpp_exe = str(resolved_wcpp_path)
57
+ else:
58
+ raise FileNotFoundError(f"Provided whispercpp_exe_path '{whispercpp_exe_path}' not found or not executable.")
59
+ else:
60
+ for name in DEFAULT_WHISPERCPP_EXE_NAMES:
61
+ found_path = shutil.which(name)
62
+ if found_path:
63
+ self.whispercpp_exe = found_path
64
+ ASCIIColors.info(f"Found whisper.cpp executable via PATH: {self.whispercpp_exe}")
65
+ break
66
+
67
+ if not self.whispercpp_exe:
68
+ raise FileNotFoundError(
69
+ f"Whisper.cpp executable (tried: {', '.join(DEFAULT_WHISPERCPP_EXE_NAMES)}) not found in PATH or explicitly provided. "
70
+ "Please build/install whisper.cpp (from https://github.com/ggerganov/whisper.cpp) "
71
+ "and ensure its main executable is in your system's PATH or provide its path via whispercpp_exe_path argument."
72
+ )
73
+
74
+ # --- Validate Model Path ---
75
+ self.model_path = Path(model_path)
76
+ if not self.model_path.is_file():
77
+ # Try to resolve relative to models_search_path if provided and model_path is not absolute
78
+ if models_search_path and not self.model_path.is_absolute() and Path(models_search_path, self.model_path).is_file():
79
+ self.model_path = Path(models_search_path, self.model_path).resolve()
80
+ else:
81
+ raise FileNotFoundError(f"Whisper GGUF model file not found at '{self.model_path}'. Also checked in models_search_path if applicable.")
82
+
83
+ self.models_search_path = Path(models_search_path).resolve() if models_search_path else None
84
+ self.default_language = default_language
85
+ self.n_threads = n_threads
86
+ self.extra_whisper_args = kwargs.get("extra_whisper_args", []) # e.g. ["--no-timestamps"]
87
+
88
+ ASCIIColors.green(f"WhisperCppSTTBinding initialized with model: {self.model_path}")
89
+
90
+ def _convert_to_wav(self, input_audio_path: Path, output_wav_path: Path) -> bool:
91
+ if not self.ffmpeg_exe:
92
+ ASCIIColors.error("ffmpeg is required for audio conversion but was not found or configured.")
93
+ return False
94
+ try:
95
+ command = [
96
+ self.ffmpeg_exe,
97
+ "-i", str(input_audio_path),
98
+ "-ar", "16000", # 16kHz sample rate
99
+ "-ac", "1", # Mono channel
100
+ "-c:a", "pcm_s16le", # Signed 16-bit PCM little-endian
101
+ "-y", # Overwrite output file if it exists
102
+ str(output_wav_path)
103
+ ]
104
+ ASCIIColors.info(f"Converting audio with ffmpeg: {' '.join(command)}")
105
+ # Using Popen to better handle stderr/stdout if needed for detailed logging
106
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
107
+ stdout, stderr = process.communicate()
108
+
109
+ if process.returncode != 0:
110
+ ASCIIColors.error(f"ffmpeg conversion failed (exit code {process.returncode}).")
111
+ ASCIIColors.error(f"ffmpeg stdout:\n{stdout}")
112
+ ASCIIColors.error(f"ffmpeg stderr:\n{stderr}")
113
+ return False
114
+ ASCIIColors.info(f"ffmpeg conversion successful: {output_wav_path}")
115
+ return True
116
+ except FileNotFoundError: # Handle case where ffmpeg command itself is not found
117
+ ASCIIColors.error(f"ffmpeg command '{self.ffmpeg_exe}' not found. Ensure ffmpeg is installed and in PATH or ffmpeg_path is correct.")
118
+ return False
119
+ except Exception as e:
120
+ ASCIIColors.error(f"An error occurred during ffmpeg conversion: {e}")
121
+ trace_exception(e)
122
+ return False
123
+
124
+ def transcribe_audio(self, audio_path: Union[str, Path], model: Optional[str] = None, **kwargs) -> str:
125
+ input_audio_p = Path(audio_path)
126
+ if not input_audio_p.exists():
127
+ raise FileNotFoundError(f"Input audio file not found: {input_audio_p}")
128
+
129
+ current_model_path = self.model_path
130
+ if model: # User specified a different model for this transcription
131
+ potential_model_p = Path(model)
132
+ if potential_model_p.is_absolute() and potential_model_p.is_file():
133
+ current_model_path = potential_model_p
134
+ elif self.models_search_path and (self.models_search_path / model).is_file():
135
+ current_model_path = self.models_search_path / model
136
+ elif Path(model).is_file(): # Relative to current working directory?
137
+ current_model_path = Path(model)
138
+ else:
139
+ ASCIIColors.warning(f"Specified model '{model}' not found as absolute path, in models_search_path, or current dir. Using default: {self.model_path.name}")
140
+
141
+ language = kwargs.get("language", self.default_language)
142
+ threads = kwargs.get("n_threads", self.n_threads)
143
+ extra_args_call = kwargs.get("extra_whisper_args", self.extra_whisper_args)
144
+
145
+ with tempfile.TemporaryDirectory(prefix="lollms_whispercpp_") as tmpdir:
146
+ tmp_dir_path = Path(tmpdir)
147
+
148
+ # Always convert to ensure 16kHz mono WAV, unless explicitly told not to by a kwarg (e.g. assume_wav=True)
149
+ force_conversion = not kwargs.get("assume_compatible_wav", False)
150
+
151
+ if force_conversion or input_audio_p.suffix.lower() != ".wav":
152
+ if not self.ffmpeg_exe:
153
+ raise RuntimeError("ffmpeg is required for audio pre-processing but is not configured. "
154
+ "Please provide a 16kHz mono WAV file or configure ffmpeg.")
155
+ converted_wav_path = tmp_dir_path / (input_audio_p.stem + "_16khz_mono.wav")
156
+ if not self._convert_to_wav(input_audio_p, converted_wav_path):
157
+ raise Exception(f"Audio conversion to compatible WAV failed for {input_audio_p}.")
158
+ target_audio_file = converted_wav_path
159
+ else: # Input is WAV, assume it's compatible (user's responsibility if assume_compatible_wav=True)
160
+ target_audio_file = input_audio_p
161
+
162
+ command = [
163
+ self.whispercpp_exe,
164
+ "-m", str(current_model_path),
165
+ "-f", str(target_audio_file),
166
+ "-l", language,
167
+ "-t", str(threads),
168
+ "-otxt" # Output as a .txt file in the same dir as input wav
169
+ ]
170
+ if isinstance(extra_args_call, list):
171
+ command.extend(extra_args_call)
172
+ elif isinstance(extra_args_call, str):
173
+ command.extend(extra_args_call.split())
174
+
175
+ ASCIIColors.info(f"Executing Whisper.cpp: {' '.join(command)}")
176
+ try:
177
+ # Run whisper.cpp, making it output its .txt file into our temp directory.
178
+ # To do this, we can copy the target_audio_file into tmp_dir_path if it's not already there,
179
+ # then run whisper.cpp with CWD as tmp_dir_path.
180
+
181
+ final_target_audio_in_tmp: Path
182
+ if target_audio_file.parent != tmp_dir_path:
183
+ final_target_audio_in_tmp = tmp_dir_path / target_audio_file.name
184
+ shutil.copy2(target_audio_file, final_target_audio_in_tmp)
185
+ # Update command to use the path within tmp_dir_path if we copied it.
186
+ # The -f argument should be relative to the CWD if CWD is set.
187
+ command[command.index("-f")+1] = str(final_target_audio_in_tmp.name)
188
+ else:
189
+ final_target_audio_in_tmp = target_audio_file
190
+ command[command.index("-f")+1] = str(final_target_audio_in_tmp.name)
191
+
192
+
193
+ process = subprocess.run(command, capture_output=True, text=True, check=True, cwd=str(tmp_dir_path))
194
+
195
+ output_txt_file = tmp_dir_path / (final_target_audio_in_tmp.name + ".txt")
196
+
197
+ if output_txt_file.exists():
198
+ transcribed_text = output_txt_file.read_text(encoding='utf-8').strip()
199
+ ASCIIColors.green(f"Whisper.cpp transcription successful for {input_audio_p.name}.")
200
+ return transcribed_text
201
+ else:
202
+ ASCIIColors.error(f"Whisper.cpp did not produce the expected output file: {output_txt_file.name} in {tmp_dir_path}")
203
+ ASCIIColors.info(f"Whisper.cpp stdout:\n{process.stdout}")
204
+ ASCIIColors.info(f"Whisper.cpp stderr:\n{process.stderr}")
205
+ raise Exception("Whisper.cpp execution failed to produce output text file.")
206
+
207
+ except subprocess.CalledProcessError as e:
208
+ ASCIIColors.error(f"Whisper.cpp execution failed with exit code {e.returncode} for {input_audio_p.name}")
209
+ ASCIIColors.error(f"Command: {' '.join(e.cmd)}")
210
+ ASCIIColors.error(f"Stdout:\n{e.stdout}")
211
+ ASCIIColors.error(f"Stderr:\n{e.stderr}")
212
+ trace_exception(e)
213
+ raise Exception(f"Whisper.cpp execution error: {e.stderr or e.stdout or 'Unknown whisper.cpp error'}") from e
214
+ except Exception as e:
215
+ ASCIIColors.error(f"An error occurred during Whisper.cpp transcription for {input_audio_p.name}: {e}")
216
+ trace_exception(e)
217
+ raise
218
+
219
+ def list_models(self, **kwargs) -> List[str]:
220
+ models = []
221
+ # 1. Add the default configured model's name
222
+ if self.model_path and self.model_path.exists():
223
+ # For consistency, list by name. The 'model' arg in transcribe_audio can take this name.
224
+ models.append(self.model_path.name)
225
+
226
+ # 2. Scan models_search_path if provided
227
+ if self.models_search_path and self.models_search_path.is_dir():
228
+ for item in self.models_search_path.iterdir():
229
+ if item.is_file() and item.suffix.lower() == ".gguf":
230
+ # Add name if not already listed (default model might be in search path)
231
+ if item.name not in models:
232
+ models.append(item.name)
233
+
234
+ return sorted(list(set(models))) # Ensure uniqueness and sort
235
+
236
+
237
+ # --- Main Test Block ---
238
+ if __name__ == '__main__':
239
+ ASCIIColors.yellow("--- WhisperCppSTTBinding Test ---")
240
+
241
+ # --- USER CONFIGURATION REQUIRED FOR TEST ---
242
+ # Find your whisper.cpp build directory and the 'main' or 'whisper-cli' executable.
243
+ # Example: If you built whisper.cpp in /home/user/whisper.cpp, the exe might be /home/user/whisper.cpp/main
244
+ TEST_WHISPERCPP_EXE = None # SET THIS: e.g., "/path/to/whisper.cpp/main" or "whisper-cli" if in PATH
245
+
246
+ # Download a GGUF model from Hugging Face: https://huggingface.co/ggerganov/whisper.cpp/tree/main
247
+ # Place it somewhere accessible.
248
+ TEST_MODEL_GGUF = "ggml-tiny.en.bin" # SET THIS: e.g., "/path/to/models/ggml-tiny.en.bin"
249
+ # If just a name, expects it in CWD or models_search_path.
250
+
251
+ # Optional: Path to ffmpeg if not in system PATH
252
+ TEST_FFMPEG_EXE = None # e.g., "/usr/local/bin/ffmpeg"
253
+
254
+ # Optional: A directory to put other .gguf models for testing list_models
255
+ TEST_MODELS_SEARCH_DIR = Path("./test_whisper_models_cpp")
256
+ # --- END USER CONFIGURATION ---
257
+
258
+
259
+ # Create a dummy audio file for testing (requires scipy and numpy)
260
+ dummy_audio_file = Path("dummy_whispercpp_test.wav")
261
+ if not dummy_audio_file.exists():
262
+ try:
263
+ import numpy as np
264
+ from scipy.io.wavfile import write as write_wav
265
+ samplerate = 44100; duration = 1.5; frequency = 330 # A bit longer, different freq
266
+ t = np.linspace(0., duration, int(samplerate * duration), endpoint=False)
267
+ amplitude = np.iinfo(np.int16).max * 0.3
268
+ data = amplitude * np.sin(2. * np.pi * frequency * t)
269
+ # Add some noise
270
+ data += (amplitude * 0.05 * np.random.normal(size=data.shape[0])).astype(data.dtype)
271
+ write_wav(dummy_audio_file, samplerate, data.astype(np.int16))
272
+ ASCIIColors.green(f"Created dummy audio file for testing: {dummy_audio_file}")
273
+ except ImportError:
274
+ ASCIIColors.warning("SciPy/NumPy not installed. Cannot create dummy audio file for testing.")
275
+ ASCIIColors.warning(f"Please manually place a test audio file named '{dummy_audio_file.name}' in the current directory.")
276
+ except Exception as e_dummy:
277
+ ASCIIColors.error(f"Could not create dummy audio file: {e_dummy}")
278
+
279
+ # Prepare model search directory for list_models test
280
+ if TEST_MODELS_SEARCH_DIR:
281
+ TEST_MODELS_SEARCH_DIR.mkdir(exist_ok=True)
282
+ # Create another dummy GGUF (just an empty file for listing purposes)
283
+ (TEST_MODELS_SEARCH_DIR / "ggml-base.en.bin").touch(exist_ok=True)
284
+
285
+
286
+ # Basic check for prerequisites before attempting to initialize
287
+ if TEST_WHISPERCPP_EXE is None and not any(shutil.which(name) for name in DEFAULT_WHISPERCPP_EXE_NAMES):
288
+ ASCIIColors.error(f"Whisper.cpp executable not found in PATH and TEST_WHISPERCPP_EXE not set. Aborting test.")
289
+ exit(1)
290
+
291
+ if not Path(TEST_MODEL_GGUF).exists() and not (TEST_MODELS_SEARCH_DIR and (TEST_MODELS_SEARCH_DIR / TEST_MODEL_GGUF).exists()):
292
+ # Check if model is just a name and exists in current dir if TEST_MODELS_SEARCH_DIR is not set or model not in it
293
+ if not (Path().cwd()/TEST_MODEL_GGUF).exists():
294
+ ASCIIColors.error(f"Test model GGUF '{TEST_MODEL_GGUF}' not found. Please download/place it or update TEST_MODEL_GGUF path. Aborting test.")
295
+ exit(1)
296
+ else: # Found in CWD
297
+ TEST_MODEL_GGUF = str((Path().cwd()/TEST_MODEL_GGUF).resolve())
298
+
299
+
300
+ stt_binding = None
301
+ try:
302
+ ASCIIColors.cyan("\n--- Initializing WhisperCppSTTBinding ---")
303
+ stt_binding = WhisperCppSTTBinding(
304
+ model_path=TEST_MODEL_GGUF,
305
+ whispercpp_exe_path=TEST_WHISPERCPP_EXE,
306
+ ffmpeg_path=TEST_FFMPEG_EXE,
307
+ models_search_path=TEST_MODELS_SEARCH_DIR,
308
+ default_language="en",
309
+ n_threads=os.cpu_count() // 2 or 1, # Use half CPU cores or at least 1
310
+ )
311
+ ASCIIColors.green("Binding initialized successfully.")
312
+
313
+ ASCIIColors.cyan("\n--- Listing available models ---")
314
+ models = stt_binding.list_models()
315
+ if models:
316
+ print(f"Available models: {models}")
317
+ else:
318
+ ASCIIColors.warning("No models listed. Check paths and models_search_path.")
319
+
320
+ if dummy_audio_file.exists():
321
+ ASCIIColors.cyan(f"\n--- Transcribing '{dummy_audio_file.name}' (expected 16kHz mono after conversion) ---")
322
+ transcription = stt_binding.transcribe_audio(str(dummy_audio_file))
323
+ print(f"Transcription: '{transcription}'")
324
+
325
+ # Test with a different model if listed and available
326
+ if "ggml-base.en.bin" in models and "ggml-base.en.bin" != Path(TEST_MODEL_GGUF).name :
327
+ if (TEST_MODELS_SEARCH_DIR / "ggml-base.en.bin").exists(): # Ensure it's actually there
328
+ ASCIIColors.cyan(f"\n--- Transcribing with model 'ggml-base.en.bin' from search path ---")
329
+ transcription_base = stt_binding.transcribe_audio(str(dummy_audio_file), model="ggml-base.en.bin")
330
+ print(f"Transcription (ggml-base.en.bin): '{transcription_base}'")
331
+ else:
332
+ ASCIIColors.warning("Model 'ggml-base.en.bin' listed but not found in search path for test.")
333
+
334
+ # Test assume_compatible_wav (if user has a 16kHz mono WAV already)
335
+ # Create a specific 16kHz mono wav file for this
336
+ compatible_wav_file = Path("compatible_test.wav")
337
+ try:
338
+ import numpy as np
339
+ from scipy.io.wavfile import write as write_wav
340
+ samplerate = 16000; duration = 1.0; frequency = 550
341
+ t_compat = np.linspace(0., duration, int(samplerate * duration), endpoint=False)
342
+ data_compat = (np.iinfo(np.int16).max * 0.2 * np.sin(2. * np.pi * frequency * t_compat)).astype(np.int16)
343
+ write_wav(compatible_wav_file, samplerate, data_compat)
344
+ ASCIIColors.green(f"Created compatible 16kHz mono WAV: {compatible_wav_file}")
345
+
346
+ ASCIIColors.cyan(f"\n--- Transcribing '{compatible_wav_file.name}' with assume_compatible_wav=True ---")
347
+ transcription_compat = stt_binding.transcribe_audio(str(compatible_wav_file), assume_compatible_wav=True)
348
+ print(f"Transcription (compatible WAV): '{transcription_compat}'")
349
+
350
+ except ImportError: ASCIIColors.warning("SciPy/NumPy not available, skipping compatible WAV test.")
351
+ except Exception as e_compat: ASCIIColors.error(f"Error in compatible WAV test: {e_compat}")
352
+ finally:
353
+ if compatible_wav_file.exists(): compatible_wav_file.unlink(missing_ok=True)
354
+
355
+
356
+ else:
357
+ ASCIIColors.warning(f"Dummy audio file '{dummy_audio_file}' not found. Skipping main transcription test.")
358
+
359
+ except FileNotFoundError as e:
360
+ ASCIIColors.error(f"Initialization or transcription failed due to FileNotFoundError: {e}")
361
+ ASCIIColors.info("Please ensure whisper.cpp/ffmpeg executables are in PATH or paths are correctly set in the test script, and the GGUF model file exists.")
362
+ except RuntimeError as e:
363
+ ASCIIColors.error(f"Runtime error: {e}")
364
+ except Exception as e:
365
+ ASCIIColors.error(f"An unexpected error occurred: {e}")
366
+ trace_exception(e)
367
+ finally:
368
+ # Clean up dummy files created by this test script
369
+ if "samplerate" in locals() and dummy_audio_file.exists(): # Heuristic: if we created it
370
+ dummy_audio_file.unlink(missing_ok=True)
371
+ if TEST_MODELS_SEARCH_DIR:
372
+ if (TEST_MODELS_SEARCH_DIR / "ggml-base.en.bin").exists():
373
+ (TEST_MODELS_SEARCH_DIR / "ggml-base.en.bin").unlink(missing_ok=True)
374
+ # Remove dir only if it's empty (or was created by this script and now empty)
375
+ try:
376
+ if not any(TEST_MODELS_SEARCH_DIR.iterdir()):
377
+ TEST_MODELS_SEARCH_DIR.rmdir()
378
+ except OSError: pass # Ignore if not empty or other issues
379
+
380
+ ASCIIColors.yellow("\n--- WhisperCppSTTBinding Test Finished ---")
@@ -16,7 +16,6 @@ class LollmsTTIBinding_Impl(LollmsTTIBinding):
16
16
 
17
17
  def __init__(self,
18
18
  host_address: Optional[str] = "http://localhost:9600", # Default LOLLMS host
19
- model_name: Optional[str] = None, # Default service name (server decides if None)
20
19
  service_key: Optional[str] = None,
21
20
  verify_ssl_certificate: bool = True):
22
21
  """
@@ -24,14 +23,13 @@ class LollmsTTIBinding_Impl(LollmsTTIBinding):
24
23
 
25
24
  Args:
26
25
  host_address (Optional[str]): Host address for the LOLLMS service.
27
- model_name (Optional[str]): Default service/model identifier (currently unused by LOLLMS TTI endpoints).
28
26
  service_key (Optional[str]): Authentication key (used for client_id verification).
29
27
  verify_ssl_certificate (bool): Whether to verify SSL certificates.
30
28
  """
31
- super().__init__(host_address=host_address,
32
- model_name=model_name, # model_name is not directly used by LOLLMS TTI API yet
33
- service_key=service_key,
34
- verify_ssl_certificate=verify_ssl_certificate)
29
+ super().__init__(binding_name="lollms")
30
+ self.host_address=host_address
31
+ self.verify_ssl_certificate = verify_ssl_certificate
32
+
35
33
  # The 'service_key' here will act as the 'client_id' for TTI requests if provided.
36
34
  # This assumes the client library user provides their LOLLMS client_id here.
37
35
  self.client_id = service_key