whispero 0.1.0__tar.gz

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.
whispero-0.1.0/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Neguiolidas
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: whispero
3
+ Version: 0.1.0
4
+ Summary: Universal audio transcription and deep analysis - Gemini native audio plus any OpenAI-compatible API
5
+ Author: Neguiolidas
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Neguiolidas/whispero
8
+ Project-URL: Repository, https://github.com/Neguiolidas/whispero
9
+ Project-URL: Issues, https://github.com/Neguiolidas/whispero/issues
10
+ Keywords: audio,transcription,whisper,speech-to-text,ai-agents,gemini
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # Whispero
25
+
26
+ Audio transcription and deep audio analysis with any OpenAI-compatible
27
+ provider. Defaults to Gemini's native audio input (transcribes *and*
28
+ reasons in one pass); also works with OpenAI, Groq, Together, or any
29
+ Whisper-style `/v1/audio/transcriptions` endpoint.
30
+
31
+ Unlike pure ASR models, the Gemini path processes audio with reasoning: it
32
+ transcribes, cleans up, structures output, detects speakers, emotions, and
33
+ action items in a single pass.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install whispero
39
+ ```
40
+
41
+ ## Setup
42
+
43
+ ```bash
44
+ # Gemini (default) - free key at https://aistudio.google.com/apikey
45
+ export GEMINI_API_KEY="your-key-here"
46
+
47
+ # ...or any provider key, picked up automatically
48
+ export WHISPERO_API_KEY="your-key-here"
49
+ export OPENAI_API_KEY="your-key-here"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # Simple transcription, Gemini (Portuguese default)
56
+ whispero meeting.mp3
57
+
58
+ # English transcription
59
+ whispero call.mp3 --lang en
60
+
61
+ # YouTube URL directly, no download needed (Gemini only)
62
+ whispero "https://www.youtube.com/watch?v=..." --lang en
63
+ whispero-analyze "https://www.youtube.com/watch?v=..."
64
+
65
+ # OpenAI-compatible provider (Whisper-style API)
66
+ whispero meeting.mp3 --provider openai
67
+ whispero meeting.mp3 --provider openai \
68
+ --base-url https://api.groq.com/openai/v1 \
69
+ --model whisper-large-v3
70
+
71
+ # Deep analysis: transcript, speakers, tone, emotions, urgency,
72
+ # intent, action items, key moments, irony/sarcasm detection
73
+ whispero-analyze meeting.mp3
74
+ whispero-analyze meeting.mp3 --provider openai \
75
+ --base-url https://api.groq.com/openai/v1
76
+
77
+ # Faster upload for large files (compresses to opus 32k first, local files only)
78
+ whispero long.mp3 --compress
79
+
80
+ # Configurable fallback models (comma-separated, tried in order)
81
+ whispero meeting.mp3 --fallback-models gemini-2.0-flash
82
+ ```
83
+
84
+ ## Features
85
+
86
+ - Two providers: `gemini` (default, native audio + YouTube URLs) and `openai`
87
+ (any OpenAI-compatible `/v1/audio/transcriptions` endpoint)
88
+ - Speaker diarization ([FALANTE 1], [FALANTE 2] with voice characteristics)
89
+ - Retry with exponential backoff on rate limits and server errors
90
+ - `--fallback-models`: configurable comma-separated fallback list
91
+ - `--compress` flag: converts to opus 32k mono 16kHz via ffmpeg before upload
92
+ - JSON validation on analysis output
93
+ - Zero pip dependencies, only stdlib + ffmpeg
94
+
95
+ ## Benchmark
96
+
97
+ 50 seconds of real audio, Gemini (Portuguese):
98
+
99
+ | Script | Time | Output |
100
+ |---|---|---|
101
+ | transcribe.py | ~4s | faithful transcript |
102
+ | transcribe.py --compress | ~4s | same, half the upload size |
103
+ | audio_analyze.py | ~14s | full JSON analysis |
104
+
105
+ GTA 6 trailer via YouTube URL (no download):
106
+
107
+ | Script | Time | Output |
108
+ |---|---|---|
109
+ | transcribe.py | ~12s | dialogue + song lyrics + on-screen text |
110
+ | audio_analyze.py | ~23s | 4 speakers, tone, 9 key moments, intent |
111
+
112
+ ## License
113
+
114
+ Apache 2.0 — see LICENSE.
@@ -0,0 +1,91 @@
1
+ # Whispero
2
+
3
+ Audio transcription and deep audio analysis with any OpenAI-compatible
4
+ provider. Defaults to Gemini's native audio input (transcribes *and*
5
+ reasons in one pass); also works with OpenAI, Groq, Together, or any
6
+ Whisper-style `/v1/audio/transcriptions` endpoint.
7
+
8
+ Unlike pure ASR models, the Gemini path processes audio with reasoning: it
9
+ transcribes, cleans up, structures output, detects speakers, emotions, and
10
+ action items in a single pass.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install whispero
16
+ ```
17
+
18
+ ## Setup
19
+
20
+ ```bash
21
+ # Gemini (default) - free key at https://aistudio.google.com/apikey
22
+ export GEMINI_API_KEY="your-key-here"
23
+
24
+ # ...or any provider key, picked up automatically
25
+ export WHISPERO_API_KEY="your-key-here"
26
+ export OPENAI_API_KEY="your-key-here"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # Simple transcription, Gemini (Portuguese default)
33
+ whispero meeting.mp3
34
+
35
+ # English transcription
36
+ whispero call.mp3 --lang en
37
+
38
+ # YouTube URL directly, no download needed (Gemini only)
39
+ whispero "https://www.youtube.com/watch?v=..." --lang en
40
+ whispero-analyze "https://www.youtube.com/watch?v=..."
41
+
42
+ # OpenAI-compatible provider (Whisper-style API)
43
+ whispero meeting.mp3 --provider openai
44
+ whispero meeting.mp3 --provider openai \
45
+ --base-url https://api.groq.com/openai/v1 \
46
+ --model whisper-large-v3
47
+
48
+ # Deep analysis: transcript, speakers, tone, emotions, urgency,
49
+ # intent, action items, key moments, irony/sarcasm detection
50
+ whispero-analyze meeting.mp3
51
+ whispero-analyze meeting.mp3 --provider openai \
52
+ --base-url https://api.groq.com/openai/v1
53
+
54
+ # Faster upload for large files (compresses to opus 32k first, local files only)
55
+ whispero long.mp3 --compress
56
+
57
+ # Configurable fallback models (comma-separated, tried in order)
58
+ whispero meeting.mp3 --fallback-models gemini-2.0-flash
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - Two providers: `gemini` (default, native audio + YouTube URLs) and `openai`
64
+ (any OpenAI-compatible `/v1/audio/transcriptions` endpoint)
65
+ - Speaker diarization ([FALANTE 1], [FALANTE 2] with voice characteristics)
66
+ - Retry with exponential backoff on rate limits and server errors
67
+ - `--fallback-models`: configurable comma-separated fallback list
68
+ - `--compress` flag: converts to opus 32k mono 16kHz via ffmpeg before upload
69
+ - JSON validation on analysis output
70
+ - Zero pip dependencies, only stdlib + ffmpeg
71
+
72
+ ## Benchmark
73
+
74
+ 50 seconds of real audio, Gemini (Portuguese):
75
+
76
+ | Script | Time | Output |
77
+ |---|---|---|
78
+ | transcribe.py | ~4s | faithful transcript |
79
+ | transcribe.py --compress | ~4s | same, half the upload size |
80
+ | audio_analyze.py | ~14s | full JSON analysis |
81
+
82
+ GTA 6 trailer via YouTube URL (no download):
83
+
84
+ | Script | Time | Output |
85
+ |---|---|---|
86
+ | transcribe.py | ~12s | dialogue + song lyrics + on-screen text |
87
+ | audio_analyze.py | ~23s | 4 speakers, tone, 9 key moments, intent |
88
+
89
+ ## License
90
+
91
+ Apache 2.0 — see LICENSE.
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env python3
2
+ """Whispero - deep audio analysis with any OpenAI-compatible provider.
3
+
4
+ Usage:
5
+ audio_analyze.py <audio_file_or_url> [--provider gemini]
6
+ [--model gemini-2.5-flash] [--base-url URL] [--api-key KEY]
7
+ [--fallback-models m1,m2] [--compress]
8
+
9
+ Providers:
10
+ gemini - Google AI Studio, native audio input, YouTube URLs supported
11
+ (default, key via GEMINI_API_KEY or --api-key)
12
+ openai - Any OpenAI-compatible API: transcribes via
13
+ /v1/audio/transcriptions then analyzes the text via
14
+ /v1/chat/completions (key via OPENAI_API_KEY or --api-key,
15
+ URL via --base-url)
16
+
17
+ Prints JSON with: transcript, speakers, tone, emotions, emotional_arc,
18
+ urgency, intent, action_items, speaker_notes, key_moments,
19
+ irony_detected, sarcasm_detected.
20
+
21
+ Zero pip dependencies (stdlib + ffmpeg).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import base64
27
+ import json
28
+ import mimetypes
29
+ import os
30
+ import subprocess # nosec B404
31
+ import sys
32
+ import tempfile
33
+ import time
34
+ import urllib.request
35
+ import urllib.error
36
+
37
+ DEFAULT_PROVIDER = "gemini"
38
+ DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
39
+ DEFAULT_OPENAI_MODEL = "whisper-1"
40
+ DEFAULT_CHAT_MODEL = "gpt-4o-mini"
41
+ GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
42
+ OPENAI_API_BASE = "https://api.openai.com/v1"
43
+ MAX_RETRIES = 3
44
+ RETRY_DELAYS = [2, 5, 10]
45
+
46
+ ANALYZE_PROMPT = (
47
+ "Analise este áudio em profundidade e retorne um JSON com os seguintes campos:\n"
48
+ "- transcript: transcrição fiel em português, com marcação de falante se houver mais de um ([FALANTE 1], [FALANTE 2])\n"
49
+ "- speakers: lista de falantes detectados com características de voz de cada um\n"
50
+ "- tone: tom geral (ex: calmo, tenso, animado, irritado, sarcástico)\n"
51
+ "- emotions: lista das emoções detectadas com intensidade (0-1)\n"
52
+ "- emotional_arc: como as emoções mudam ao longo do áudio (início/meio/fim)\n"
53
+ "- urgency: nível de urgência 0-10 e justificativa curta\n"
54
+ "- intent: o que o falante quer (pedido, desabafo, informação, decisão, etc.)\n"
55
+ "- action_items: lista de ações ou compromissos mencionados\n"
56
+ "- speaker_notes: características de voz (ritmo, pausas, ênfases, sotaque)\n"
57
+ "- key_moments: lista de momentos importantes com timestamp aproximado e descrição\n"
58
+ "- irony_detected: true/false com explicação se houver\n"
59
+ "- sarcasm_detected: true/false com explicação se houver\n"
60
+ "Retorne APENAS o JSON, sem markdown nem comentários."
61
+ )
62
+
63
+ ANALYZE_TEXT_PROMPT = (
64
+ "Analise a transcrição abaixo em profundidade e retorne um JSON com os "
65
+ "seguintes campos:\n"
66
+ "- transcript: a transcrição recebida, com marcação de falante se houver "
67
+ "mais de um ([FALANTE 1], [FALANTE 2])\n"
68
+ "- speakers: lista de falantes detectados (se identificável pelo texto)\n"
69
+ "- tone: tom geral (ex: calmo, tenso, animado, irritado, sarcástico)\n"
70
+ "- emotions: lista das emoções detectadas com intensidade (0-1)\n"
71
+ "- emotional_arc: como as emoções mudam ao longo do texto (início/meio/fim)\n"
72
+ "- urgency: nível de urgência 0-10 e justificativa curta\n"
73
+ "- intent: o que o falante quer (pedido, desabafo, informação, decisão, etc.)\n"
74
+ "- action_items: lista de ações ou compromissos mencionados\n"
75
+ "- key_moments: lista de momentos importantes com descrição\n"
76
+ "- irony_detected: true/false com explicação se houver\n"
77
+ "- sarcasm_detected: true/false com explicação se houver\n"
78
+ "Retorne APENAS o JSON, sem markdown nem comentários.\n\nTranscrição:\n"
79
+ )
80
+
81
+
82
+ def is_url(s: str) -> bool:
83
+ return s.startswith("http://") or s.startswith("https://")
84
+
85
+
86
+ def get_api_key(args) -> str:
87
+ for src in (args.api_key, os.environ.get("WHISPERO_API_KEY"),
88
+ os.environ.get("GEMINI_API_KEY"),
89
+ os.environ.get("OPENAI_API_KEY")):
90
+ if src and src.strip():
91
+ return src.strip()
92
+ print(
93
+ "error: no API key. Use --api-key or set WHISPERO_API_KEY "
94
+ "(or GEMINI_API_KEY / OPENAI_API_KEY).",
95
+ file=sys.stderr,
96
+ )
97
+ sys.exit(3)
98
+
99
+
100
+ def compress_audio(src: str) -> str:
101
+ tmp = tempfile.NamedTemporaryFile(suffix=".ogg", delete=False)
102
+ tmp.close()
103
+ try:
104
+ subprocess.run( # nosec B607 B603
105
+ ["ffmpeg", "-y", "-v", "error", "-i", src,
106
+ "-c:a", "libopus", "-b:a", "32k", "-ar", "16000", "-ac", "1",
107
+ tmp.name],
108
+ check=True, timeout=120,
109
+ )
110
+ return tmp.name
111
+ except (subprocess.CalledProcessError, FileNotFoundError,
112
+ subprocess.TimeoutExpired):
113
+ os.unlink(tmp.name)
114
+ return src
115
+
116
+
117
+ def http_post_json(url: str, headers: dict, payload: dict,
118
+ timeout: int = 180) -> dict | None:
119
+ req = urllib.request.Request(
120
+ url, data=json.dumps(payload).encode("utf-8"),
121
+ headers=headers, method="POST",
122
+ )
123
+ last_err = None
124
+ for attempt in range(MAX_RETRIES):
125
+ try:
126
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
127
+ return json.load(resp)
128
+ except urllib.error.HTTPError as e:
129
+ if e.code in (429, 500, 502, 503):
130
+ last_err = e
131
+ else:
132
+ body = e.read().decode(errors="replace")[:300]
133
+ print(f"error: API {e.code}: {body}", file=sys.stderr)
134
+ return None
135
+ except Exception as e:
136
+ last_err = e
137
+ if attempt < MAX_RETRIES - 1:
138
+ time.sleep(RETRY_DELAYS[attempt])
139
+ print(f"warn: failed after {MAX_RETRIES} tries: {last_err}", file=sys.stderr)
140
+ return None
141
+
142
+
143
+ def extract_json(data: dict) -> dict | None:
144
+ try:
145
+ text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
146
+ except (KeyError, IndexError, TypeError):
147
+ try:
148
+ text = data["choices"][0]["message"]["content"].strip()
149
+ except (KeyError, IndexError, TypeError):
150
+ return None
151
+ if text.startswith("```"):
152
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
153
+ try:
154
+ parsed = json.loads(text)
155
+ return parsed if isinstance(parsed, dict) else None
156
+ except json.JSONDecodeError:
157
+ return None
158
+
159
+
160
+ # ---------------- Gemini provider ----------------
161
+
162
+ def gemini_analyze(base: str, key: str, model: str,
163
+ media_part: dict) -> dict | None:
164
+ url = f"{base}/{model}:generateContent?key={key}"
165
+ payload = {
166
+ "contents": [{"parts": [{"text": ANALYZE_PROMPT}, media_part]}],
167
+ "generationConfig": {"temperature": 0.2},
168
+ }
169
+ data = http_post_json(url, {"Content-Type": "application/json"}, payload)
170
+ return extract_json(data) if data else None
171
+
172
+
173
+ # ---------------- OpenAI-compatible provider ----------------
174
+
175
+ def openai_transcribe(base: str, key: str, model: str,
176
+ audio_path: str) -> str | None:
177
+ import uuid
178
+ boundary = uuid.uuid4().hex
179
+ url = f"{base.rstrip('/')}/audio/transcriptions"
180
+ with open(audio_path, "rb") as fh:
181
+ audio_bytes = fh.read()
182
+ filename = os.path.basename(audio_path)
183
+ mime, _ = mimetypes.guess_type(audio_path)
184
+ mime = mime or "application/octet-stream"
185
+ parts = []
186
+ for name, value in (("model", model), ("response_format", "text")):
187
+ parts.append(
188
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'
189
+ f'\r\n\r\n{value}\r\n'.encode()
190
+ )
191
+ parts.append(
192
+ f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
193
+ f'filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode()
194
+ + audio_bytes + b'\r\n'
195
+ )
196
+ parts.append(f'--{boundary}--\r\n'.encode())
197
+ req = urllib.request.Request(
198
+ url, data=b"".join(parts),
199
+ headers={"Authorization": f"Bearer {key}",
200
+ "Content-Type": f"multipart/form-data; boundary={boundary}"},
201
+ method="POST",
202
+ )
203
+ last_err = None
204
+ for attempt in range(MAX_RETRIES):
205
+ try:
206
+ with urllib.request.urlopen(req, timeout=300) as resp: # nosec B310
207
+ return resp.read().decode().strip() or None
208
+ except urllib.error.HTTPError as e:
209
+ if e.code in (429, 500, 502, 503):
210
+ last_err = e
211
+ else:
212
+ print(f"error: API {e.code}: {e.read().decode()[:300]}",
213
+ file=sys.stderr)
214
+ return None
215
+ except Exception as e:
216
+ last_err = e
217
+ if attempt < MAX_RETRIES - 1:
218
+ time.sleep(RETRY_DELAYS[attempt])
219
+ print(f"warn: failed after {MAX_RETRIES} tries: {last_err}", file=sys.stderr)
220
+ return None
221
+
222
+
223
+ def openai_analyze_text(base: str, key: str, model: str,
224
+ transcript: str) -> dict | None:
225
+ url = f"{base.rstrip('/')}/chat/completions"
226
+ payload = {
227
+ "model": model,
228
+ "messages": [{"role": "user",
229
+ "content": ANALYZE_TEXT_PROMPT + transcript}],
230
+ "temperature": 0.2,
231
+ }
232
+ data = http_post_json(
233
+ url, {"Content-Type": "application/json",
234
+ "Authorization": f"Bearer {key}"},
235
+ payload,
236
+ )
237
+ return extract_json(data) if data else None
238
+
239
+
240
+ # ---------------- main ----------------
241
+
242
+ def main() -> int:
243
+ p = argparse.ArgumentParser(description="Whispero deep audio analysis.")
244
+ p.add_argument("source", help="Audio file path or YouTube URL (gemini only)")
245
+ p.add_argument("--provider", default=DEFAULT_PROVIDER,
246
+ choices=["gemini", "openai"])
247
+ p.add_argument("--model", default=None,
248
+ help="Transcription model (default: gemini-2.5-flash / whisper-1)")
249
+ p.add_argument("--chat-model", default=DEFAULT_CHAT_MODEL,
250
+ help="Chat model for text analysis step (openai provider)")
251
+ p.add_argument("--base-url", default=None,
252
+ help="Custom API base URL for OpenAI-compatible providers")
253
+ p.add_argument("--api-key", default=None, help="API key (or env var)")
254
+ p.add_argument("--fallback-models", default="",
255
+ help="Comma-separated fallback models to try in order")
256
+ p.add_argument("--compress", action="store_true",
257
+ help="Compress to opus 32k before upload (local files only)")
258
+ args = p.parse_args()
259
+
260
+ key = get_api_key(args)
261
+ fallbacks = [m.strip() for m in args.fallback_models.split(",") if m.strip()]
262
+
263
+ if args.provider == "gemini":
264
+ model = args.model or DEFAULT_GEMINI_MODEL
265
+ models = [model] + [m for m in fallbacks if m != model]
266
+ base = GEMINI_API_BASE
267
+ tmp_path = None
268
+ if is_url(args.source):
269
+ media_part = {"file_data": {"file_uri": args.source}}
270
+ else:
271
+ if not os.path.isfile(args.source):
272
+ print(f"error: file not found: {args.source}", file=sys.stderr)
273
+ return 2
274
+ src = args.source
275
+ if args.compress:
276
+ tmp_path = compress_audio(src)
277
+ if tmp_path != src:
278
+ src = tmp_path
279
+ try:
280
+ mime, _ = mimetypes.guess_type(src)
281
+ mime = mime or "audio/ogg"
282
+ with open(src, "rb") as fh:
283
+ b64 = base64.b64encode(fh.read()).decode("ascii")
284
+ media_part = {"inline_data": {"mime_type": mime, "data": b64}}
285
+ finally:
286
+ if tmp_path and tmp_path != args.source:
287
+ os.unlink(tmp_path)
288
+ for m in models:
289
+ result = gemini_analyze(base, key, m, media_part)
290
+ if result:
291
+ print(json.dumps(result, ensure_ascii=False, indent=2))
292
+ return 0
293
+ print(f"warn: no usable result from {m}, trying next...",
294
+ file=sys.stderr)
295
+ else: # openai-compatible: transcribe then analyze text
296
+ if is_url(args.source):
297
+ print("error: URL input only supported with --provider gemini",
298
+ file=sys.stderr)
299
+ return 2
300
+ if not os.path.isfile(args.source):
301
+ print(f"error: file not found: {args.source}", file=sys.stderr)
302
+ return 2
303
+ model = args.model or DEFAULT_OPENAI_MODEL
304
+ models = [model] + [m for m in fallbacks if m != model]
305
+ base = args.base_url or OPENAI_API_BASE
306
+ src = args.source
307
+ tmp_path = None
308
+ if args.compress:
309
+ tmp_path = compress_audio(src)
310
+ if tmp_path != src:
311
+ src = tmp_path
312
+ try:
313
+ transcript = None
314
+ for m in models:
315
+ transcript = openai_transcribe(base, key, m, src)
316
+ if transcript:
317
+ break
318
+ print(f"warn: transcription failed on {m}, trying next...",
319
+ file=sys.stderr)
320
+ if not transcript:
321
+ print("error: transcription failed on all models",
322
+ file=sys.stderr)
323
+ return 5
324
+ result = openai_analyze_text(base, key, args.chat_model, transcript)
325
+ if result:
326
+ result.setdefault("transcript", transcript)
327
+ print(json.dumps(result, ensure_ascii=False, indent=2))
328
+ return 0
329
+ finally:
330
+ if tmp_path and tmp_path != args.source:
331
+ os.unlink(tmp_path)
332
+
333
+ print("error: no usable analysis from any model", file=sys.stderr)
334
+ return 5
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "whispero"
7
+ version = "0.1.0"
8
+ description = "Universal audio transcription and deep analysis - Gemini native audio plus any OpenAI-compatible API"
9
+ readme = "README.md"
10
+ authors = [{name = "Neguiolidas"}]
11
+ license = {text = "Apache-2.0"}
12
+ requires-python = ">=3.10"
13
+ keywords = ["audio", "transcription", "whisper", "speech-to-text", "ai-agents", "gemini"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Neguiolidas/whispero"
28
+ Repository = "https://github.com/Neguiolidas/whispero"
29
+ Issues = "https://github.com/Neguiolidas/whispero/issues"
30
+
31
+ [project.scripts]
32
+ whispero = "transcribe:main"
33
+ whispero-analyze = "audio_analyze:main"
34
+
35
+ [tool.setuptools]
36
+ py-modules = ["transcribe", "audio_analyze"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env python3
2
+ """Whispero - transcribe audio with any OpenAI-compatible provider.
3
+
4
+ Usage:
5
+ transcribe.py <audio_file_or_url> [--provider gemini] [--lang pt]
6
+ [--model gemini-2.5-flash] [--base-url URL] [--api-key KEY]
7
+ [--fallback-models m1,m2] [--prompt "..."] [--compress]
8
+
9
+ Providers:
10
+ gemini - Google AI Studio, native audio input, YouTube URLs supported
11
+ (default, key via GEMINI_API_KEY or --api-key)
12
+ openai - Any OpenAI-compatible API (OpenAI, Groq, Together, etc.)
13
+ uses /v1/audio/transcriptions (Whisper-style)
14
+ (key via OPENAI_API_KEY or --api-key, URL via --base-url)
15
+
16
+ Prints the transcript to stdout. Zero pip dependencies (stdlib + ffmpeg).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import base64
22
+ import json
23
+ import mimetypes
24
+ import os
25
+ import subprocess # nosec B404
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ import urllib.request
30
+ import urllib.error
31
+
32
+ DEFAULT_PROVIDER = "gemini"
33
+ DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
34
+ DEFAULT_OPENAI_MODEL = "whisper-1"
35
+ GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
36
+ OPENAI_API_BASE = "https://api.openai.com/v1"
37
+ MAX_RETRIES = 3
38
+ RETRY_DELAYS = [2, 5, 10]
39
+
40
+
41
+ def is_url(s: str) -> bool:
42
+ return s.startswith("http://") or s.startswith("https://")
43
+
44
+
45
+ def get_api_key(args) -> str:
46
+ for src in (args.api_key, os.environ.get("WHISPERO_API_KEY"),
47
+ os.environ.get("GEMINI_API_KEY"),
48
+ os.environ.get("OPENAI_API_KEY")):
49
+ if src and src.strip():
50
+ return src.strip()
51
+ print(
52
+ "error: no API key. Use --api-key or set WHISPERO_API_KEY "
53
+ "(or GEMINI_API_KEY / OPENAI_API_KEY).",
54
+ file=sys.stderr,
55
+ )
56
+ sys.exit(3)
57
+
58
+
59
+ def compress_audio(src: str) -> str:
60
+ """Convert to low-bitrate opus for faster upload. Returns temp path."""
61
+ tmp = tempfile.NamedTemporaryFile(suffix=".ogg", delete=False)
62
+ tmp.close()
63
+ try:
64
+ subprocess.run( # nosec B607 B603
65
+ ["ffmpeg", "-y", "-v", "error", "-i", src,
66
+ "-c:a", "libopus", "-b:a", "32k", "-ar", "16000", "-ac", "1",
67
+ tmp.name],
68
+ check=True, timeout=120,
69
+ )
70
+ return tmp.name
71
+ except (subprocess.CalledProcessError, FileNotFoundError,
72
+ subprocess.TimeoutExpired):
73
+ os.unlink(tmp.name)
74
+ return src
75
+
76
+
77
+ def http_post(url: str, headers: dict, data: bytes | None = None,
78
+ timeout: int = 180) -> dict | None:
79
+ """POST with retry on 429/5xx. Returns parsed JSON or None."""
80
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
81
+ last_err = None
82
+ for attempt in range(MAX_RETRIES):
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
85
+ return json.load(resp)
86
+ except urllib.error.HTTPError as e:
87
+ if e.code in (429, 500, 502, 503):
88
+ last_err = e
89
+ else:
90
+ body = e.read().decode(errors="replace")[:300]
91
+ print(f"error: API {e.code}: {body}", file=sys.stderr)
92
+ return None
93
+ except Exception as e:
94
+ last_err = e
95
+ if attempt < MAX_RETRIES - 1:
96
+ time.sleep(RETRY_DELAYS[attempt])
97
+ print(f"warn: failed after {MAX_RETRIES} tries: {last_err}", file=sys.stderr)
98
+ return None
99
+
100
+
101
+ # ---------------- Gemini provider ----------------
102
+
103
+ def gemini_transcribe(base: str, key: str, model: str, prompt: str,
104
+ media_part: dict) -> str | None:
105
+ url = f"{base}/{model}:generateContent?key={key}"
106
+ payload = {"contents": [{"parts": [{"text": prompt}, media_part]}]}
107
+ data = http_post(url, {"Content-Type": "application/json"},
108
+ json.dumps(payload).encode())
109
+ if not data:
110
+ return None
111
+ try:
112
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
113
+ except (KeyError, IndexError, TypeError):
114
+ return None
115
+
116
+
117
+ # ---------------- OpenAI-compatible provider ----------------
118
+
119
+ def openai_transcribe(base: str, key: str, model: str, audio_path: str,
120
+ lang: str) -> str | None:
121
+ """Whisper-style /v1/audio/transcriptions with multipart upload."""
122
+ import uuid
123
+ boundary = uuid.uuid4().hex
124
+ url = f"{base.rstrip('/')}/audio/transcriptions"
125
+
126
+ with open(audio_path, "rb") as fh:
127
+ audio_bytes = fh.read()
128
+ filename = os.path.basename(audio_path)
129
+ mime, _ = mimetypes.guess_type(audio_path)
130
+ mime = mime or "application/octet-stream"
131
+
132
+ parts = []
133
+ for name, value in (("model", model), ("language", lang),
134
+ ("response_format", "text")):
135
+ parts.append(
136
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'
137
+ f'\r\n\r\n{value}\r\n'.encode()
138
+ )
139
+ parts.append(
140
+ f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
141
+ f'filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode()
142
+ + audio_bytes + b'\r\n'
143
+ )
144
+ parts.append(f'--{boundary}--\r\n'.encode())
145
+ body = b"".join(parts)
146
+
147
+ req = urllib.request.Request(
148
+ url, data=body,
149
+ headers={"Authorization": f"Bearer {key}",
150
+ "Content-Type": f"multipart/form-data; boundary={boundary}"},
151
+ method="POST",
152
+ )
153
+ last_err = None
154
+ for attempt in range(MAX_RETRIES):
155
+ try:
156
+ with urllib.request.urlopen(req, timeout=300) as resp: # nosec B310
157
+ return resp.read().decode().strip() or None
158
+ except urllib.error.HTTPError as e:
159
+ if e.code in (429, 500, 502, 503):
160
+ last_err = e
161
+ else:
162
+ print(f"error: API {e.code}: {e.read().decode()[:300]}",
163
+ file=sys.stderr)
164
+ return None
165
+ except Exception as e:
166
+ last_err = e
167
+ if attempt < MAX_RETRIES - 1:
168
+ time.sleep(RETRY_DELAYS[attempt])
169
+ print(f"warn: failed after {MAX_RETRIES} tries: {last_err}", file=sys.stderr)
170
+ return None
171
+
172
+
173
+ # ---------------- main ----------------
174
+
175
+ def main() -> int:
176
+ p = argparse.ArgumentParser(description="Whispero transcription.")
177
+ p.add_argument("source", help="Audio file path or YouTube URL (gemini only)")
178
+ p.add_argument("--provider", default=DEFAULT_PROVIDER,
179
+ choices=["gemini", "openai"])
180
+ p.add_argument("--model", default=None,
181
+ help="Model name (default: gemini-2.5-flash / whisper-1)")
182
+ p.add_argument("--base-url", default=None,
183
+ help="Custom API base URL for OpenAI-compatible providers")
184
+ p.add_argument("--api-key", default=None, help="API key (or env var)")
185
+ p.add_argument("--fallback-models", default="",
186
+ help="Comma-separated fallback models to try in order")
187
+ p.add_argument("--prompt", default=None)
188
+ p.add_argument("--lang", default="pt")
189
+ p.add_argument("--compress", action="store_true",
190
+ help="Compress to opus 32k before upload (local files only)")
191
+ args = p.parse_args()
192
+
193
+ key = get_api_key(args)
194
+ fallbacks = [m.strip() for m in args.fallback_models.split(",") if m.strip()]
195
+
196
+ if args.provider == "gemini":
197
+ model = args.model or DEFAULT_GEMINI_MODEL
198
+ models = [model] + [m for m in fallbacks if m != model]
199
+ base = GEMINI_API_BASE
200
+ prompt = args.prompt or (
201
+ f"Transcreva este áudio de forma fiel em {args.lang}. "
202
+ "Retorne apenas a transcrição, sem comentários."
203
+ if args.lang.startswith("pt")
204
+ else f"Transcribe this audio faithfully in {args.lang}. "
205
+ "Return only the transcript, no commentary."
206
+ )
207
+ tmp_path = None
208
+ if is_url(args.source):
209
+ media_part = {"file_data": {"file_uri": args.source}}
210
+ else:
211
+ if not os.path.isfile(args.source):
212
+ print(f"error: file not found: {args.source}", file=sys.stderr)
213
+ return 2
214
+ src = args.source
215
+ if args.compress:
216
+ tmp_path = compress_audio(src)
217
+ if tmp_path != src:
218
+ src = tmp_path
219
+ try:
220
+ mime, _ = mimetypes.guess_type(src)
221
+ mime = mime or "audio/ogg"
222
+ with open(src, "rb") as fh:
223
+ b64 = base64.b64encode(fh.read()).decode("ascii")
224
+ media_part = {"inline_data": {"mime_type": mime, "data": b64}}
225
+ finally:
226
+ if tmp_path and tmp_path != args.source:
227
+ os.unlink(tmp_path)
228
+ for m in models:
229
+ text = gemini_transcribe(base, key, m, prompt, media_part)
230
+ if text:
231
+ print(text)
232
+ return 0
233
+ print(f"warn: empty response from {m}, trying next...",
234
+ file=sys.stderr)
235
+ else: # openai-compatible
236
+ if is_url(args.source):
237
+ print("error: URL input only supported with --provider gemini",
238
+ file=sys.stderr)
239
+ return 2
240
+ if not os.path.isfile(args.source):
241
+ print(f"error: file not found: {args.source}", file=sys.stderr)
242
+ return 2
243
+ model = args.model or DEFAULT_OPENAI_MODEL
244
+ models = [model] + [m for m in fallbacks if m != model]
245
+ base = args.base_url or OPENAI_API_BASE
246
+ src = args.source
247
+ tmp_path = None
248
+ if args.compress:
249
+ tmp_path = compress_audio(src)
250
+ if tmp_path != src:
251
+ src = tmp_path
252
+ try:
253
+ for m in models:
254
+ text = openai_transcribe(base, key, m, src, args.lang)
255
+ if text:
256
+ print(text)
257
+ return 0
258
+ print(f"warn: empty response from {m}, trying next...",
259
+ file=sys.stderr)
260
+ finally:
261
+ if tmp_path and tmp_path != args.source:
262
+ os.unlink(tmp_path)
263
+
264
+ print("error: no usable transcript from any model", file=sys.stderr)
265
+ return 5
266
+
267
+
268
+ if __name__ == "__main__":
269
+ sys.exit(main())
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: whispero
3
+ Version: 0.1.0
4
+ Summary: Universal audio transcription and deep analysis - Gemini native audio plus any OpenAI-compatible API
5
+ Author: Neguiolidas
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Neguiolidas/whispero
8
+ Project-URL: Repository, https://github.com/Neguiolidas/whispero
9
+ Project-URL: Issues, https://github.com/Neguiolidas/whispero/issues
10
+ Keywords: audio,transcription,whisper,speech-to-text,ai-agents,gemini
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # Whispero
25
+
26
+ Audio transcription and deep audio analysis with any OpenAI-compatible
27
+ provider. Defaults to Gemini's native audio input (transcribes *and*
28
+ reasons in one pass); also works with OpenAI, Groq, Together, or any
29
+ Whisper-style `/v1/audio/transcriptions` endpoint.
30
+
31
+ Unlike pure ASR models, the Gemini path processes audio with reasoning: it
32
+ transcribes, cleans up, structures output, detects speakers, emotions, and
33
+ action items in a single pass.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install whispero
39
+ ```
40
+
41
+ ## Setup
42
+
43
+ ```bash
44
+ # Gemini (default) - free key at https://aistudio.google.com/apikey
45
+ export GEMINI_API_KEY="your-key-here"
46
+
47
+ # ...or any provider key, picked up automatically
48
+ export WHISPERO_API_KEY="your-key-here"
49
+ export OPENAI_API_KEY="your-key-here"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # Simple transcription, Gemini (Portuguese default)
56
+ whispero meeting.mp3
57
+
58
+ # English transcription
59
+ whispero call.mp3 --lang en
60
+
61
+ # YouTube URL directly, no download needed (Gemini only)
62
+ whispero "https://www.youtube.com/watch?v=..." --lang en
63
+ whispero-analyze "https://www.youtube.com/watch?v=..."
64
+
65
+ # OpenAI-compatible provider (Whisper-style API)
66
+ whispero meeting.mp3 --provider openai
67
+ whispero meeting.mp3 --provider openai \
68
+ --base-url https://api.groq.com/openai/v1 \
69
+ --model whisper-large-v3
70
+
71
+ # Deep analysis: transcript, speakers, tone, emotions, urgency,
72
+ # intent, action items, key moments, irony/sarcasm detection
73
+ whispero-analyze meeting.mp3
74
+ whispero-analyze meeting.mp3 --provider openai \
75
+ --base-url https://api.groq.com/openai/v1
76
+
77
+ # Faster upload for large files (compresses to opus 32k first, local files only)
78
+ whispero long.mp3 --compress
79
+
80
+ # Configurable fallback models (comma-separated, tried in order)
81
+ whispero meeting.mp3 --fallback-models gemini-2.0-flash
82
+ ```
83
+
84
+ ## Features
85
+
86
+ - Two providers: `gemini` (default, native audio + YouTube URLs) and `openai`
87
+ (any OpenAI-compatible `/v1/audio/transcriptions` endpoint)
88
+ - Speaker diarization ([FALANTE 1], [FALANTE 2] with voice characteristics)
89
+ - Retry with exponential backoff on rate limits and server errors
90
+ - `--fallback-models`: configurable comma-separated fallback list
91
+ - `--compress` flag: converts to opus 32k mono 16kHz via ffmpeg before upload
92
+ - JSON validation on analysis output
93
+ - Zero pip dependencies, only stdlib + ffmpeg
94
+
95
+ ## Benchmark
96
+
97
+ 50 seconds of real audio, Gemini (Portuguese):
98
+
99
+ | Script | Time | Output |
100
+ |---|---|---|
101
+ | transcribe.py | ~4s | faithful transcript |
102
+ | transcribe.py --compress | ~4s | same, half the upload size |
103
+ | audio_analyze.py | ~14s | full JSON analysis |
104
+
105
+ GTA 6 trailer via YouTube URL (no download):
106
+
107
+ | Script | Time | Output |
108
+ |---|---|---|
109
+ | transcribe.py | ~12s | dialogue + song lyrics + on-screen text |
110
+ | audio_analyze.py | ~23s | 4 speakers, tone, 9 key moments, intent |
111
+
112
+ ## License
113
+
114
+ Apache 2.0 — see LICENSE.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ audio_analyze.py
4
+ pyproject.toml
5
+ transcribe.py
6
+ whispero.egg-info/PKG-INFO
7
+ whispero.egg-info/SOURCES.txt
8
+ whispero.egg-info/dependency_links.txt
9
+ whispero.egg-info/entry_points.txt
10
+ whispero.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ whispero = transcribe:main
3
+ whispero-analyze = audio_analyze:main
@@ -0,0 +1,2 @@
1
+ audio_analyze
2
+ transcribe