Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Video Transcription Tool

This tool automates the process of transcribing video files using multiple transcription services: Azure OpenAI, Groq, and OpenAI APIs. It converts video files to MP3 format, transcribes the audio, and saves the transcription as a text file.
This tool automates the process of transcribing video files using multiple transcription services: Azure OpenAI, Groq, OpenAI, and Rev AI APIs. It converts video files to MP3 format, transcribes the audio, and saves the transcription as a text file.

## Features

- Converts video files to MP3 format using ffmpeg
- Supports transcription using Azure OpenAI, Groq, and OpenAI APIs
- Supports transcription using Azure OpenAI, Groq, OpenAI, and Rev AI APIs
- Supports processing of individual video files or entire directories
- Cleans up temporary MP3 files after transcription
- Provides flexibility in selecting the transcription service via configuration
Expand All @@ -18,6 +18,7 @@ This tool automates the process of transcribing video files using multiple trans
- Azure OpenAI API
- Groq Cloud API
- OpenAI API
- Rev AI API

## Installation

Expand Down Expand Up @@ -53,6 +54,13 @@ This tool automates the process of transcribing video files using multiple trans
OPENAI_MODEL=whisper-1
OPENAI_API_ENDPOINT=https://api.openai.com/v1/audio/transcriptions
OPENAI_MODEL_NAME_CHAT=gpt-4o

