[GH-ISSUE #10064] Kokoro real time audio #15752

Closed
opened 2026-04-19 21:54:01 -05:00 by GiteaMirror · 6 comments
Owner

Originally created by @andsty on GitHub (Feb 15, 2025).
Original GitHub issue: https://github.com/open-webui/open-webui/issues/10064

Feature Request

Important Notes

Add support of real time voice transcription with Kokoro.
You can easily do it we websockets.
Here is a sample code:

import asyncio
import json
import numpy as np
import requests
import pyaudio
from kokoro import KPipeline
import torch
import warnings
from pynput import keyboard
import threading

# Suppress warnings
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=FutureWarning)

# Global speed control
SPEECH_SPEED = 1.4  # Default speed
SPEED_STEP = 0.1    # Speed adjustment increment
speed_lock = threading.Lock()

# Initialize Kokoro TTS pipeline
pipeline = KPipeline(lang_code='a')

# PyAudio configuration
FORMAT = pyaudio.paFloat32
CHANNELS = 1
RATE = 24000
CHUNK = 1024

# Initialize PyAudio
py_audio = pyaudio.PyAudio()
audio_stream = py_audio.open(format=FORMAT,
                             channels=CHANNELS,
                             rate=RATE,
                             output=True)

def adjust_speed(direction):
    """Adjust the speech speed up or down."""
    global SPEECH_SPEED
    with speed_lock:
        if direction == 'up':
            SPEECH_SPEED = min(3.0, SPEECH_SPEED + SPEED_STEP)
        else:
            SPEECH_SPEED = max(0.5, SPEECH_SPEED - SPEED_STEP)
        print(f"\nSpeed adjusted to {SPEECH_SPEED:.1f}x")

def on_press(key):
    """Handle keyboard press events."""
    try:
        if key == keyboard.Key.up:
            adjust_speed('up')
        elif key == keyboard.Key.down:
            adjust_speed('down')
    except AttributeError:
        pass

def start_keyboard_listener():
    """Start the keyboard listener in a separate thread."""
    listener = keyboard.Listener(on_press=on_press)
    listener.start()

def clean_text(text):
    """Clean the text by removing unnecessary characters and fixing spaces."""
    text = text.replace('*', '').strip()
    common_fixes = {
        'emp ower': 'empower',
        'effect ively': 'effectively',
        'decision -m': 'decision-m',
        'decision - ': 'decision-',
        ' -': '-',
        '- ': '-'
    }
    for wrong, correct in common_fixes.items():
        text = text.replace(wrong, correct)
    text = ' '.join(text.split())
    return text

def tensor_to_audio_bytes(tensor_data):
    """Convert a PyTorch tensor or numpy array to audio bytes."""
    if isinstance(tensor_data, torch.Tensor):
        audio_np = tensor_data.cpu().numpy()
    elif isinstance(tensor_data, np.ndarray):
        audio_np = tensor_data
    else:
        raise ValueError(f"Unexpected data type: {type(tensor_data)}")

    audio_np = audio_np.astype(np.float32)
    if audio_np.max() > 1.0 or audio_np.min() < -1.0:
        audio_np = np.clip(audio_np, -1.0, 1.0)
    return audio_np.tobytes()

def process_audio_chunk(audio_data):
    """Process and play a single audio chunk."""
    if audio_data is not None and len(audio_data) > 0:
        try:
            audio_bytes = tensor_to_audio_bytes(audio_data)
            audio_stream.write(audio_bytes)
        except Exception as e:
            print(f"Error processing audio chunk: {e}")

def process_text_chunk(text_chunk):
    """Process a single text chunk through Kokoro TTS."""
    cleaned_text = clean_text(text_chunk)
    print(f"Processing: {cleaned_text}")

    try:
        with speed_lock:
            current_speed = SPEECH_SPEED
        generator = pipeline(cleaned_text, voice='af_nicole', speed=current_speed)
        for _, (_, _, audio) in enumerate(generator):
            if audio is not None:
                process_audio_chunk(audio)
                break
    except Exception as e:
        print(f"Error processing chunk: {e}")

def get_ollama_stream(prompt, model="deepseek-r1:1.5b"):
    """Stream text from Ollama using HTTP streaming."""
    url = "http://localhost:11434/api/generate"
    data = {
        "model": model,
        "prompt": prompt
    }

    try:
        response = requests.post(url, json=data, stream=True)
        buffer = ""

        for line in response.iter_lines():
            if line:
                json_response = json.loads(line)
                if 'response' in json_response:
                    chunk = json_response['response']
                    buffer += chunk

                    # Check for sentence endings
                    while True:
                        sentence_end = -1
                        for end_char in ['.', '!', '?']:
                            pos = buffer.find(end_char)
                            if pos != -1 and (sentence_end == -1 or pos < sentence_end):
                                sentence_end = pos

                        if sentence_end == -1:
                            break

                        # Extract the sentence and yield it
                        sentence = buffer[:sentence_end + 1].strip()
                        if sentence:
                            yield sentence
                        buffer = buffer[sentence_end + 1:].strip()

        # Yield any remaining text
        if buffer.strip():
            yield buffer.strip()

    except requests.exceptions.RequestException as e:
        print(f"Error connecting to Ollama: {e}")
        return

