File size: 9,580 Bytes
ec0f4cb
 
 
 
 
 
 
 
 
5f27c88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec0f4cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# -*- coding: utf-8 -*-
import os
import json
import tempfile
import datetime
import requests
from pathlib import Path

import gradio as gr
try:
    from PyPDF2 import PdfReader
except ImportError:
    from pdfminer.high_level import extract_text as _extract_text

    class _PdfPage:
        def __init__(self, text):
            self._text = text

        def extract_text(self):
            return self._text

    class PdfReader:
        """
        Minimal fallback PDF reader using pdfminer.
        Provides a `pages` attribute with objects that have `extract_text()`.
        """
        def __init__(self, path):
            full_text = _extract_text(path) or ""
            # Split into pages by form feed if possible, else treat as single page
            pages_text = full_text.split("\f")
            self.pages = [_PdfPage(t) for t in pages_text if t]
from pydub import AudioSegment

from gradio_theme_fenix import apply_fenix_theme, FENIX_CSS, fenix_header, fenix_footer

# ----------------------------------------------------------------------
# Configuration (read from environment)
# ----------------------------------------------------------------------
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID")  # voice for "Álvaro España"
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10 MB
HISTORY_FILE = Path("history.json")
MAX_HISTORY = 5

# ----------------------------------------------------------------------
# Helper functions
# ----------------------------------------------------------------------
def load_history():
    if HISTORY_FILE.exists():
        try:
            with open(HISTORY_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return []
    return []

def save_history(entry):
    history = load_history()
    history.insert(0, entry)  # newest first
    history = history[:MAX_HISTORY]
    with open(HISTORY_FILE, "w", encoding="utf-8") as f:
        json.dump(history, f, ensure_ascii=False, indent=2)

def extract_text_from_pdf(pdf_path: str, progress=gr.Progress()):
    """
    Extracts text from a PDF file preserving reading order.
    """
    progress(0, desc="Opening PDF")
    try:
        reader = PdfReader(pdf_path)
        text_pages = []
        total_pages = len(reader.pages)
        for i, page in enumerate(reader.pages):
            text_pages.append(page.extract_text() or "")
            progress((i + 1) / total_pages, desc=f"Extracting page {i+1}/{total_pages}")
        full_text = "\n".join(text_pages).strip()
        return full_text
    except Exception as e:
        raise RuntimeError(f"Error extracting PDF: {str(e)}")

def call_elevenlabs_tts(text: str, tone: str, speed: float, progress=gr.Progress()):
    """
    Calls ElevenLabs TTS API to generate MP3 audio.
    """
    if not ELEVENLABS_API_KEY or not ELEVENLABS_VOICE_ID:
        raise RuntimeError("ElevenLabs API key or voice ID not configured.")
    url = f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVENLABS_VOICE_ID}"
    headers = {
        "xi-api-key": ELEVENLABS_API_KEY,
        "Content-Type": "application/json"
    }

    # Voice settings: adjust pitch and stability for "sagrado" tone
    voice_settings = {
        "stability": 0.75,
        "similarity_boost": 0.75,
        "speed": speed
    }
    if tone == "sagrado":
        voice_settings.update({
            "stability": 0.9,
            "similarity_boost": 0.9,
            "pitch": 1.2  # higher pitch for sacred tone
        })

    payload = {
        "text": text,
        "voice_settings": voice_settings,
        "model_id": "eleven_multilingual_v2"
    }

    # Retry logic (max 2 retries)
    for attempt in range(2):
        try:
            progress(0.2, desc="Sending request to TTS")
            response = requests.post(url, headers=headers, json=payload, timeout=30)
            if response.status_code == 200:
                progress(0.8, desc="Receiving audio")
                return response.content
            else:
                raise RuntimeError(f"TTS API error {response.status_code}: {response.text}")
        except Exception as e:
            if attempt == 1:
                raise RuntimeError(f"TTS request failed after retries: {str(e)}")
            # wait a moment before retry
            progress(0.5, desc=f"Retry {attempt+1}/2")
    raise RuntimeError("Unexpected flow in TTS generation.")

