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
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,36 @@ curl --request POST \
# {"text":" Hot hole high. Hu. Hu. Hu. Behind. Hu. Hu."}
```

## Development setup
### Language

An optional `language` form field accepts an ISO 639-1 language code (for
example `pl`) and forces faster-whisper to transcribe with that language
instead of running automatic language detection.

- Omitting the field keeps automatic detection.
- An invalid code is rejected deterministically with `400 Bad Request`.

```bash
curl --request POST \
--url "http://localhost:8000/v1/audio/transcriptions" \
--header 'Content-Type: multipart/form-data' \
--form file=@polski.wav \
--form model=whisper-1 \
--form language=pl
```

We use [Poetry](https://python-poetry.org/) to manage dependencies, [Ruff](https://docs.astral.sh/ruff/) for linting and [Black](https://black.readthedocs.io/en/stable/) for formatting.
## Development setup

The `poetry.lock` file is committed to the repository.
When adding a new dependency, use `poetry add <package>` and commit the updated `poetry.lock` file.
If the dependency is only needed for development, add the `--dev` flag.
We use [uv](https://docs.astral.sh/uv/) to manage dependencies,
[Ruff](https://docs.astral.sh/ruff/) for linting and formatting, and
[pytest](https://docs.pytest.org/) for tests.

```bash
# Automatically update the dependencies to the latest compatible version
poetry update
# Install dependencies (including development dependencies)
uv sync --dev

# Use the export commands to update the frozen requirements files
poetry export -f requirements.txt --output requirements.txt
poetry export --only dev -f requirements.txt --output dev-requirements.txt
# Run tests
uv run pytest

# Setup pre-commit hooks (See https://pre-commit.com/)
pre-commit install
Expand Down
10 changes: 10 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import uvicorn
from faster_whisper import WhisperModel
from faster_whisper.tokenizer import _LANGUAGE_CODES
from faster_whisper.transcribe import Segment, TranscriptionInfo
from fastapi import FastAPI, Form, UploadFile, File
from fastapi import HTTPException, status
Expand Down Expand Up @@ -63,6 +64,7 @@ async def transcriptions(
file: UploadFile = File(...),
response_format: Optional[str] = Form(None),
temperature: Optional[float] = Form(None),
language: Optional[str] = Form(None),
settings_override: Optional[dict] = Form(None),
):
assert model == "whisper-1"
Expand All @@ -88,6 +90,12 @@ async def transcriptions(
detail="Bad Request, bad temperature",
)

if language is not None and language not in _LANGUAGE_CODES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bad Request, bad language",
)

filename = file.filename
fileobj = file.file
upload_name = os.path.join(UPLOAD_DIR, filename)
Expand All @@ -102,6 +110,8 @@ async def transcriptions(
}
if settings_override is not None:
whisper_args.update(settings_override)
if language is not None:
whisper_args["language"] = language

segments, _ = transcribe(audio_path=upload_name, **whisper_args)

Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ dependencies = [

[dependency-groups]
dev = [
"ruff>=0.11.0",
"httpx>=0.27.0",
"pre-commit>=3.6.0",
"pytest>=8.0.0",
"ruff>=0.11.0",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
pythonpath = ["."]

[tool.ruff]
line-length = 88

Expand Down
195 changes: 195 additions & 0 deletions tests/test_language_forwarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Regression tests for the optional `language` form field.

Proves:
- omitted language keeps the existing auto-detect behavior,
- language=pl reaches the faster-whisper transcribe call,
- an invalid language is rejected deterministically (400),
- existing validation/response behavior is unchanged.
"""

import asyncio
import io

from fastapi import UploadFile
from fastapi.testclient import TestClient

import main


class FakeSegment:
def __init__(self):
self.id = 0
self.start = 0.0
self.end = 0.5
self.text = " test"


class FakeInfo:
language = None
language_probability = None
duration = 0.5


def make_client(monkeypatch, tmp_path, calls):
"""TestClient with main.transcribe replaced by a recording stub."""

def fake_transcribe(**kwargs):
calls.append(kwargs)
return [FakeSegment()], FakeInfo()

monkeypatch.setattr(main, "transcribe", fake_transcribe)
monkeypatch.chdir(tmp_path)
return TestClient(main.app)


def post_transcription(client, data=None):
payload = {"model": "whisper-1"}
if data:
payload.update(data)
return client.post(
"/v1/audio/transcriptions",
files={"file": ("audio.wav", b"fake-wav-bytes", "audio/wav")},
data=payload,
)


def test_no_language_keeps_auto_detect(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client)

assert response.status_code == 200
assert response.json() == {"text": " test"}
assert len(calls) == 1
assert "language" not in calls[0]


def test_language_pl_reaches_transcribe_call(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"language": "pl"})

assert response.status_code == 200
assert len(calls) == 1
assert calls[0]["language"] == "pl"


def test_invalid_language_returns_400_and_skips_transcribe(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"language": "xx-totally-invalid"})

assert response.status_code == 400
assert "language" in response.json()["detail"]
assert calls == []


def test_empty_language_string_behaves_as_omitted(monkeypatch, tmp_path):
"""The multipart stack delivers an empty form value as None, so
`language=` is equivalent to omitted -> auto-detect stays intact."""
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"language": ""})

assert response.status_code == 200
assert len(calls) == 1
assert "language" not in calls[0]


def test_uppercase_language_is_rejected_like_faster_whisper_would(
monkeypatch, tmp_path
):
"""faster-whisper Tokenizer validates case-sensitively; mirror that."""
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"language": "PL"})

assert response.status_code == 400
assert calls == []


def test_openapi_schema_declares_optional_language():
schema = main.app.openapi()
body_schema_name = next(
name
for name in schema["components"]["schemas"]
if name.startswith("Body_transcriptions")
)
properties = schema["components"]["schemas"][body_schema_name]["properties"]
assert "language" in properties
assert properties["language"]["title"] == "Language"
assert {"type": "string"} in properties["language"]["anyOf"]
assert {"type": "null"} in properties["language"]["anyOf"]


def test_default_json_response_format_unchanged(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client)

assert response.status_code == 200
assert set(response.json().keys()) == {"text"}


def test_explicit_text_response_format_unchanged(monkeypatch, tmp_path):
"""Existing upstream quirk preserved: response_format=text still returns
the JSON dict shape (documented here as regression protection)."""
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"response_format": "text"})

assert response.status_code == 200
assert response.json() == {"text": " test"}


def test_bad_response_format_still_400(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"response_format": "bogus"})

assert response.status_code == 400
assert response.json()["detail"] == "Bad Request, bad response_format"
assert calls == []


def test_language_field_takes_precedence_over_settings_override(monkeypatch, tmp_path):
"""The dedicated API field must win over the generic settings_override
dict, even when both carry a 'language' key. Called directly because
settings_override cannot be populated over multipart HTTP anyway."""
calls = []
make_client(monkeypatch, tmp_path, calls)

upload = UploadFile(file=io.BytesIO(b"fake-wav-bytes"), filename="audio.wav")

async def call_route():
return await main.transcriptions(
model="whisper-1",
file=upload,
response_format=None,
temperature=None,
language="pl",
settings_override={"language": "en"},
)

asyncio.run(call_route())

assert calls[0]["language"] == "pl"


def test_bad_temperature_still_400(monkeypatch, tmp_path):
calls = []
client = make_client(monkeypatch, tmp_path, calls)

response = post_transcription(client, {"temperature": 2.5})

assert response.status_code == 400
assert response.json()["detail"] == "Bad Request, bad temperature"
assert calls == []
Loading