# Rev AI
REVAI_ACCESS_TOKEN=your_rev_ai_access_token_here
# Optional overrides:
# REVAI_API_BASE_URL=https://api.rev.ai/speechtotext/v1
# REVAI_JOB_POLL_INTERVAL_SECONDS=5
# REVAI_JOB_TIMEOUT_SECONDS=3600
```

## Building and Installing the Package
Expand Down Expand Up @@ -102,24 +110,26 @@ This tool automates the process of transcribing video files using multiple trans
Run the script with a video file or directory as an argument:

```
sapat <video_file_or_directory> [--language <language>] [--prompt <prompt>] [--temperature <temperature>]
sapat <video_file_or_directory> [--language <language>] [--transcription-prompt <prompt>] [--temperature <temperature>]
```

### Options

- `--language`: Specify the language of the audio (default: "en").
- `--prompt`: Optional prompt to guide the model's transcription.
- `--transcription-prompt`: Optional prompt to guide the model's transcription.
- `--temperature`: The sampling temperature, between 0 and 1 (default: 0).
- `--quality`: Quality of the MP3 audio: 'L' for low, 'M' for medium, and 'H' for high (default: 'M').
- `--api`: Specify the API to use for transcription.
- `--api azure` for Azure OpenAI API
- `--api groq` for Groq Cloud API
- `--api openai` for OpenAI API
- `--model`: Provider-specific model or mode. For Rev AI, use `fusion`, `low_cost`, `machine`, or `human` to set Rev AI's transcriber option; omit it for the default machine transcription path.
- `--provider`: Specify the provider to use for transcription.
- `--provider azure` for Azure OpenAI API
- `--provider groq` for Groq Cloud API
- `--provider openai` for OpenAI API
- `--provider revai` for Rev AI API

Example:

```
sapat my_video.mp4 --quality H --language es --prompt "This is a test prompt" --temperature 0.5 --api groq
sapat my_video.mp4 --quality H --language es --transcription-prompt "This is a test prompt" --temperature 0.5 --provider revai
```

- If a file is provided, it will process that single file.
Expand All @@ -129,7 +139,7 @@ The script will create a `.txt` file with the same name as the input video file,

## Note

This tool is designed for use with multiple APIs (Azure OpenAI, Groq, and OpenAI). Ensure you have valid API credentials configured in the `.env` file and the necessary permissions and credits for the API service you plan to use.
This tool is designed for use with multiple APIs (Azure OpenAI, Groq, OpenAI, and Rev AI). Ensure you have valid API credentials configured in the `.env` file and the necessary permissions and credits for the API service you plan to use.

## License

Expand Down
103 changes: 103 additions & 0 deletions sapat/providers/revai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# ABOUTME: Rev AI asynchronous transcription provider
# ABOUTME: Submits local audio, polls job status, and fetches plain-text transcripts

import json
import mimetypes
import os

import requests

from sapat.providers import register
from sapat.providers.async_poll import AsyncPollProvider
from sapat.providers.base import (
AudioFormat,
ProviderConfig,
TranscriptionResult,
)


@register
class RevAIProvider(AsyncPollProvider):
"""Rev AI asynchronous Speech-to-Text API provider."""

name = "revai"
config = ProviderConfig(
required_env_vars=["REVAI_ACCESS_TOKEN"],
max_file_size_mb=2048.0,
preferred_format=AudioFormat.MP3,
supports_correction=False,
default_model="async",
)

def __init__(self):
super().__init__()
self.access_token = os.getenv("REVAI_ACCESS_TOKEN", "")
self.base_url = os.getenv(
"REVAI_API_BASE_URL",
"https://api.rev.ai/speechtotext/v1",
).rstrip("/")
self.poll_interval = float(os.getenv("REVAI_JOB_POLL_INTERVAL_SECONDS", "5"))
self.max_poll_time = float(os.getenv("REVAI_JOB_TIMEOUT_SECONDS", "3600"))

def _headers(self, accept: str = "application/json") -> dict:
return {
"Authorization": f"Bearer {self.access_token}",
"Accept": accept,
}

def _job_options(self, model: str, language: str) -> dict:
options = {}
if language and language.lower() != "auto":
options["language"] = language
if model and model != self.config.default_model:
options["transcriber"] = model
return options

def _upload(self, audio_file: str, model: str, language: str, **kwargs) -> str:
options = self._job_options(model, language)
data = {"options": json.dumps(options)} if options else {}
media_type = mimetypes.guess_type(audio_file)[0] or "application/octet-stream"

with open(audio_file, "rb") as f:
response = requests.post(
f"{self.base_url}/jobs",
headers=self._headers(),
data=data,
files={"media": (os.path.basename(audio_file), f, media_type)},
timeout=120,
)

if response.status_code not in (200, 201):
raise RuntimeError(f"Rev AI job creation failed: {response.text}")

job_id = response.json().get("id")
if not job_id:
raise RuntimeError("Rev AI job creation response did not include an id.")
return job_id

def _poll(self, job_id: str) -> str:
response = requests.get(
f"{self.base_url}/jobs/{job_id}",
headers=self._headers(),
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f"Rev AI status check failed: {response.text}")

status = response.json().get("status")
if status == "transcribed":
return "completed"
if status == "failed":
return "failed"
return "pending"

def _fetch_result(self, job_id: str) -> TranscriptionResult:
response = requests.get(
f"{self.base_url}/jobs/{job_id}/transcript",
headers=self._headers("text/plain"),
timeout=60,
)
if response.status_code != 200:
raise RuntimeError(f"Rev AI transcript retrieval failed: {response.text}")

return TranscriptionResult(text=response.text, raw_response=response.text)
159 changes: 159 additions & 0 deletions tests/providers/test_revai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# ABOUTME: Tests for the Rev AI asynchronous transcription provider
# ABOUTME: Verifies upload, polling, transcript fetch, and availability behavior

import json
import os
from unittest.mock import patch

import pytest

from sapat.providers.base import TranscriptionResult


class FakeResponse:
"""Minimal requests.Response stand-in for mocked HTTP calls."""

def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self.payload = payload or {}
self.text = text

def json(self):
return self.payload


@pytest.fixture
def audio_file(tmp_path):
path = tmp_path / "sample.mp3"
path.write_bytes(b"fake audio bytes")
return str(path)


class TestRevAIProvider:
@patch.dict(
os.environ,
{
"REVAI_ACCESS_TOKEN": "test-token",
"REVAI_API_BASE_URL": "https://revai.test/speechtotext/v1",
},
clear=False,
)
@patch("sapat.providers.revai.requests.get")
@patch("sapat.providers.revai.requests.post")
def test_transcribe_submits_polls_and_fetches_text(
self, mock_post, mock_get, audio_file
):
mock_post.return_value = FakeResponse(
status_code=201,
payload={"id": "job-123", "status": "in_progress"},
)
mock_get.side_effect = [
FakeResponse(status_code=200, payload={"status": "in_progress"}),
FakeResponse(status_code=200, payload={"status": "transcribed"}),
FakeResponse(status_code=200, text="Transcript from Rev AI."),
]

from sapat.providers.revai import RevAIProvider

provider = RevAIProvider()
provider.poll_interval = 0.01
result = provider.transcribe(audio_file, model="async", language="en")

assert isinstance(result, TranscriptionResult)
assert result.text == "Transcript from Rev AI."

mock_post.assert_called_once()
post_url = mock_post.call_args.args[0]
post_kwargs = mock_post.call_args.kwargs
assert post_url == "https://revai.test/speechtotext/v1/jobs"
assert post_kwargs["headers"]["Authorization"] == "Bearer test-token"
assert json.loads(post_kwargs["data"]["options"]) == {"language": "en"}
assert "media" in post_kwargs["files"]
assert post_kwargs["files"]["media"][2] == "audio/mpeg"

assert mock_get.call_args_list[0].args[0].endswith("/jobs/job-123")
assert (
mock_get.call_args_list[2].args[0]
== "https://revai.test/speechtotext/v1/jobs/job-123/transcript"
)
assert mock_get.call_args_list[2].kwargs["headers"]["Accept"] == "text/plain"

@patch.dict(os.environ, {"REVAI_ACCESS_TOKEN": "test-token"}, clear=False)
def test_default_model(self):
from sapat.providers.revai import RevAIProvider

assert RevAIProvider.config.default_model == "async"

@patch.dict(os.environ, {"REVAI_ACCESS_TOKEN": "test-token"}, clear=False)
def test_job_options_omit_auto_language_and_default_model(self):
from sapat.providers.revai import RevAIProvider

provider = RevAIProvider()

assert provider._job_options("async", "auto") == {}

@patch.dict(os.environ, {"REVAI_ACCESS_TOKEN": "test-token"}, clear=False)
def test_job_options_map_model_to_transcriber(self):
from sapat.providers.revai import RevAIProvider

provider = RevAIProvider()

assert provider._job_options("fusion", "en-gb") == {
"language": "en-gb",
"transcriber": "fusion",
}

@patch.dict(os.environ, {"REVAI_ACCESS_TOKEN": "test-token"}, clear=False)
def test_available_with_access_token(self):
from sapat.providers.revai import RevAIProvider

assert RevAIProvider.is_available() is True

@patch.dict(os.environ, {}, clear=True)
def test_not_available_without_access_token(self):
from sapat.providers.revai import RevAIProvider

assert RevAIProvider.is_available() is False

@patch.dict(
os.environ,
{
"REVAI_ACCESS_TOKEN": "test-token",
"REVAI_API_BASE_URL": "https://revai.test/speechtotext/v1",
},
clear=False,
)
@patch("sapat.providers.revai.requests.post")
def test_upload_requires_job_id(self, mock_post, audio_file):
mock_post.return_value = FakeResponse(status_code=201, payload={})

from sapat.providers.revai import RevAIProvider

provider = RevAIProvider()
with pytest.raises(RuntimeError, match="did not include an id"):
provider.transcribe(audio_file, model="async")

@patch.dict(
os.environ,
{
"REVAI_ACCESS_TOKEN": "test-token",
"REVAI_API_BASE_URL": "https://revai.test/speechtotext/v1",
},
clear=False,
)
@patch("sapat.providers.revai.requests.get")
@patch("sapat.providers.revai.requests.post")
def test_failed_job_raises(self, mock_post, mock_get, audio_file):
mock_post.return_value = FakeResponse(
status_code=201, payload={"id": "job-123"}
)
mock_get.return_value = FakeResponse(
status_code=200, payload={"status": "failed"}
)

from sapat.providers.revai import RevAIProvider

provider = RevAIProvider()
provider.poll_interval = 0.01
with pytest.raises(RuntimeError, match="failed"):
provider.transcribe(audio_file, model="async")
Loading