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 +17 -0
- whispero-0.1.0/PKG-INFO +114 -0
- whispero-0.1.0/README.md +91 -0
- whispero-0.1.0/audio_analyze.py +338 -0
- whispero-0.1.0/pyproject.toml +36 -0
- whispero-0.1.0/setup.cfg +4 -0
- whispero-0.1.0/transcribe.py +269 -0
- whispero-0.1.0/whispero.egg-info/PKG-INFO +114 -0
- whispero-0.1.0/whispero.egg-info/SOURCES.txt +10 -0
- whispero-0.1.0/whispero.egg-info/dependency_links.txt +1 -0
- whispero-0.1.0/whispero.egg-info/entry_points.txt +3 -0
- whispero-0.1.0/whispero.egg-info/top_level.txt +2 -0
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.
|
whispero-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
whispero-0.1.0/README.md
ADDED
|
@@ -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"]
|
whispero-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|