def process_prompt(prompt):
    """Process the entire prompt."""
    print(f"\nProcessing prompt: {prompt}")
    for text_chunk in get_ollama_stream(prompt):
        process_text_chunk(text_chunk)

def main():
    print("Welcome to Ollama-Kokoro Real-time TTS!")
    print("Controls:")
    print("- Ctrl + Up Arrow: Increase speed")
    print("- Ctrl + Down Arrow: Decrease speed")
    print("- Type 'quit' or 'exit' to end the program")
    print(f"Current speed: {SPEECH_SPEED}x")

    # Start the keyboard listener
    start_keyboard_listener()

    try:
        while True:
            prompt = input("\nEnter your prompt: ").strip()

            if prompt.lower() in ['quit', 'exit']:
                print("Goodbye!")
                break

            if not prompt:
                print("Please enter a valid prompt.")
                continue

            process_prompt(prompt)

    except KeyboardInterrupt:
        print("\nProgram interrupted by user. Goodbye!")
    finally:
        audio_stream.stop_stream()
        audio_stream.close()
        py_audio.terminate()

if __name__ == "__main__":
    main()
Originally created by @andsty on GitHub (Feb 15, 2025). Original GitHub issue: https://github.com/open-webui/open-webui/issues/10064 # Feature Request ## Important Notes Add support of real time voice transcription with Kokoro. You can easily do it we websockets. Here is a sample code: ```python import asyncio import json import numpy as np import requests import pyaudio from kokoro import KPipeline import torch import warnings from pynput import keyboard import threading # Suppress warnings warnings.filterwarnings("ignore", category=UserWarning) warnings.filterwarnings("ignore", category=FutureWarning) # Global speed control SPEECH_SPEED = 1.4 # Default speed SPEED_STEP = 0.1 # Speed adjustment increment speed_lock = threading.Lock() # Initialize Kokoro TTS pipeline pipeline = KPipeline(lang_code='a') # PyAudio configuration FORMAT = pyaudio.paFloat32 CHANNELS = 1 RATE = 24000 CHUNK = 1024 # Initialize PyAudio py_audio = pyaudio.PyAudio() audio_stream = py_audio.open(format=FORMAT, channels=CHANNELS, rate=RATE, output=True) def adjust_speed(direction): """Adjust the speech speed up or down.""" global SPEECH_SPEED with speed_lock: if direction == 'up': SPEECH_SPEED = min(3.0, SPEECH_SPEED + SPEED_STEP) else: SPEECH_SPEED = max(0.5, SPEECH_SPEED - SPEED_STEP) print(f"\nSpeed adjusted to {SPEECH_SPEED:.1f}x") def on_press(key): """Handle keyboard press events.""" try: if key == keyboard.Key.up: adjust_speed('up') elif key == keyboard.Key.down: adjust_speed('down') except AttributeError: pass def start_keyboard_listener(): """Start the keyboard listener in a separate thread.""" listener = keyboard.Listener(on_press=on_press) listener.start() def clean_text(text): """Clean the text by removing unnecessary characters and fixing spaces.""" text = text.replace('*', '').strip() common_fixes = { 'emp ower': 'empower', 'effect ively': 'effectively', 'decision -m': 'decision-m', 'decision - ': 'decision-', ' -': '-', '- ': '-' } for wrong, correct in common_fixes.items(): text = text.replace(wrong, correct) text = ' '.join(text.split()) return text def tensor_to_audio_bytes(tensor_data): """Convert a PyTorch tensor or numpy array to audio bytes.""" if isinstance(tensor_data, torch.Tensor): audio_np = tensor_data.cpu().numpy() elif isinstance(tensor_data, np.ndarray): audio_np = tensor_data else: raise ValueError(f"Unexpected data type: {type(tensor_data)}") audio_np = audio_np.astype(np.float32) if audio_np.max() > 1.0 or audio_np.min() < -1.0: audio_np = np.clip(audio_np, -1.0, 1.0) return audio_np.tobytes() def process_audio_chunk(audio_data): """Process and play a single audio chunk.""" if audio_data is not None and len(audio_data) > 0: try: audio_bytes = tensor_to_audio_bytes(audio_data) audio_stream.write(audio_bytes) except Exception as e: print(f"Error processing audio chunk: {e}") def process_text_chunk(text_chunk): """Process a single text chunk through Kokoro TTS.""" cleaned_text = clean_text(text_chunk) print(f"Processing: {cleaned_text}") try: with speed_lock: current_speed = SPEECH_SPEED generator = pipeline(cleaned_text, voice='af_nicole', speed=current_speed) for _, (_, _, audio) in enumerate(generator): if audio is not None: process_audio_chunk(audio) break except Exception as e: print(f"Error processing chunk: {e}") def get_ollama_stream(prompt, model="deepseek-r1:1.5b"): """Stream text from Ollama using HTTP streaming.""" url = "http://localhost:11434/api/generate" data = { "model": model, "prompt": prompt } try: response = requests.post(url, json=data, stream=True) buffer = "" for line in response.iter_lines(): if line: json_response = json.loads(line) if 'response' in json_response: chunk = json_response['response'] buffer += chunk # Check for sentence endings while True: sentence_end = -1 for end_char in ['.', '!', '?']: pos = buffer.find(end_char) if pos != -1 and (sentence_end == -1 or pos < sentence_end): sentence_end = pos if sentence_end == -1: break # Extract the sentence and yield it sentence = buffer[:sentence_end + 1].strip() if sentence: yield sentence buffer = buffer[sentence_end + 1:].strip() # Yield any remaining text if buffer.strip(): yield buffer.strip() except requests.exceptions.RequestException as e: print(f"Error connecting to Ollama: {e}") return def process_prompt(prompt): """Process the entire prompt.""" print(f"\nProcessing prompt: {prompt}") for text_chunk in get_ollama_stream(prompt): process_text_chunk(text_chunk) def main(): print("Welcome to Ollama-Kokoro Real-time TTS!") print("Controls:") print("- Ctrl + Up Arrow: Increase speed") print("- Ctrl + Down Arrow: Decrease speed") print("- Type 'quit' or 'exit' to end the program") print(f"Current speed: {SPEECH_SPEED}x") # Start the keyboard listener start_keyboard_listener() try: while True: prompt = input("\nEnter your prompt: ").strip() if prompt.lower() in ['quit', 'exit']: print("Goodbye!") break if not prompt: print("Please enter a valid prompt.") continue process_prompt(prompt) except KeyboardInterrupt: print("\nProgram interrupted by user. Goodbye!") finally: audio_stream.stop_stream() audio_stream.close() py_audio.terminate() if __name__ == "__main__": main() ```
Author
Owner

