2026-03-13

How to listen to German radio


Now is the golden age of language learning. Small language models are lightweight and free, your laptop can run them. These models can transcribe and translate European languages. You can download MP3 file of Deutchlandfunk Kultur broadcast, transcribe it with whisper, translate it with Qwen3-4B and build a PDF with transcript and translation. Then you can listen to broadcast, see the transcript and translation, pause (Winamp has global hotkeys, Ctrl+Space and Ctrl+Left work great for me), look up a word in the dictionary.

Here’s the script that translates the transcript and outputs parallel_translation.pdf:

import nltk
from nltk.tokenize import sent_tokenize
import requests
import subprocess
import time
import os
import sys
from tqdm import tqdm
from fpdf import FPDF

# Configuration
LLAMA_SERVER_URL = "http://127.0.0.1:8080/completion"
SERVER_EXECUTABLE_PATH = r'llama-b6715-bin-win-cpu-x64\llama-server.exe'
MODEL_PATH = r'llama-b6715-bin-win-cpu-x64\Qwen3-4B-Instruct-2507-Q4_K_M.gguf'

MODEL_PARAMS = {
    "repeat_penalty": 1.0,
    "temperature": 0.6,
    "top_k": 20,
    "top_p": 0.95,
}

SERVER_ARGS = [
    "-m", MODEL_PATH,
    "--port", "8080",
    "--host", "127.0.0.1",
    "--threads", "4"
]
SERVER_STARTUP_TIMEOUT = 300

input_file = 'text.txt'
output_pdf = 'parallel_translation.pdf'

# nltk
def ensure_nltk_punkt():
    try:
        nltk.data.find('tokenizers/punkt')
    except LookupError:
        nltk.download('punkt', quiet=True)

# Server functions
def is_server_ready():
    try:
        payload = {"prompt": "Hello", "n_predict": 1, "stream": False}
        response = requests.post(LLAMA_SERVER_URL, json=payload, timeout=5)
        return response.status_code == 200
    except requests.exceptions.RequestException:
        return False

def start_server():
    if not os.path.exists(SERVER_EXECUTABLE_PATH):
        print(f"FATAL ERROR: Server executable not found at '{SERVER_EXECUTABLE_PATH}'")
        return None
    if not os.path.exists(MODEL_PATH):
        print(f"FATAL ERROR: Model file not found at '{MODEL_PATH}'")
        return None

    try:
        server_process = subprocess.Popen(
            [SERVER_EXECUTABLE_PATH] + SERVER_ARGS,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True
        )

        for _ in range(SERVER_STARTUP_TIMEOUT):
            if is_server_ready():
                return server_process
            time.sleep(1)

        print(f"ERROR: Server failed to start within {SERVER_STARTUP_TIMEOUT}s.")
        if server_process.poll() is None:
            server_process.terminate()
        return None

    except Exception as e:
        print(f"FATAL ERROR: Could not start server process: {e}")
        return None

# Batching functions
def split_into_sentences(text):
    return sent_tokenize(text)

def generate_batching_rule(n):
    if n < 1:
        return []
    if n == 1:
        return [1]
    threes = n // 3
    remainder = n % 3
    if remainder == 0:
        return [3] * threes
    elif remainder == 1:
        return [3] * (threes - 1) + [2, 2] if threes >= 1 else [2, 2]
    else:
        return [3] * threes + [2]

def create_batches(paragraphs):
    batches = []
    for p_idx, paragraph in enumerate(paragraphs):
        sentences = split_into_sentences(paragraph)
        n = len(sentences)
        if n == 0:
            continue
        rule = generate_batching_rule(n)
        pointer = 0
        for size in rule:
            batch = " ".join(sentences[pointer:pointer + size])
            batches.append((p_idx, batch))
            pointer += size
    return batches