def generate_audio(pdf_file, tone, speed, progress=gr.Progress()):
    """
    Main pipeline: extract text -> generate audio -> save MP3 -> update history.
    Returns path to MP3 file and a dict for history display.
    """
    if pdf_file is None:
        raise gr.Error("Please upload a PDF file.")
    if pdf_file.size > MAX_FILE_SIZE:
        raise gr.Error("File exceeds maximum size of 10 MB.")

    # Save uploaded PDF to a temporary file
    with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_pdf:
        tmp_pdf.write(pdf_file.read())
        pdf_path = tmp_pdf.name

    # Step 1: Extract text
    progress(0.0, desc="Extracting text")
    text = extract_text_from_pdf(pdf_path, progress=progress)
    if not text:
        raise gr.Error("No readable text found in the PDF.")

    # Step 2: Generate audio via ElevenLabs
    progress(0.3, desc="Generating audio")
    audio_bytes = call_elevenlabs_tts(text, tone, speed, progress=progress)

    # Step 3: Save MP3 to temporary file
    with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp_mp3:
        tmp_mp3.write(audio_bytes)
        mp3_path = tmp_mp3.name

    # Step 4: Gather metadata for history
    file_name = Path(pdf_file.name).name
    duration_sec = AudioSegment.from_file(mp3_path).duration_seconds
    entry = {
        "filename": file_name,
        "date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "duration": f"{duration_sec:.1f}s",
        "audio_path": mp3_path
    }
    save_history(entry)

    # Clean up PDF temp file
    try:
        os.remove(pdf_path)
    except Exception:
        pass

    return mp3_path, entry

def load_history_for_ui():
    """
    Returns a list of rows for the history Dataframe.
    """
    history = load_history()
    rows = [
        [h["filename"], h["date"], h["duration"]]
        for h in history
    ]
    return rows

def play_from_history(index):
    """
    Returns the audio file path for the selected history entry.
    """
    history = load_history()
    if 0 <= index < len(history):
        return history[index]["audio_path"]
    raise gr.Error("Invalid history selection.")

# ----------------------------------------------------------------------
# Gradio Interface
# ----------------------------------------------------------------------
with gr.Blocks(css=FENIX_CSS, theme=apply_fenix_theme(), title="PDF Voice Reader") as demo:
    fenix_header("PDF Voice Reader", "Convert PDF to audio with sacred tone")

    with gr.Row():
        # Left column: main workflow
        with gr.Column(scale=3):
            pdf_input = gr.File(label="PDF file", file_types=[".pdf"], type="file")
            tone_radio = gr.Radio(
                choices=["normal", "sagrado"],
                label="Select tone",
                value="normal"
            )
            speed_slider = gr.Slider(
                minimum=0.75,
                maximum=1.25,
                step=0.25,
                label="Reading speed",
                value=1.0
            )
            generate_btn = gr.Button("Generar Audio", variant="primary")
            audio_output = gr.Audio(label="Audio result", type="filepath")
            download_btn = gr.File(label="Descargar MP3", visible=False)

            # Progress bar (hidden until used)
            progress_bar = gr.ProgressBar(visible=False)

            # Callback chain
            def on_generate(pdf_file, tone, speed):
                progress_bar.visible = True
                mp3_path, entry = generate_audio(pdf_file, tone, speed, progress=progress_bar)
                download_btn.visible = True
                download_btn.update(value=mp3_path, label="Descargar MP3")
                return mp3_path, entry

            generate_btn.click(
                fn=on_generate,
                inputs=[pdf_input, tone_radio, speed_slider],
                outputs=[audio_output, None],
                api_name=False
            )

        # Right column: history
        with gr.Column(scale=2):
            gr.Markdown("## Historial (últimos 5 PDFs)")
            history_df = gr.Dataframe(
                headers=["Archivo", "Fecha", "Duración"],
                datatype=["str", "str", "str"],
                row_count=MAX_HISTORY,
                column_count=3,
                interactive=False,
                label="Historial"
            )
            play_btn = gr.Button("Reproducir seleccionado")
            history_audio = gr.Audio(label="Audio del historial", type="filepath")

            # Load history initially
            history_df.load(fn=load_history_for_ui)

            def on_play(selected):
                if selected is None or len(selected) == 0:
                    raise gr.Error("Seleccione una fila del historial.")
                # selected is a list of row indices; take first
                idx = selected[0]
                return play_from_history(idx)

            play_btn.click(
                fn=on_play,
                inputs=[history_df.select],
                outputs=history_audio
            )

    fenix_footer()

demo.launch()