@cyuzik commented on GitHub (Feb 15, 2025):

The demos I've heard for Kokoro are fantastic and far more realistic than the existing TTS implementation.

<!-- gh-comment-id:2661027738 --> @cyuzik commented on GitHub (Feb 15, 2025): The demos I've heard for Kokoro are fantastic and far more realistic than the existing TTS implementation.
Author
Owner

@silentoplayz commented on GitHub (Feb 15, 2025):

As of v0.5.11 of Open WebUI:

🎤 Kokoro-JS TTS Support: A new on-device, high-quality text-to-speech engine has been integrated, vastly improving voice generation quality—everything runs directly in your browser.

That's one option available. If your browser does not have WebGPU support, then the Kokoro.js option will not work for you.

A second option available is to run Kokoro-FastAPI locally and integrate that into your Open WebUI instance.

<!-- gh-comment-id:2661057724 --> @silentoplayz commented on GitHub (Feb 15, 2025): As of [v0.5.11](https://github.com/open-webui/open-webui/releases/tag/v0.5.11) of Open WebUI: > 🎤 Kokoro-JS TTS Support: A new on-device, high-quality text-to-speech engine has been integrated, vastly improving voice generation quality—everything runs directly in your browser. That's one option available. If your browser does not have [WebGPU support](https://caniuse.com/webgpu), then the Kokoro.js option will not work for you. A second option available is to run [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) locally and integrate that into your Open WebUI instance.
Author
Owner

@myrulezzz commented on GitHub (Feb 15, 2025):

The kokoro integration is perfect. But as models like deepseek generate a lot of content i was thinking that with similar implemenration i shared it wont wait wait for inference to finish and then start the voice

<!-- gh-comment-id:2661061948 --> @myrulezzz commented on GitHub (Feb 15, 2025): The kokoro integration is perfect. But as models like deepseek generate a lot of content i was thinking that with similar implemenration i shared it wont wait wait for inference to finish and then start the voice
Author
Owner

@StevePierce commented on GitHub (Feb 15, 2025):

There is a setting in Admin Settings > Settings > Audio called "Response Splitting" which will dictate how quickly portions of the response will be sent to TTS engine.

<!-- gh-comment-id:2661095026 --> @StevePierce commented on GitHub (Feb 15, 2025): There is a setting in Admin Settings > Settings > Audio called "Response Splitting" which will dictate how quickly portions of the response will be sent to TTS engine.
Author
Owner

@myrulezzz commented on GitHub (Feb 15, 2025):

But even that one having auto audio response enabled for llm answers it will wait for the response to finish and then start.it will not start lets say when the first paragraph is finished. So if there is a long response, the response should finish and then start the audio.

<!-- gh-comment-id:2661096417 --> @myrulezzz commented on GitHub (Feb 15, 2025): But even that one having auto audio response enabled for llm answers it will wait for the response to finish and then start.it will not start lets say when the first paragraph is finished. So if there is a long response, the response should finish and then start the audio.
Author
Owner

@tjbck commented on GitHub (Feb 16, 2025):

Already implemented.

<!-- gh-comment-id:2661154094 --> @tjbck commented on GitHub (Feb 16, 2025): Already implemented.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/open-webui#15752