Compare commits
11
Commits
ca2e41e0f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7f93d067 | ||
|
|
d8b92d5025 | ||
|
|
0af80fde24 | ||
|
|
7c8d283103 | ||
|
|
4c06fbdc8d | ||
|
|
39c0e89c67 | ||
|
|
c533da9d86 | ||
|
|
0a7b20f408 | ||
|
|
2c6f7aee8e | ||
|
|
3b1e90a54d | ||
|
|
5b468c87f7 |
@@ -0,0 +1,17 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
logs/
|
||||||
|
history/
|
||||||
|
models/
|
||||||
|
tmp/
|
||||||
|
client.png
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
sox \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/logs /app/history
|
||||||
|
|
||||||
|
EXPOSE 5042
|
||||||
|
|
||||||
|
CMD ["python", "server.py", "--config", "config.docker.json"]
|
||||||
@@ -63,6 +63,88 @@ pip install -r requirements.txt
|
|||||||
python server.py
|
python server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The repository now includes a container setup for running the API in Docker.
|
||||||
|
|
||||||
|
### What gets mounted
|
||||||
|
|
||||||
|
- `./config.docker.json` -> `/app/config.docker.json`
|
||||||
|
- `./logs` -> `/app/logs`
|
||||||
|
- `./history` -> `/app/history`
|
||||||
|
- `./models` -> `/models`
|
||||||
|
|
||||||
|
Place your Whisper model inside `./models/whisper` or update `model_path` in `config.docker.json`.
|
||||||
|
That path must contain the actual Hugging Face model files, not just an empty folder.
|
||||||
|
|
||||||
|
If you prefer automatic bootstrap, set:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"auto_download_missing_model": true,
|
||||||
|
"model_repo_id": "openai/whisper-large-v3"
|
||||||
|
```
|
||||||
|
|
||||||
|
With the default Docker config, the container will automatically download that
|
||||||
|
model into `/models/whisper` on first start if the directory is missing or incomplete.
|
||||||
|
|
||||||
|
Expected example layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
models/
|
||||||
|
whisper/
|
||||||
|
config.json
|
||||||
|
generation_config.json
|
||||||
|
preprocessor_config.json
|
||||||
|
tokenizer.json
|
||||||
|
tokenizer_config.json
|
||||||
|
model.safetensors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and run with Docker Compose
|
||||||
|
|
||||||
|
If your selected model requires authentication on Hugging Face, export a token first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HF_TOKEN=your_huggingface_token
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5042
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and run with plain Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t whisper-api-server .
|
||||||
|
docker run --rm -p 5042:5042 \
|
||||||
|
-e HF_TOKEN="$HF_TOKEN" \
|
||||||
|
-v "$(pwd)/config.docker.json:/app/config.docker.json:ro" \
|
||||||
|
-v "$(pwd)/logs:/app/logs" \
|
||||||
|
-v "$(pwd)/history:/app/history" \
|
||||||
|
-v "$(pwd)/models:/models" \
|
||||||
|
whisper-api-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU note
|
||||||
|
|
||||||
|
The container image installs the same Python dependencies as the local setup, including CUDA-oriented PyTorch wheels on Linux x86_64. To actually use NVIDIA GPU acceleration at runtime, start the container with GPU access enabled in your Docker environment, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all -p 5042:5042 \
|
||||||
|
-e HF_TOKEN="$HF_TOKEN" \
|
||||||
|
-v "$(pwd)/config.docker.json:/app/config.docker.json:ro" \
|
||||||
|
-v "$(pwd)/logs:/app/logs" \
|
||||||
|
-v "$(pwd)/history:/app/history" \
|
||||||
|
-v "$(pwd)/models:/models" \
|
||||||
|
whisper-api-server
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The service is configured through the `config.json` file:
|
The service is configured through the `config.json` file:
|
||||||
@@ -71,16 +153,30 @@ The service is configured through the `config.json` file:
|
|||||||
{
|
{
|
||||||
"service_port": 5042,
|
"service_port": 5042,
|
||||||
"model_path": "/path/to/whisper/model",
|
"model_path": "/path/to/whisper/model",
|
||||||
|
"auto_download_missing_model": false,
|
||||||
|
"model_repo_id": "openai/whisper-large-v3",
|
||||||
"language": "russian",
|
"language": "russian",
|
||||||
"enable_history": true,
|
"enable_history": true,
|
||||||
|
"max_history_days": 30,
|
||||||
"chunk_length_s": 28,
|
"chunk_length_s": 28,
|
||||||
"batch_size": 8,
|
"batch_size": 6,
|
||||||
"max_new_tokens": 384,
|
"max_new_tokens": 384,
|
||||||
"temperature": 0.01,
|
"temperature": 0.01,
|
||||||
"return_timestamps": false,
|
"return_timestamps": false,
|
||||||
"audio_rate": 8000,
|
"audio_rate": 16000,
|
||||||
"norm_level": "-0.55",
|
"norm_level": "-0.55",
|
||||||
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15"
|
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||||
|
"device_id": 0,
|
||||||
|
"file_validation": {
|
||||||
|
"max_file_size_mb": 500,
|
||||||
|
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
|
||||||
|
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||||
|
},
|
||||||
|
"log_level": "INFO",
|
||||||
|
"log_file": "logs/whisper_api.log",
|
||||||
|
"request_logging": {
|
||||||
|
"exclude_endpoints": ["/health", "/static"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -90,8 +186,11 @@ The service is configured through the `config.json` file:
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `service_port` | Port on which the service will run |
|
| `service_port` | Port on which the service will run |
|
||||||
| `model_path` | Path to the Whisper model directory |
|
| `model_path` | Path to the Whisper model directory |
|
||||||
|
| `auto_download_missing_model` | Download the configured fallback model into `model_path` when the local directory is missing or incomplete |
|
||||||
|
| `model_repo_id` | Hugging Face model repo to download when auto-download is enabled |
|
||||||
| `language` | Language for transcription (e.g., "russian", "english") |
|
| `language` | Language for transcription (e.g., "russian", "english") |
|
||||||
| `enable_history` | Whether to save transcription history (true/false) |
|
| `enable_history` | Whether to save transcription history (true/false) |
|
||||||
|
| `max_history_days` | Number of days to keep transcription history before rotation |
|
||||||
| `chunk_length_s` | Length of audio chunks for processing (in seconds) |
|
| `chunk_length_s` | Length of audio chunks for processing (in seconds) |
|
||||||
| `batch_size` | Batch size for processing |
|
| `batch_size` | Batch size for processing |
|
||||||
| `max_new_tokens` | Maximum new tokens for the model output |
|
| `max_new_tokens` | Maximum new tokens for the model output |
|
||||||
@@ -100,6 +199,13 @@ The service is configured through the `config.json` file:
|
|||||||
| `audio_rate` | Audio sampling rate in Hz |
|
| `audio_rate` | Audio sampling rate in Hz |
|
||||||
| `norm_level` | Normalization level for audio preprocessing |
|
| `norm_level` | Normalization level for audio preprocessing |
|
||||||
| `compand_params` | Parameters for audio compression/expansion |
|
| `compand_params` | Parameters for audio compression/expansion |
|
||||||
|
| `device_id` | CUDA device index to use for inference |
|
||||||
|
| `file_validation.max_file_size_mb` | Maximum allowed file size in megabytes |
|
||||||
|
| `file_validation.allowed_extensions` | List of accepted audio file extensions |
|
||||||
|
| `file_validation.allowed_mime_types` | List of accepted MIME types |
|
||||||
|
| `log_level` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| `log_file` | Path to the log file |
|
||||||
|
| `request_logging.exclude_endpoints` | Endpoints excluded from request logging |
|
||||||
|
|
||||||
## Web interface
|
## Web interface
|
||||||
|
|
||||||
@@ -159,14 +265,33 @@ curl -X POST http://localhost:5042/v1/audio/transcriptions/base64 \
|
|||||||
-d '{"file":"base64_encoded_audio_data"}'
|
-d '{"file":"base64_encoded_audio_data"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transcribe a local file on the server
|
### Transcribe asynchronously
|
||||||
|
|
||||||
|
Submit a file for background transcription and receive a task ID:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5042/local/transcriptions \
|
curl -X POST http://localhost:5042/v1/audio/transcriptions/async \
|
||||||
-H "Content-Type: application/json" \
|
-F file=@audio.mp3
|
||||||
-d '{"file_path":"/path/to/audio.mp3"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{"task_id": "abc123..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get async task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5042/v1/tasks/<task_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response when completed:
|
||||||
|
```json
|
||||||
|
{"task_id": "abc123...", "status": "completed", "result": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible statuses: `pending`, `completed`, `failed`.
|
||||||
|
|
||||||
### Request with additional parameters
|
### Request with additional parameters
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -215,45 +340,22 @@ curl -X POST http://localhost:5042/v1/audio/transcriptions \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
The project consists of the following components:
|
|
||||||
|
|
||||||
- `server.py`: Entry point that initializes and starts the service
|
|
||||||
- `server.sh`: Bash script for launching the server with optional conda environment update
|
|
||||||
- `config.json`: Service configuration file
|
|
||||||
- `app/`: Main application module
|
|
||||||
- `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization
|
|
||||||
- `routes.py`: API route definitions
|
|
||||||
- `history.py`: Saving transcription history
|
|
||||||
- `core/`: Core logic
|
|
||||||
- `transcriber.py`: `WhisperTranscriber` class for speech recognition
|
|
||||||
- `transcription_service.py`: Manages the transcription workflow
|
|
||||||
- `audio/`: Audio processing
|
|
||||||
- `processor.py`: `AudioProcessor` class for audio preprocessing
|
|
||||||
- `sources.py`: Audio source handlers (upload, URL, base64)
|
|
||||||
- `utils.py`: Audio utilities (loading, duration)
|
|
||||||
- `infrastructure/`: Supporting modules
|
|
||||||
- `log.py`: Logging configuration
|
|
||||||
- `validation.py`: File validation
|
|
||||||
- `storage.py`: Temp file management
|
|
||||||
- `async_tasks.py`: Async task manager
|
|
||||||
- `static/`: Web interface files
|
|
||||||
|
|
||||||
## Advanced usage
|
## Advanced usage
|
||||||
|
|
||||||
### Using with different models
|
### Using with different models
|
||||||
|
|
||||||
You can use any Whisper model by changing the `model_path` in the configuration:
|
You can use any Whisper model by changing the `model_path` in the configuration:
|
||||||
|
|
||||||
1. Download a model from Hugging Face (e.g., `openai/whisper-large-v3`)
|
1. Download a model from Hugging Face
|
||||||
2. Update the `model_path` in `config.json`
|
2. Update the `model_path` in `config.json`
|
||||||
3. Restart the service
|
3. Restart the service
|
||||||
|
|
||||||
|
The recommended model for Russian speech recognition is [whisper-large-v3-russian-ties-podlodka-v1.2](https://huggingface.co/Apel-sin/whisper-large-v3-russian-ties-podlodka-v1.2).
|
||||||
|
|
||||||
### Hardware acceleration
|
### Hardware acceleration
|
||||||
|
|
||||||
The service automatically selects the best available compute device:
|
The service automatically selects the best available compute device:
|
||||||
- CUDA GPU (index 1 if available, otherwise index 0)
|
- CUDA GPU (device index configured via `device_id` in `config.json`)
|
||||||
- Apple Silicon MPS (for Mac with M1/M2/M3 chips)
|
- Apple Silicon MPS (for Mac with M1/M2/M3 chips)
|
||||||
- CPU (fallback)
|
- CPU (fallback)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -48,7 +48,7 @@ class WhisperServiceAPI:
|
|||||||
|
|
||||||
# Получаем логгер для этого модуля
|
# Получаем логгер для этого модуля
|
||||||
self.logger = logging.getLogger('app')
|
self.logger = logging.getLogger('app')
|
||||||
self.logger.info("Инициализация WhisperServiceAPI")
|
self.logger.info("Initializing WhisperServiceAPI")
|
||||||
|
|
||||||
# Инициализация Flask приложения
|
# Инициализация Flask приложения
|
||||||
self.app = Flask(__name__)
|
self.app = Flask(__name__)
|
||||||
@@ -66,11 +66,11 @@ class WhisperServiceAPI:
|
|||||||
# Регистрация маршрутов
|
# Регистрация маршрутов
|
||||||
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
||||||
|
|
||||||
self.logger.info("WhisperServiceAPI успешно инициализирован")
|
self.logger.info("WhisperServiceAPI initialized successfully")
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Запуск сервиса через Waitress."""
|
"""Запуск сервиса через Waitress."""
|
||||||
self.logger.info("Запуск сервиса на 0.0.0.0:%s", self.port)
|
self.logger.info("Starting service on 0.0.0.0:%s", self.port)
|
||||||
waitress.serve(self.app, host='0.0.0.0', port=self.port)
|
waitress.serve(self.app, host='0.0.0.0', port=self.port)
|
||||||
|
|
||||||
def create_app(self) -> Flask:
|
def create_app(self) -> Flask:
|
||||||
|
|||||||
+11
-10
@@ -59,20 +59,21 @@ class AudioProcessor:
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "warning",
|
"-loglevel", "warning",
|
||||||
|
"-y",
|
||||||
"-i", input_path,
|
"-i", input_path,
|
||||||
"-ar", f"{audio_rate}",
|
"-ar", f"{audio_rate}",
|
||||||
"-ac", "1", # Монофонический звук
|
"-ac", "1", # Монофонический звук
|
||||||
output_path
|
output_path
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.debug("Конвертация в WAV: %s", " ".join(cmd))
|
logger.debug("Converting to WAV: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info("Файл конвертирован в WAV: %s", output_path)
|
logger.info("Converted file to WAV: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error("Ошибка при конвертации в WAV: %s", e.stderr.decode())
|
logger.error("Failed to convert to WAV: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def normalize_audio(self, input_path: str) -> str:
|
def normalize_audio(self, input_path: str) -> str:
|
||||||
@@ -100,14 +101,14 @@ class AudioProcessor:
|
|||||||
"compand"
|
"compand"
|
||||||
] + self.compand_params.split()
|
] + self.compand_params.split()
|
||||||
|
|
||||||
logger.debug("Нормализация аудио: %s", " ".join(cmd))
|
logger.debug("Normalizing audio: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info("Аудио нормализовано: %s", output_path)
|
logger.info("Audio normalized: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error("Ошибка при нормализации аудио: %s", e.stderr.decode())
|
logger.error("Failed to normalize audio: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_silence(self, input_path: str) -> str:
|
def add_silence(self, input_path: str) -> str:
|
||||||
@@ -134,14 +135,14 @@ class AudioProcessor:
|
|||||||
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
|
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Добавление тишины: %s", " ".join(cmd))
|
logger.info("Adding silence padding: %s", " ".join(cmd))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
logger.info("Тишина добавлена: %s", output_path)
|
logger.info("Silence padding added: %s", output_path)
|
||||||
return output_path
|
return output_path
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error("Ошибка при добавлении тишины: %s", e.stderr.decode())
|
logger.error("Failed to add silence padding: %s", e.stderr.decode())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def process_audio(self, input_path: str) -> Tuple[str, list]:
|
def process_audio(self, input_path: str) -> Tuple[str, list]:
|
||||||
@@ -176,6 +177,6 @@ class AudioProcessor:
|
|||||||
return silence_path, temp_files
|
return silence_path, temp_files
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при обработке аудио %s: %s", input_path, e)
|
logger.error("Failed to process audio %s: %s", input_path, e)
|
||||||
cleanup_temp_files(temp_files)
|
cleanup_temp_files(temp_files)
|
||||||
raise
|
raise
|
||||||
@@ -104,7 +104,7 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
|
|||||||
return temp_path, original_name or os.path.basename(temp_path), None
|
return temp_path, original_name or os.path.basename(temp_path), None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при получении файла по URL %s: %s", url, e)
|
logger.error("Failed to fetch file from URL %s: %s", url, e)
|
||||||
return None, None, f"Error retrieving file from URL: {str(e)}"
|
return None, None, f"Error retrieving file from URL: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -144,5 +144,5 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
|
|||||||
return temp_path, os.path.basename(temp_path), None
|
return temp_path, os.path.basename(temp_path), None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при декодировании base64 данных: %s", e)
|
logger.error("Failed to decode base64 data: %s", e)
|
||||||
return None, None, f"Error decoding base64 data: {str(e)}"
|
return None, None, f"Error decoding base64 data: {str(e)}"
|
||||||
|
|||||||
+6
-6
@@ -27,7 +27,7 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
|||||||
try:
|
try:
|
||||||
with wave.open(file_path, 'rb') as wav_file:
|
with wave.open(file_path, 'rb') as wav_file:
|
||||||
if wav_file.getnchannels() != 1:
|
if wav_file.getnchannels() != 1:
|
||||||
logger.warning("Файл %s не моно-аудио", file_path)
|
logger.warning("File %s is not mono audio", file_path)
|
||||||
|
|
||||||
frames = wav_file.readframes(-1)
|
frames = wav_file.readframes(-1)
|
||||||
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
@@ -41,7 +41,7 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
|||||||
return audio_array, sampling_rate
|
return audio_array, sampling_rate
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при загрузке аудио %s: %s", file_path, e)
|
logger.error("Failed to load audio %s: %s", file_path, e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def get_audio_duration(file_path: str) -> float:
|
|||||||
Длительность в секундах.
|
Длительность в секундах.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise Exception(f"Файл не существует: {file_path}")
|
raise Exception(f"File does not exist: {file_path}")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
@@ -70,8 +70,8 @@ def get_audio_duration(file_path: str) -> float:
|
|||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10)
|
||||||
return float(result.stdout.strip())
|
return float(result.stdout.strip())
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Exception(f"Таймаут при определении длительности файла {file_path}")
|
raise Exception(f"Timed out while determining duration for file {file_path}")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
raise Exception(f"Ошибка ffprobe для файла {file_path}: {e.stderr}")
|
raise Exception(f"ffprobe error for file {file_path}: {e.stderr}")
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
raise Exception(f"Failed to parse duration for file {file_path}: {e}")
|
||||||
|
|||||||
+3
-3
@@ -27,11 +27,11 @@ def load_config(config_path: str) -> Dict:
|
|||||||
try:
|
try:
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
logger.info("Конфигурация успешно загружена из %s", config_path)
|
logger.info("Configuration loaded successfully from %s", config_path)
|
||||||
return config
|
return config
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
logger.error("Файл конфигурации не найден: %s", e)
|
logger.error("Configuration file not found: %s", e)
|
||||||
raise
|
raise
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error("Ошибка при загрузке конфигурации: %s", e)
|
logger.error("Failed to load configuration: %s", e)
|
||||||
raise
|
raise
|
||||||
+116
-21
@@ -6,6 +6,7 @@ OpenAI для транскрибации аудиофайлов в текст.
|
|||||||
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
|
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
@@ -14,6 +15,7 @@ import logging
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
from transformers import (
|
from transformers import (
|
||||||
WhisperForConditionalGeneration,
|
WhisperForConditionalGeneration,
|
||||||
WhisperProcessor,
|
WhisperProcessor,
|
||||||
@@ -94,19 +96,19 @@ class WhisperTranscriber:
|
|||||||
|
|
||||||
# Проверяем, что device_id является целым числом
|
# Проверяем, что device_id является целым числом
|
||||||
if not isinstance(device_id, int):
|
if not isinstance(device_id, int):
|
||||||
logger.warning("device_id должен быть целым числом, получено: %s. Используем значение по умолчанию 0", device_id)
|
logger.warning("device_id must be an integer, got: %s. Using default value 0", device_id)
|
||||||
device_id = 0
|
device_id = 0
|
||||||
|
|
||||||
# Проверяем, доступен ли запрошенный GPU
|
# Проверяем, доступен ли запрошенный GPU
|
||||||
device_count = torch.cuda.device_count()
|
device_count = torch.cuda.device_count()
|
||||||
if device_id >= device_count:
|
if device_id >= device_count:
|
||||||
logger.warning("Запрошенный GPU с индексом %s недоступен. Доступно GPU: %s. Используем GPU с индексом 0", device_id, device_count)
|
logger.warning("Requested GPU index %s is not available. Available GPU count: %s. Using GPU index 0", device_id, device_count)
|
||||||
device_id = 0
|
device_id = 0
|
||||||
|
|
||||||
logger.info("Используется CUDA GPU с индексом %s для вычислений", device_id)
|
logger.info("Using CUDA GPU with index %s for inference", device_id)
|
||||||
return torch.device(f"cuda:{device_id}")
|
return torch.device(f"cuda:{device_id}")
|
||||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
logger.info("Используется MPS (Apple Silicon) для вычислений")
|
logger.info("Using MPS (Apple Silicon) for inference")
|
||||||
# Обходное решение для MPS: PyTorch проверяет is_initialized()
|
# Обходное решение для MPS: PyTorch проверяет is_initialized()
|
||||||
# при создании тензоров на MPS-устройстве, что вызывает ошибку
|
# при создании тензоров на MPS-устройстве, что вызывает ошибку
|
||||||
# в однопроцессном режиме.
|
# в однопроцессном режиме.
|
||||||
@@ -114,7 +116,7 @@ class WhisperTranscriber:
|
|||||||
setattr(torch.distributed, "is_initialized", lambda: False)
|
setattr(torch.distributed, "is_initialized", lambda: False)
|
||||||
return torch.device("mps")
|
return torch.device("mps")
|
||||||
else:
|
else:
|
||||||
logger.info("Используется CPU для вычислений")
|
logger.info("Using CPU for inference")
|
||||||
return torch.device("cpu")
|
return torch.device("cpu")
|
||||||
|
|
||||||
def _get_torch_dtype(self) -> torch.dtype:
|
def _get_torch_dtype(self) -> torch.dtype:
|
||||||
@@ -135,7 +137,8 @@ class WhisperTranscriber:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: Если не удалось загрузить модель.
|
Exception: Если не удалось загрузить модель.
|
||||||
"""
|
"""
|
||||||
logger.info("Загрузка модели из %s", self.model_path)
|
logger.info("Loading model from %s", self.model_path)
|
||||||
|
self.model_path = self._prepare_model_path()
|
||||||
|
|
||||||
model_kwargs = dict(
|
model_kwargs = dict(
|
||||||
torch_dtype=self.torch_dtype,
|
torch_dtype=self.torch_dtype,
|
||||||
@@ -143,16 +146,26 @@ class WhisperTranscriber:
|
|||||||
use_safetensors=True,
|
use_safetensors=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
use_flash_attn = False
|
||||||
if self.device.type == "cuda":
|
if self.device.type == "cuda":
|
||||||
|
# Flash Attention 2 требует архитектуру Ampere или новее (compute capability >= 8.0)
|
||||||
|
capability = torch.cuda.get_device_capability(self.device.index)
|
||||||
|
if capability[0] >= 8:
|
||||||
|
use_flash_attn = True
|
||||||
|
logger.info("GPU supports Flash Attention 2 (compute capability: %d.%d)", *capability)
|
||||||
|
else:
|
||||||
|
logger.info("GPU does not support Flash Attention 2 (compute capability: %d.%d), using standard attention", *capability)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if use_flash_attn:
|
||||||
model_kwargs["attn_implementation"] = "flash_attention_2"
|
model_kwargs["attn_implementation"] = "flash_attention_2"
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||||
self.model_path, **model_kwargs
|
self.model_path, **model_kwargs
|
||||||
).to(self.device)
|
).to(self.device)
|
||||||
if self.device.type == "cuda":
|
if use_flash_attn:
|
||||||
logger.info("Используется Flash Attention 2")
|
logger.info("Using Flash Attention 2")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Не удалось загрузить модель с Flash Attention: %s", e)
|
logger.warning("Failed to load model with Flash Attention: %s", e)
|
||||||
model_kwargs.pop("attn_implementation", None)
|
model_kwargs.pop("attn_implementation", None)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(
|
self.model = WhisperForConditionalGeneration.from_pretrained(
|
||||||
self.model_path, **model_kwargs
|
self.model_path, **model_kwargs
|
||||||
@@ -172,7 +185,89 @@ class WhisperTranscriber:
|
|||||||
device=self.device,
|
device=self.device,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Модель успешно загружена и готова к использованию")
|
logger.info("Model loaded successfully and is ready to use")
|
||||||
|
|
||||||
|
def _prepare_model_path(self) -> str:
|
||||||
|
"""
|
||||||
|
Ensures that the configured model path is usable.
|
||||||
|
|
||||||
|
If ``model_path`` looks like a local path and the model is missing,
|
||||||
|
this method can optionally download a fallback Whisper model into that
|
||||||
|
directory. If ``model_path`` is a Hugging Face repo ID, it is returned
|
||||||
|
unchanged and transformers will resolve it normally.
|
||||||
|
"""
|
||||||
|
model_path = self.model_path
|
||||||
|
|
||||||
|
looks_like_local_path = (
|
||||||
|
os.path.isabs(model_path)
|
||||||
|
or model_path.startswith(".")
|
||||||
|
or os.path.sep in model_path
|
||||||
|
)
|
||||||
|
if not looks_like_local_path:
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
if self._is_valid_local_model_dir(model_path):
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
if self.config.get("auto_download_missing_model", False):
|
||||||
|
return self._download_missing_model(model_path)
|
||||||
|
|
||||||
|
if not os.path.isdir(model_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Local model directory not found: "
|
||||||
|
f"'{model_path}'. Mount the Whisper model into the container, "
|
||||||
|
"set 'model_path' to that folder, or enable "
|
||||||
|
"'auto_download_missing_model'."
|
||||||
|
)
|
||||||
|
|
||||||
|
config_file = os.path.join(model_path, "config.json")
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"The configured model directory exists but does not look like a "
|
||||||
|
f"Hugging Face model folder: '{model_path}'. Missing required file "
|
||||||
|
f"'{config_file}'. Make sure the directory contains files like "
|
||||||
|
"'config.json', tokenizer files, and model weights, or enable "
|
||||||
|
"'auto_download_missing_model'."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_valid_local_model_dir(self, model_path: str) -> bool:
|
||||||
|
"""Returns True when the directory looks like a local HF model folder."""
|
||||||
|
return os.path.isdir(model_path) and os.path.isfile(
|
||||||
|
os.path.join(model_path, "config.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _download_missing_model(self, model_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Downloads a fallback Whisper model into the configured local directory.
|
||||||
|
"""
|
||||||
|
model_repo_id = self.config.get("model_repo_id", "openai/whisper-large-v3")
|
||||||
|
model_revision = self.config.get("model_revision")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Local model was not found at %s. Downloading fallback model %s",
|
||||||
|
model_path,
|
||||||
|
model_repo_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(model_path, exist_ok=True)
|
||||||
|
snapshot_download(
|
||||||
|
repo_id=model_repo_id,
|
||||||
|
local_dir=model_path,
|
||||||
|
local_dir_use_symlinks=False,
|
||||||
|
revision=model_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_valid_local_model_dir(model_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Model download completed but the target directory still does not "
|
||||||
|
f"look valid: '{model_path}'. Expected to find '{os.path.join(model_path, 'config.json')}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Downloaded Whisper model %s into %s",
|
||||||
|
model_repo_id,
|
||||||
|
model_path,
|
||||||
|
)
|
||||||
|
return model_path
|
||||||
|
|
||||||
def transcribe(self, audio_path: str, return_timestamps: bool = None,
|
def transcribe(self, audio_path: str, return_timestamps: bool = None,
|
||||||
language: str = None, temperature: float = None,
|
language: str = None, temperature: float = None,
|
||||||
@@ -199,7 +294,7 @@ class WhisperTranscriber:
|
|||||||
if temperature is None:
|
if temperature is None:
|
||||||
temperature = self.temperature
|
temperature = self.temperature
|
||||||
|
|
||||||
logger.info("Начало транскрибации файла: %s", audio_path)
|
logger.info("Starting transcription for file: %s", audio_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Загрузка аудио в формате numpy array
|
# Загрузка аудио в формате numpy array
|
||||||
@@ -226,7 +321,7 @@ class WhisperTranscriber:
|
|||||||
# Если временные метки не запрошены, возвращаем только текст
|
# Если временные метки не запрошены, возвращаем только текст
|
||||||
if not return_timestamps:
|
if not return_timestamps:
|
||||||
transcribed_text = result.get("text", "")
|
transcribed_text = result.get("text", "")
|
||||||
logger.info("Транскрибация завершена: получено %s символов текста", len(transcribed_text))
|
logger.info("Transcription completed: produced %s text characters", len(transcribed_text))
|
||||||
return transcribed_text
|
return transcribed_text
|
||||||
|
|
||||||
# Если временные метки запрошены, обрабатываем и форматируем результат
|
# Если временные метки запрошены, обрабатываем и форматируем результат
|
||||||
@@ -258,9 +353,9 @@ class WhisperTranscriber:
|
|||||||
"text": text
|
"text": text
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
|
logger.warning("Timestamps were requested but not found in the transcription result")
|
||||||
|
|
||||||
logger.info("Транскрибация с временными метками завершена: получено %s сегментов", len(segments))
|
logger.info("Timestamped transcription completed: produced %s segments", len(segments))
|
||||||
|
|
||||||
# Возвращаем словарь с сегментами и полным текстом
|
# Возвращаем словарь с сегментами и полным текстом
|
||||||
return {
|
return {
|
||||||
@@ -269,8 +364,8 @@ class WhisperTranscriber:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка в процессе транскрибации аудиофайла '%s': %s", audio_path, e)
|
logger.error("Error while transcribing audio file '%s': %s", audio_path, e)
|
||||||
logger.error("Тип исключения: %s", type(e).__name__)
|
logger.error("Exception type: %s", type(e).__name__)
|
||||||
logger.error("Traceback: %s", traceback.format_exc())
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -293,7 +388,7 @@ class WhisperTranscriber:
|
|||||||
- Если return_timestamps=True: словарь с ключами "segments" и "text"
|
- Если return_timestamps=True: словарь с ключами "segments" и "text"
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Начало обработки файла: %s", input_path)
|
logger.info("Starting file processing: %s", input_path)
|
||||||
|
|
||||||
temp_files = []
|
temp_files = []
|
||||||
|
|
||||||
@@ -307,14 +402,14 @@ class WhisperTranscriber:
|
|||||||
prompt=prompt)
|
prompt=prompt)
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
logger.info("Обработка и транскрибация завершены за %.2f секунд", elapsed_time)
|
logger.info("Processing and transcription finished in %.2f seconds", elapsed_time)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
logger.error("Ошибка при обработке файла '%s' через %.2f секунд: %s", input_path, elapsed_time, e)
|
logger.error("Error while processing file '%s' after %.2f seconds: %s", input_path, elapsed_time, e)
|
||||||
logger.error("Тип исключения: %s", type(e).__name__)
|
logger.error("Exception type: %s", type(e).__name__)
|
||||||
logger.error("Traceback: %s", traceback.format_exc())
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TranscriptionService:
|
|||||||
Кортеж (JSON-ответ, HTTP-код).
|
Кортеж (JSON-ответ, HTTP-код).
|
||||||
"""
|
"""
|
||||||
params = params or {}
|
params = params or {}
|
||||||
language = params.get('language', self.config.get('language', 'en'))
|
language = params.get('language') or self.config.get('language', 'en')
|
||||||
temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0))))
|
temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0))))
|
||||||
prompt = params.get('prompt', '')
|
prompt = params.get('prompt', '')
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ class TranscriptionService:
|
|||||||
try:
|
try:
|
||||||
duration = get_audio_duration(file_path)
|
duration = get_audio_duration(file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при определении длительности файла: %s", e)
|
logger.error("Failed to determine file duration: %s", e)
|
||||||
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
return {"error": f"Failed to determine audio duration: {e}"}, 500
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
result = self.transcriber.process_file(
|
result = self.transcriber.process_file(
|
||||||
@@ -86,6 +86,6 @@ class TranscriptionService:
|
|||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при транскрибации файла '%s': %s", filename, e)
|
logger.error("Failed to transcribe file '%s': %s", filename, e)
|
||||||
logger.error("Traceback: %s", traceback.format_exc())
|
logger.error("Traceback: %s", traceback.format_exc())
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|||||||
+4
-4
@@ -49,12 +49,12 @@ def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -
|
|||||||
with open(history_path, 'w', encoding='utf-8') as f:
|
with open(history_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
logger.info("Результат сохранён в историю: %s", history_path)
|
logger.info("Saved transcription result to history: %s", history_path)
|
||||||
_cleanup_old_history(config)
|
_cleanup_old_history(config)
|
||||||
return history_path
|
return history_path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при сохранении истории: %s", e)
|
logger.error("Failed to save history: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +80,6 @@ def _cleanup_old_history(config: Dict) -> None:
|
|||||||
# Директории имеют формат YYYY-MM-DD
|
# Директории имеют формат YYYY-MM-DD
|
||||||
if len(entry) == 10 and entry < cutoff_str:
|
if len(entry) == 10 and entry < cutoff_str:
|
||||||
shutil.rmtree(entry_path, ignore_errors=True)
|
shutil.rmtree(entry_path, ignore_errors=True)
|
||||||
logger.info("Удалена старая директория истории: %s", entry)
|
logger.info("Removed old history directory: %s", entry)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Ошибка при очистке старой истории: %s", e)
|
logger.warning("Failed to clean up old history: %s", e)
|
||||||
|
|||||||
@@ -93,14 +93,14 @@ class AsyncTaskManager:
|
|||||||
self.tasks[task_id]["result"] = result
|
self.tasks[task_id]["result"] = result
|
||||||
self.tasks[task_id]["completed_at"] = time.time()
|
self.tasks[task_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
logger.info("Задача %s завершена успешно", task_id)
|
logger.info("Task %s completed successfully", task_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.tasks[task_id]["status"] = "failed"
|
self.tasks[task_id]["status"] = "failed"
|
||||||
self.tasks[task_id]["error"] = str(e)
|
self.tasks[task_id]["error"] = str(e)
|
||||||
self.tasks[task_id]["completed_at"] = time.time()
|
self.tasks[task_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
logger.error("Задача %s завершилась с ошибкой: %s", task_id, e)
|
logger.error("Task %s failed: %s", task_id, e)
|
||||||
|
|
||||||
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class RequestLogger:
|
|||||||
return
|
return
|
||||||
g.start_time = time.time()
|
g.start_time = time.time()
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"%s %s от %s",
|
"%s %s from %s",
|
||||||
request.method, request.path, self._get_client_ip(),
|
request.method, request.path, self._get_client_ip(),
|
||||||
extra={"type": "request"}
|
extra={"type": "request"}
|
||||||
)
|
)
|
||||||
@@ -101,7 +101,7 @@ class RequestLogger:
|
|||||||
return response
|
return response
|
||||||
processing_time = time.time() - getattr(g, 'start_time', time.time())
|
processing_time = time.time() - getattr(g, 'start_time', time.time())
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"%s за %.3f сек",
|
"%s in %.3f sec",
|
||||||
response.status_code, processing_time,
|
response.status_code, processing_time,
|
||||||
extra={"type": "response"}
|
extra={"type": "response"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def create_temp_file(suffix: str = ".wav") -> str:
|
|||||||
"""
|
"""
|
||||||
fd, temp_path = tempfile.mkstemp(suffix=suffix)
|
fd, temp_path = tempfile.mkstemp(suffix=suffix)
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
logger.debug("Создан временный файл: %s", temp_path)
|
logger.debug("Created temporary file: %s", temp_path)
|
||||||
return temp_path
|
return temp_path
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,6 @@ def cleanup_temp_files(file_paths: list) -> None:
|
|||||||
try:
|
try:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
logger.debug("Удалён временный файл: %s", path)
|
logger.debug("Removed temporary file: %s", path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Не удалось очистить временный файл %s: %s", path, e)
|
logger.warning("Failed to clean up temporary file %s: %s", path, e)
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ class FileValidator:
|
|||||||
if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
|
if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
|
||||||
# Логирование попытки загрузки файла с неразрешенным расширением
|
# Логирование попытки загрузки файла с неразрешенным расширением
|
||||||
file_extension = os.path.splitext(filename)[1]
|
file_extension = os.path.splitext(filename)[1]
|
||||||
logger.warning("Попытка загрузки файла с неразрешенным расширением '%s'. "
|
logger.warning("Attempt to upload a file with disallowed extension '%s'. "
|
||||||
"Имя файла: %s. Разрешенные расширения: %s", file_extension, filename, ", ".join(self.allowed_extensions))
|
"Filename: %s. Allowed extensions: %s", file_extension, filename, ", ".join(self.allowed_extensions))
|
||||||
|
|
||||||
raise ValidationError(f"Расширение файла не разрешено. "
|
raise ValidationError(f"File extension is not allowed. "
|
||||||
f"Разрешенные расширения: {', '.join(self.allowed_extensions)}")
|
f"Allowed extensions: {', '.join(self.allowed_extensions)}")
|
||||||
|
|
||||||
def validate_file_by_path(self, file_path: str, filename: str) -> bool:
|
def validate_file_by_path(self, file_path: str, filename: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -78,18 +78,18 @@ class FileValidator:
|
|||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
max_size_bytes = self.max_file_size_mb * 1024 * 1024
|
max_size_bytes = self.max_file_size_mb * 1024 * 1024
|
||||||
if file_size > max_size_bytes:
|
if file_size > max_size_bytes:
|
||||||
raise ValidationError(f"Размер файла ({file_size / (1024*1024):.2f} МБ) "
|
raise ValidationError(f"File size ({file_size / (1024*1024):.2f} MB) "
|
||||||
f"превышает максимально допустимый ({self.max_file_size_mb} МБ)")
|
f"exceeds the maximum allowed size ({self.max_file_size_mb} MB)")
|
||||||
|
|
||||||
# Проверка MIME-типа
|
# Проверка MIME-типа
|
||||||
try:
|
try:
|
||||||
mime_type = magic.from_file(file_path, mime=True)
|
mime_type = magic.from_file(file_path, mime=True)
|
||||||
if mime_type not in self.allowed_mime_types:
|
if mime_type not in self.allowed_mime_types:
|
||||||
raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. "
|
raise ValidationError(f"File MIME type ({mime_type}) is not allowed. "
|
||||||
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
|
f"Allowed MIME types: {', '.join(self.allowed_mime_types)}")
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Не удалось определить MIME-тип файла: %s", e)
|
logger.warning("Failed to determine file MIME type: %s", e)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
+3
-3
@@ -94,7 +94,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
logger.warning("File validation failed for '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
@@ -122,7 +122,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
logger.warning("File validation failed for '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
@@ -150,7 +150,7 @@ class Routes:
|
|||||||
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
|
||||||
return jsonify(response), status_code
|
return jsonify(response), status_code
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.warning("Ошибка валидации файла '%s': %s", filename, e)
|
logger.warning("File validation failed for '%s': %s", filename, e)
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
finally:
|
finally:
|
||||||
cleanup_temp_files([temp_path])
|
cleanup_temp_files([temp_path])
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"service_port": 5042,
|
||||||
|
"model_path": "/models/whisper",
|
||||||
|
"auto_download_missing_model": true,
|
||||||
|
"model_repo_id": "openai/whisper-large-v3",
|
||||||
|
"language": "russian",
|
||||||
|
"enable_history": true,
|
||||||
|
"max_history_days": 30,
|
||||||
|
"chunk_length_s": 28,
|
||||||
|
"batch_size": 6,
|
||||||
|
"max_new_tokens": 384,
|
||||||
|
"temperature": 0.01,
|
||||||
|
"return_timestamps": false,
|
||||||
|
"audio_rate": 16000,
|
||||||
|
"norm_level": "-0.55",
|
||||||
|
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
|
||||||
|
"device_id": 0,
|
||||||
|
"file_validation": {
|
||||||
|
"max_file_size_mb": 500,
|
||||||
|
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
|
||||||
|
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
|
||||||
|
},
|
||||||
|
"log_level": "INFO",
|
||||||
|
"log_file": "logs/whisper_api.log",
|
||||||
|
"request_logging": {
|
||||||
|
"exclude_endpoints": ["/health", "/static"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"service_port": 5042,
|
"service_port": 5042,
|
||||||
"model_path": "/home/text-generation/models/whisper/podlodka-turbo",
|
"model_path": "/home/text-generation/models/whisper/podlodka-turbo",
|
||||||
"language": "ru",
|
"auto_download_missing_model": false,
|
||||||
|
"model_repo_id": "openai/whisper-large-v3",
|
||||||
|
"language": "russian",
|
||||||
"enable_history": true,
|
"enable_history": true,
|
||||||
"max_history_days": 30,
|
"max_history_days": 30,
|
||||||
"chunk_length_s": 28,
|
"chunk_length_s": 28,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
whisper-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: whisper-api-server
|
||||||
|
ports:
|
||||||
|
- "5042:5042"
|
||||||
|
environment:
|
||||||
|
HF_TOKEN: ${HF_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- ./config.docker.json:/app/config.docker.json:ro
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./history:/app/history
|
||||||
|
- ./models:/models
|
||||||
|
restart: unless-stopped
|
||||||
Reference in New Issue
Block a user