# llama.cpp server API
def translate_batch_with_server(batch_text):
    prompt_text = (
        f"<|im_start|>user\nReturn only translation. Translate to English:\n{batch_text}\n<|im_end|>\n"
        f"<|im_start|>assistant\n"
    )

    payload = {
        "prompt": prompt_text,
        "repeat_penalty": MODEL_PARAMS['repeat_penalty'],
        "temperature": MODEL_PARAMS['temperature'],
        "top_k": MODEL_PARAMS['top_k'],
        "top_p": MODEL_PARAMS['top_p'],
        "stop": ["<|im_end|>"],
        "stream": False
    }

    try:
        response = requests.post(LLAMA_SERVER_URL, json=payload, timeout=600)
        response.raise_for_status()
        data = response.json()
        translated = data.get('content', '').strip()
        for token in ["<|im_end|>", "</s>", "[end of text]"]:
            translated = translated.replace(token, "").strip()
        return translated
    except requests.exceptions.ConnectionError:
        print(f"\n[ERROR] Connection failed. Is the server running at {LLAMA_SERVER_URL}?")
        return '[CONNECTION ERROR]'
    except requests.exceptions.RequestException as e:
        print(f"\n[ERROR] Request failed: {e}")
        return '[REQUEST ERROR]'
    except Exception as e:
        print(f"\n[EXCEPTION] Batch failed: {str(e)}")
        return '[TRANSLATION ERROR]'

server_process = None
ensure_nltk_punkt()

# setup
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()

# font
pdf.add_font("Arial", style="", fname="C:/Windows/Fonts/arial.ttf")
pdf.set_font('Arial', size=11)

# layout
margin = 15
gutter = 10
col_width = (pdf.w - (margin * 2) - gutter) / 2
line_height = 6

try:
    try:
        with open(input_file, 'r', encoding='utf-8') as f:
            raw_text = f.read()
    except FileNotFoundError:
        print(f"Error: Input file '{input_file}' not found.")
        sys.exit(1)

    paragraphs = [p.strip() for p in raw_text.split('\n\n') if p.strip()]
    batches = create_batches(paragraphs)

    print(f"🔄 Translating {len(batches)} batches...\n")

    if not is_server_ready():
        server_process = start_server()
        if server_process is None:
            sys.exit(1)

    for (p_idx, left_txt) in tqdm(batches, desc="Processing", unit="batch"):
        if len(left_txt.split()) < 3:
            right_txt = left_txt
        else:
            right_txt = translate_batch_with_server(left_txt)
        
        # calculate the height needed for both sides
        # split_lines() helps us predict how many lines fpdf will create
        lines_left = pdf.multi_cell(col_width, line_height, left_txt, dry_run=True, output="LINES")
        lines_right = pdf.multi_cell(col_width, line_height, right_txt, dry_run=True, output="LINES")
        
        max_lines = max(len(lines_left), len(lines_right))
        row_height = max_lines * line_height + 4 # +4 for internal padding

        # check for page break
        if pdf.get_y() + row_height > pdf.page_break_trigger:
            pdf.add_page()

        start_x = pdf.get_x()
        start_y = pdf.get_y()

        # draw the border
        pdf.rect(start_x, start_y, pdf.w - (margin * 2), row_height)
        
        # draw the middle line
        pdf.line(start_x + col_width + (gutter/2), start_y, 
                 start_x + col_width + (gutter/2), start_y + row_height)

        # place the text
        pdf.set_xy(start_x + 2, start_y + 2) # +2 for padding inside box
        pdf.multi_cell(col_width, line_height, left_txt, border=0, align='L')
        
        pdf.set_xy(start_x + col_width + gutter, start_y + 2)
        pdf.multi_cell(col_width, line_height, right_txt, border=0, align='L')

        # move cursor to the bottom of the common box
        pdf.set_y(start_y + row_height)

    pdf.output(output_pdf)
    print(f"\n✅ Translation complete.")

finally:
    if server_process:
        if server_process.poll() is None:
            server_process.terminate()
            try:
                server_process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                server_process.kill()