Skip to content
Draft
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
2 changes: 2 additions & 0 deletions lute/config/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ def _load_config(self, config_file_path):
self.userimagespath = os.path.join(self.datapath, "userimages")
self.useraudiopath = os.path.join(self.datapath, "useraudio")
self.userthemespath = os.path.join(self.datapath, "userthemes")
self.ttsoutputpath = os.path.join(self.datapath, "ttsout")
self.temppath = os.path.join(self.datapath, "temp")
self.dbfilename = os.path.join(self.datapath, self.dbname)
self.tts_configs = config.get("TTS", {})

# Path to db backup.
# When Lute starts up, it backs up the db
Expand Down
26 changes: 24 additions & 2 deletions lute/read/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
"""

from datetime import datetime
from flask import Blueprint, flash, request, render_template, redirect, jsonify
from flask import Blueprint, flash, request, render_template, redirect, jsonify, send_file, current_app
from lute.read.service import set_unknowns_to_known, start_reading, get_popup_data
from lute.read.forms import TextForm
from lute.tts.registry import tts_enabled, get_tts_engine_for_language
from lute.term.model import Repository
from lute.term.routes import handle_term_form
from lute.models.book import Book, Text
from lute.models.setting import UserSetting
from lute.db import db

from lute.tts.openai import TTSEngineOpenAiApi
import os


bp = Blueprint("read", __name__, url_prefix="/read")

Expand Down Expand Up @@ -165,7 +169,8 @@ def render_page(bookid, pagenum):
flash(f"No book matching id {bookid}")
return redirect("/", 302)
paragraphs = start_reading(book, pagenum, db.session)
return render_template("read/page_content.html", paragraphs=paragraphs)
return render_template("read/page_content.html", paragraphs=paragraphs,\
use_tts=tts_enabled(book.language_id), bookid=bookid, pagenum=pagenum)


@bp.route("/empty", methods=["GET"])
Expand Down Expand Up @@ -253,3 +258,20 @@ def edit_page(bookid, pagenum):
return render_template(
"read/page_edit_form.html", hide_top_menu=True, form=form, text_dir=text_dir
)

@bp.route("/tts/<int:bookid>/<int:pagenum>", methods=["GET"])
def tts(bookid, pagenum):
"""Per-page TTS Endpoint"""
book = Book.find(bookid)
if book is None:
flash(f"No book matching id {bookid}")
return redirect("/", 302)
raw_text = book.text_at_page(pagenum).text
dirname = current_app.env_config.ttsoutputpath
speech_file_path=os.path.join(dirname, f"book{bookid}-page{pagenum}.mp3")
if os.path.exists(speech_file_path) and os.path.isfile(speech_file_path):
return send_file(speech_file_path)
else:
engine = get_tts_engine_for_language(book.language_id)
engine.tts(raw_text, speech_file_path)
return send_file(speech_file_path)
7 changes: 7 additions & 0 deletions lute/templates/read/page_content.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@

{% endfor %}


{% if use_tts %}
<p>
<audio controls src="/read/tts/{{ bookid }}/{{ pagenum }}"></audio>
</p>
{% endif %}

<script>
// Defined in lute.js
parent.start_hover_mode(false);
Expand Down
Empty file added lute/tts/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions lute/tts/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from abc import ABC, abstractmethod


class AbstractTextToSpeechEngine(ABC):
"""
Abstract engine, inherited from by all Lute TTS engines.
"""

@classmethod
@abstractmethod
def name(cls):
"""
TTS engine name, for displaying in UI.
"""

@abstractmethod
def tts(self, text: str, filepath: str, format: str):
"""
converts text to speech and writes to filepath
"""

class TTSEngineManager:
pass
39 changes: 39 additions & 0 deletions lute/tts/openai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from lute.tts.base import AbstractTextToSpeechEngine
from flask import current_app

from openai import OpenAI
import os


class TTSEngineOpenAiApi(AbstractTextToSpeechEngine):
"""
TTS Engine for Opne API
"""

def __init__(self):
model = 'tts-1'
voice = 'echo'
api_key = None
openai_config = current_app.env_config.tts_configs.get('OPENAI')
if openai_config is not None:
model = openai_config.get('MODEL', 'tts-1')
voice = openai_config.get('VOICE', 'echo')
api_key = openai_config.get('APIKEY')
if api_key is None:
self.client = OpenAI()
else:
self.client = OpenAI(api_key=api_key)
self._model = model
self._voice = voice

def name(self):
return "OpenAI_TTS"

def tts(self, text: str, speech_file_path: str, format='mp3'):
response = self.client.audio.speech.create(
model= self._model,
voice = self._voice,
input= text,
response_format = format
)
response.stream_to_file(speech_file_path)
55 changes: 55 additions & 0 deletions lute/tts/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from importlib.metadata import entry_points
from flask import current_app
from sys import version_info

from lute.tts.base import AbstractTextToSpeechEngine
from lute.tts.openai import TTSEngineOpenAiApi

__TTS_ENGINES__ = { "OpenAI_TTS": TTSEngineOpenAiApi,}


def init_tts_engines():
"""
Initialize all TTS engines from plugins
"""
vmaj = version_info.major
vmin = version_info.minor
if vmaj == 3 and vmin in (8, 9, 10, 11):
custom_tts_eps = entry_points().get("lute.plugin.tts")
elif (vmaj == 3 and vmin >= 12) or (vmaj >= 4):
# Can't be sure this will always work, API may change again,
# but can't plan for the unforseeable everywhere.
custom_tts_eps = entry_points().select(group="lute.plugin.tts")
else:
# earlier version of python than 3.8? What madness is this?
# Not going to throw, just print and hope the user sees it.
msg = f"Unable to load plugins for python {vmaj}.{vmin}, please upgrade to 3.8+"
print(msg, flush=True)
return

if custom_tts_eps is None:
return

for custom_tts_ep in custom_tts_eps:
name = custom_tts_ep.name
klass = custom_tts_ep.load()
if issubclass(klass, AbstractTextToSpeechEngine):
__TTS_ENGINES__[name] = klass
else:
raise ValueError(f"{name} is not a subclass of AbstractParser")

def get_tts_engine_for_language(language_id):
if not tts_enabled(language_id):
return None
default_engine = current_app.env_config.tts_configs.get("DEFAULT", list(__TTS_ENGINES__.keys())[0])
language_engine = current_app.env_config.tts_configs.get("LANGSPEC", {}).get(language_id, {}).get("ENGINE")
if language_engine is None:
return __TTS_ENGINES__[default_engine]()
else:
return __TTS_ENGINES__[language_engine]()


def tts_enabled(language_id):
if current_app.env_config.tts_configs.get("LANGSPEC", {}).get(language_id, {}).get("DISABLE") is not None:
return False
return len(__TTS_ENGINES__.items()) > 0