Skip to content
Merged
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
87 changes: 51 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,39 @@
[![Downloads](https://static.pepy.tech/badge/openlrc)](https://pepy.tech/project/openlrc)
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/zh-plus/Open-Lyrics/ci.yml)

Open-Lyrics is a Python library that transcribes voice files using
[faster-whisper](https://github.com/guillaumekln/faster-whisper), and translates/polishes the resulting text
into `.lrc` files in the desired language using LLM,
e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](https://github.com/anthropics/anthropic-sdk-python).
Open-Lyrics is a Python library that transcribes audio with
[faster-whisper](https://github.com/guillaumekln/faster-whisper), then translates/polishes the text
into `.lrc` subtitles with LLMs such as
[OpenAI](https://github.com/openai/openai-python) and [Anthropic](https://github.com/anthropics/anthropic-sdk-python).

#### Key Features:
#### Key Features

- Well preprocessed audio to reduce hallucination (Loudness Norm & optional Noise Suppression).
- Audio preprocessing to reduce hallucinations (loudness normalization and optional noise suppression).
- Context-aware translation to improve translation quality.
Check [prompt](https://github.com/zh-plus/openlrc/blob/master/openlrc/prompter.py) for details.
- Check [here](#how-it-works) for an overview of the architecture.

## New 🚨

- 2024.5.7:
- Add custom endpoint (base_url) support for OpenAI & Anthropic:
- Added custom endpoint (`base_url`) support for OpenAI and Anthropic:
```python
lrcer = LRCer(base_url_config={'openai': 'https://api.chatanywhere.tech',
'anthropic': 'https://example/api'})
```
- Generating bilingual subtitles
- Added bilingual subtitle generation:
```python
lrcer.run('./data/test.mp3', target_lang='zh-cn', bilingual_sub=True)
```
- 2024.5.11: Add glossary into prompt, which is confirmed to improve domain specific translation.
- 2024.5.11: Added glossary support in prompts to improve domain-specific translation.
Check [here](#glossary) for details.
- 2024.5.17: You can route model to arbitrary Chatbot SDK (either OpenAI or Anthropic) by setting `chatbot_model` to
`provider: model_name` together with base_url_config:
- 2024.5.17: You can route models to arbitrary chatbot SDKs (OpenAI or Anthropic) by setting `chatbot_model` to
`provider: model_name` together with `base_url_config`:
```python
lrcer = LRCer(chatbot_model='openai: claude-3-haiku-20240307',
base_url_config={'openai': 'https://api.g4f.icu/v1/'})
```
- 2024.6.25: Support Gemini as translation engine LLM, try using `gemini-1.5-flash`:
- 2024.6.25: Added Gemini as a translation model (for example, `gemini-1.5-flash`):
```python
lrcer = LRCer(chatbot_model='gemini-1.5-flash')
```
Expand All @@ -47,8 +47,8 @@ e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](h
```shell
pip install "faster-whisper @ https://github.com/SYSTRAN/faster-whisper/archive/8327d8cc647266ed66f6cd878cf97eccface7351.tar.gz"
```
- 2024.12.19: Add `ModelConfig` for chat model routing, which is more flexible than model name string, The ModelConfig
can be ModelConfig(provider='<provider>', model_name='<model-name>', base_url='<url>', proxy='<proxy>'), e.g.:
- 2024.12.19: Added `ModelConfig` for model routing. It is more flexible than plain model-name strings.
`ModelConfig` can be `ModelConfig(provider='<provider>', name='<model-name>', base_url='<url>', proxy='<proxy>')`, e.g.:
```python

from openlrc import LRCer, ModelConfig, ModelProvider
Expand All @@ -69,14 +69,14 @@ e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](h

## Installation ⚙️

1. Please install CUDA 11.x and [cuDNN 8 for CUDA 11](https://developer.nvidia.com/cudnn) first according
1. Install CUDA 11.x and [cuDNN 8 for CUDA 11](https://developer.nvidia.com/cudnn) first according
to https://opennmt.net/CTranslate2/installation.html to enable `faster-whisper`.

`faster-whisper` also needs [cuBLAS for CUDA 11](https://developer.nvidia.com/cublas) installed.
<details>
<summary>For Windows Users (click to expand)</summary>

(For Windows Users only) Windows user can Download the libraries from Purfview's repository:
(Windows only) You can download the libraries from Purfview's repository:

Purfview's [whisper-standalone-win](https://github.com/Purfview/whisper-standalone-win) provides the required NVIDIA
libraries for Windows in a [single archive](https://github.com/Purfview/whisper-standalone-win/releases/tag/libs).
Expand All @@ -85,11 +85,12 @@ e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](h
</details>


2. Add LLM API keys, you can either:
- Add your [OpenAI API key](https://platform.openai.com/account/api-keys) to environment variable `OPENAI_API_KEY`.
2. Add LLM API keys (recommended for most users: `OPENROUTER_API_KEY`):
- Add your [OpenAI API key](https://platform.openai.com/account/api-keys) to environment variable `OPENAI_API_KEY`.
- Add your [Anthropic API key](https://console.anthropic.com/settings/keys) to environment variable
`ANTHROPIC_API_KEY`.
- Add your [Google API Key](https://aistudio.google.com/app/apikey) to environment variable `GOOGLE_API_KEY`.
- Add your [OpenRouter API key](https://openrouter.ai/keys) to environment variable `OPENROUTER_API_KEY`.

3. Install [ffmpeg](https://ffmpeg.org/download.html) and add `bin` directory
to your `PATH`.
Expand All @@ -106,7 +107,7 @@ e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](h
pip install git+https://github.com/zh-plus/openlrc
```

5. Install latest [fast-whisper](https://github.com/guillaumekln/faster-whisper) from source:
5. Install the latest [faster-whisper](https://github.com/guillaumekln/faster-whisper) from source:
```shell
pip install "faster-whisper @ https://github.com/SYSTRAN/faster-whisper/archive/8327d8cc647266ed66f6cd878cf97eccface7351.tar.gz"
```
Expand Down Expand Up @@ -146,7 +147,8 @@ e.g. [OpenAI-GPT](https://github.com/openai/openai-python), [Anthropic-Claude](h
### Python code

```python
from openlrc import LRCer
import os
from openlrc import LRCer, ModelConfig, ModelProvider

if __name__ == '__main__':
lrcer = LRCer()
Expand All @@ -169,28 +171,35 @@ if __name__ == '__main__':
# To skip translation process
lrcer.run('./data/test.mp3', target_lang='en', skip_trans=True)

# Change asr_options or vad_options, check openlrc.defaults for details
# Change asr_options or vad_options (see openlrc.defaults for details)
vad_options = {"threshold": 0.1}
lrcer = LRCer(vad_options=vad_options)
lrcer.run('./data/test.mp3', target_lang='zh-cn')

# Enhance the audio using noise suppression (consume more time).
lrcer.run('./data/test.mp3', target_lang='zh-cn', noise_suppress=True)

# Change the LLM model for translation
# Change the translation model
lrcer = LRCer(chatbot_model='claude-3-sonnet-20240229')
lrcer.run('./data/test.mp3', target_lang='zh-cn')

# Clear temp folder after processing done
lrcer.run('./data/test.mp3', target_lang='zh-cn', clear_temp=True)

# Change base_url
lrcer = LRCer(base_url_config={'openai': 'https://api.g4f.icu/v1',
'anthropic': 'https://example/api'})

# Route model to arbitrary Chatbot SDK
lrcer = LRCer(chatbot_model='openai: claude-3-sonnet-20240229',
base_url_config={'openai': 'https://api.g4f.icu/v1/'})
# Use OpenRouter via ModelConfig (custom base_url + routed model name)
openrouter_model = ModelConfig(
provider=ModelProvider.OPENAI,
name='anthropic/claude-3.5-haiku',
base_url='https://openrouter.ai/api/v1',
api_key=os.getenv('OPENROUTER_API_KEY')
)
fallback_model = ModelConfig(
provider=ModelProvider.OPENAI,
name='openai/gpt-4.1-nano',
base_url='https://openrouter.ai/api/v1',
api_key=os.getenv('OPENROUTER_API_KEY')
)
lrcer = LRCer(chatbot_model=openrouter_model, retry_model=fallback_model)

# Bilingual subtitle
lrcer.run('./data/test.mp3', target_lang='zh-cn', bilingual_sub=True)
Expand Down Expand Up @@ -250,9 +259,9 @@ The actual cost may vary due to the language and audio speed.**

### Recommended translation model

For english audio, we recommend using `deepseek-chat`, `gpt-4o-mini` or `gemini-1.5-flash`.
For English audio, we recommend `deepseek-chat`, `gpt-4o-mini`, or `gemini-1.5-flash`.

For non-english audio, we recommend using `claude-3-5-sonnet-20240620`.
For non-English audio, we recommend `claude-3-5-sonnet-20240620`.

## How it works

Expand All @@ -265,28 +274,34 @@ To maintain context between translation segments, the process is sequential for

## Development Guide

I'm using [uv](https://github.com/astral-sh/uv) for package management.
Install uv with our standalone installers:
This project uses [uv](https://github.com/astral-sh/uv) for package management.
Install `uv` with the standalone installer:

#### On macOS and Linux.
#### On macOS and Linux

```shell
curl -LsSf https://astral.sh/uv/install.sh | sh
```

#### On Windows.
#### On Windows

```shell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

### Install deps
### Install dependencies

```shell
uv venv
uv sync
```

For live translation testing as a developer (and for CI usage), set:

```shell
export OPENROUTER_API_KEY="your-openrouter-api-key"
```

## Todo

- [x] [Efficiency] Batched translate/polish for GPT request (enable contextual ability).
Expand Down
48 changes: 45 additions & 3 deletions openlrc/chatbot.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2025. Hao Zheng
# Copyright (C) 2026. Hao Zheng
# All rights reserved.

import asyncio
Expand Down Expand Up @@ -183,15 +183,54 @@ def __init__(self, model_name='gpt-4.1-nano', temperature=1, top_p=1, retry=8, m

super().__init__(model_name, temperature, top_p, retry, max_async, fee_limit, is_beta)

resolved_api_key, resolved_base_url = self._resolve_client_settings(
api_key=api_key, base_url_config=base_url_config
)

self.async_client = AsyncGPTClient(
api_key=api_key or os.environ['OPENAI_API_KEY'],
api_key=resolved_api_key,
http_client=httpx.AsyncClient(proxy=proxy),
base_url=base_url_config['openai'] if base_url_config and base_url_config['openai'] else None
base_url=resolved_base_url
)

self.model_name = model_name
self.json_mode = json_mode

@staticmethod
def _resolve_client_settings(api_key: Optional[str], base_url_config: Optional[dict]) -> tuple[str, Optional[str]]:
"""
Resolve API key and base URL, auto-switching to OpenRouter when needed.
"""
openai_base_url = None
if base_url_config:
openai_base_url = base_url_config.get('openai')

if api_key:
return api_key, openai_base_url

is_openrouter = bool(openai_base_url and 'openrouter.ai' in openai_base_url.lower())
openai_api_key = os.environ.get('OPENAI_API_KEY')
openrouter_api_key = os.environ.get('OPENROUTER_API_KEY')

if is_openrouter:
if openrouter_api_key:
return openrouter_api_key, openai_base_url
if openai_api_key:
return openai_api_key, openai_base_url
raise ValueError(
'OPENROUTER_API_KEY is required when using OpenRouter base_url. '
'Set OPENROUTER_API_KEY or pass api_key explicitly.'
)

if openai_api_key:
return openai_api_key, openai_base_url

if openrouter_api_key:
logger.info('OPENAI_API_KEY not found, fallback to OPENROUTER_API_KEY with OpenRouter endpoint.')
return openrouter_api_key, 'https://openrouter.ai/api/v1'

raise ValueError('No API key found. Set OPENAI_API_KEY or pass api_key explicitly.')

def __exit__(self, exc_type, exc_val, exc_tb):
self.async_client.close()

Expand Down Expand Up @@ -235,6 +274,9 @@ async def _create_achat(self, messages: List[Dict], stop_sequences: Optional[Lis
continue

break
except openai.AuthenticationError as e:
# Authentication errors are deterministic and should not be retried.
raise ChatBotException(f'Authentication failed: {e}') from e
except (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.APIError,
json.decoder.JSONDecodeError) as e:
sleep_time = self._get_sleep_time(e)
Expand Down
7 changes: 4 additions & 3 deletions openlrc/prompter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2025. Hao Zheng
# Copyright (C) 2026. Hao Zheng
# All rights reserved.

import abc
Expand Down Expand Up @@ -250,7 +250,7 @@ def system(self):
</example>

Note:
There was an issue with the previous translation.
There was an issue with the previous review.

DO NOT add the translated sample text in the response.
DO NOT include any translation segment.
Expand All @@ -260,7 +260,8 @@ def system(self):
Remember to add {self.stop_sequence} after the generated contexts.
Remember you are a context provider, but NOT a translator. DO NOT provide any directly translation in the response.
If you are given an existing glossary, try your best to incorporate it into the context review.
Stop generating as soon as possible if you have generated a workable guideline (only include the glossary, characters, summary, tone and style, and target audience).'''
Stop generating as soon as possible if you have generated a workable guideline (only include the glossary, characters, summary, tone and style, and target audience).
I may give you a glossary. Please provide me with a new glossary that does not overlap with the one I give you.'''

def user(self, text, title='', given_glossary: Optional[dict] = None):
glossary_text = f'Given glossary: {given_glossary}' if given_glossary else ''
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ dependencies = [
"json_repair==0.25.2",
"matplotlib>=3.9.2,<4",
"typing-extensions>=4.12.2,<5",
"onnxruntime>=1.20.0,<2",
"onnxruntime>=1.20.0,<1.24; python_version < '3.11'",
"onnxruntime>=1.20.0,<2; python_version >= '3.11'",
"torch>=2.6.0",
"torchvision>=0.21.0",
"torchaudio>=2.0.0",
Expand Down
17 changes: 16 additions & 1 deletion tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@

import os
import unittest
import os
from typing import List
from unittest.mock import patch, MagicMock

from pydantic import BaseModel

from openlrc.agents import ChunkedTranslatorAgent, TranslationContext, ContextReviewerAgent
from openlrc.context import TranslateInfo
from openlrc.models import ModelConfig, ModelProvider
from openlrc.prompter import ChunkedTranslatePrompter

OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY')
OPENROUTER_CHEAP_MODEL = ModelConfig(
provider=ModelProvider.OPENAI,
name='google/gemini-2.5-flash-lite',
base_url=OPENROUTER_BASE_URL,
api_key=OPENROUTER_API_KEY
)
LIVE_API = os.environ.get('OPENLRC_TEST_LIVE_API', '').lower() in ('1', 'true', 'yes')


Expand Down Expand Up @@ -99,6 +109,11 @@ def test_use_glossary_terms_success(self):

@unittest.skipUnless(LIVE_API, 'Requires OPENLRC_TEST_LIVE_API=1 and valid API keys')
class TestContextReviewerAgent(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not OPENROUTER_API_KEY:
raise unittest.SkipTest('OPENROUTER_API_KEY is required for LLM integration tests.')

def test_generates_valid_context(self):
texts = ["John and Sarah discuss their plan to locate a suspect",
"John: 'As a 10 years experienced detector, my advice is we should start our search in the uptown area.'",
Expand All @@ -107,7 +122,7 @@ def test_generates_valid_context(self):
title = "The Detectors"
glossary = {"suspect": "嫌疑人", "uptown": "市中心"}

agent = ContextReviewerAgent('en', 'zh')
agent = ContextReviewerAgent('en', 'zh', chatbot_model=OPENROUTER_CHEAP_MODEL)
context = agent.build_context(texts, title, glossary)

self.assertIsNotNone(context)
Expand Down
Loading
Loading