#!/usr/bin/env python3 """ Markdown to Podcast Generator - Tkinter UI for file selection - LM-Studio (qwen/qwen3.5-35b-a3b) for conversation script generation - Kokoro TTS (free, high-quality) with two voices for audio synthesis MIT License — Copyright (c) 2026 Prof. Dr. Joern Fischer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import tkinter as tk from tkinter import filedialog, scrolledtext, ttk, messagebox import threading import re import os import sys import subprocess import importlib import importlib.util # ── dependency bootstrap ────────────────────────────────────────────────────── def _is_installed(mod_name): import importlib.util as _ilu return _ilu.find_spec(mod_name) is not None def ensure_packages(): """Install missing packages using the SAME Python that is running this script.""" for mod, pip_name in [("requests", "requests"), ("soundfile", "soundfile"), ("numpy", "numpy")]: if not _is_installed(mod): print(f"Installing {pip_name} ...") subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", pip_name]) # kokoro: find_spec is unreliable on some Mac/venv setups — use a real import test try: import kokoro # noqa: F401 except ImportError: print("kokoro not found — installing ...") subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "kokoro"]) import importlib importlib.invalidate_caches() ensure_packages() import requests # noqa: E402 import numpy as np # noqa: E402 import soundfile as sf # noqa: E402 # ── LM-Studio helper ────────────────────────────────────────────────────────── LM_STUDIO_URL = "http://localhost:1234/v1/chat/completions" LM_STUDIO_MODEL = "qwen/qwen3.5-35b-a3b" SYSTEM_PROMPT = """You are a podcast script writer. Convert the given markdown content into a natural, engaging podcast conversation between two hosts: - Host A: Alex (curious, asks questions, provides context) - Host B: Jordan (expert, explains clearly, uses examples) Rules: 1. Keep every spoken line natural and conversational. 2. Do NOT include stage directions, sound effects, or music cues. 3. Format EVERY line exactly as: ALEX: JORDAN: 4. Start with a brief intro and end with a short outro. 5. Cover all major points from the markdown. 6. Target 5-10 minutes of spoken audio (~800-1400 words total).""" def generate_script(markdown_text: str, log_fn) -> str: log_fn("Sending markdown to LM-Studio ...") payload = { "model": LM_STUDIO_MODEL, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Convert this markdown to a podcast script:\n\n{markdown_text}"}, ], "temperature": 0.75, "max_tokens": 4096, } try: resp = requests.post(LM_STUDIO_URL, json=payload, timeout=300) resp.raise_for_status() data = resp.json() script = data["choices"][0]["message"]["content"] log_fn("Script generated.") return script except requests.exceptions.ConnectionError: raise RuntimeError( "Cannot reach LM-Studio at localhost:1234.\n" "Make sure LM-Studio is running and the local server is started." ) except Exception as exc: raise RuntimeError(f"LM-Studio error: {exc}") # ── Script parser ───────────────────────────────────────────────────────────── def parse_script(script: str) -> list: """Return list of (speaker, text) tuples.""" lines = [] for raw in script.splitlines(): raw = raw.strip() m = re.match(r"^(ALEX|JORDAN)\s*:\s*(.+)$", raw, re.IGNORECASE) if m: lines.append((m.group(1).upper(), m.group(2).strip())) return lines # ── TTS with Kokoro ─────────────────────────────────────────────────────────── VOICE_MAP = { "ALEX": "af_heart", # warm voice for Alex "JORDAN": "am_echo", # clear voice for Jordan } def _kokoro_pipeline(): """Import and return KPipeline — supports both kokoro 0.x and 1.x APIs.""" try: from kokoro import KPipeline return KPipeline except ImportError as e: # Provide a clear diagnostic instead of the generic message python_path = sys.executable raise RuntimeError( f"kokoro failed to import even after installation.\n\n" f"Python in use: {python_path}\n\n" f"Please run manually in your terminal:\n" f" {python_path} -m pip install kokoro\n\n" f"Then re-launch the app with the same Python:\n" f" {python_path} {os.path.abspath(__file__)}\n\n" f"Original error: {e}" ) def synthesise_audio(lines: list, out_path: str, log_fn): """Render each dialogue line with Kokoro and concatenate into one WAV.""" KPipeline = _kokoro_pipeline() log_fn("Initialising Kokoro TTS ...") sample_rate = 24000 silence_short = np.zeros(int(sample_rate * 0.35), dtype=np.float32) silence_long = np.zeros(int(sample_rate * 0.70), dtype=np.float32) pipelines = {} segments = [] for idx, (speaker, text) in enumerate(lines, 1): voice = VOICE_MAP.get(speaker, "af_heart") log_fn(f" [{idx}/{len(lines)}] {speaker}: {text[:65]}{'...' if len(text) > 65 else ''}") if voice not in pipelines: pipelines[voice] = KPipeline(lang_code="a") pipeline = pipelines[voice] audio_parts = [] # KPipeline.__call__ yields (graphemes, phonemes, audio_ndarray) for _, _, audio in pipeline(text, voice=voice, speed=1.0, split_pattern=r"\n+"): if audio is not None and len(audio) > 0: audio_parts.append(audio) if audio_parts: segments.append(np.concatenate(audio_parts)) segments.append(silence_short) # Longer pause when the speaker changes if idx < len(lines) and lines[idx - 1][0] != lines[idx][0]: segments.append(silence_long) if not segments: raise RuntimeError("No audio was synthesised — check the script format.") sf.write(out_path, np.concatenate(segments), sample_rate) log_fn(f"Audio saved: {out_path}") # ── Main application ────────────────────────────────────────────────────────── class PodcastApp(tk.Tk): def __init__(self): super().__init__() self.title("Markdown to Podcast") self.resizable(True, True) self.configure(bg="#1e1e2e") self._md_path = "" self._script = "" self._lines = [] self._build_ui() # ── UI ──────────────────────────────────────────────────────────────────── def _build_ui(self): BG = "#1e1e2e" FG = "#cdd6f4" ACC = "#89b4fa" BTN = dict(bg="#313244", fg=FG, activebackground="#45475a", activeforeground=FG, relief="flat", cursor="hand2", font=("Segoe UI", 10)) PAD = dict(padx=12, pady=5) tk.Label(self, text="Markdown to Podcast", bg=BG, fg=ACC, font=("Segoe UI", 16, "bold")).pack(pady=(16, 2)) tk.Label(self, text=f"Python: {sys.executable}", bg=BG, fg="#6c7086", font=("Segoe UI", 8)).pack(pady=(0, 10)) # File selection row = tk.Frame(self, bg=BG); row.pack(fill="x", **PAD) tk.Label(row, text="Markdown file:", bg=BG, fg=FG, font=("Segoe UI", 10)).pack(side="left") self._file_var = tk.StringVar(value="No file selected") tk.Label(row, textvariable=self._file_var, bg="#313244", fg=ACC, font=("Segoe UI", 9), relief="flat", padx=8, pady=4, width=44, anchor="w").pack(side="left", padx=8) tk.Button(row, text="Browse ...", command=self._browse, padx=10, **BTN).pack(side="left") # Output path row2 = tk.Frame(self, bg=BG); row2.pack(fill="x", **PAD) tk.Label(row2, text="Output audio:", bg=BG, fg=FG, font=("Segoe UI", 10)).pack(side="left") self._out_var = tk.StringVar(value="podcast_output.wav") tk.Entry(row2, textvariable=self._out_var, bg="#313244", fg=FG, insertbackground=FG, font=("Segoe UI", 9), relief="flat", width=40).pack(side="left", padx=8) tk.Button(row2, text="Save as ...", command=self._browse_out, padx=10, **BTN).pack(side="left") # LM-Studio URL row3 = tk.Frame(self, bg=BG); row3.pack(fill="x", **PAD) tk.Label(row3, text="LM-Studio URL:", bg=BG, fg=FG, font=("Segoe UI", 10)).pack(side="left") self._lm_url_var = tk.StringVar(value=LM_STUDIO_URL) tk.Entry(row3, textvariable=self._lm_url_var, bg="#313244", fg=FG, insertbackground=FG, font=("Segoe UI", 9), relief="flat", width=48).pack(side="left", padx=8) # Action buttons brow = tk.Frame(self, bg=BG); brow.pack(pady=10) self._btn_script = tk.Button(brow, text="1 Generate Script", command=self._run_script, padx=14, pady=6, state="disabled", **BTN) self._btn_script.pack(side="left", padx=6) self._btn_audio = tk.Button(brow, text="2 Synthesise Audio", command=self._run_audio, padx=14, pady=6, state="disabled", **BTN) self._btn_audio.pack(side="left", padx=6) self._btn_all = tk.Button(brow, text="Generate All", command=self._run_all, padx=14, pady=6, state="disabled", **BTN) self._btn_all.pack(side="left", padx=6) # Progress bar self._progress = ttk.Progressbar(self, mode="indeterminate", length=580) self._progress.pack(pady=(2, 0)) # Script preview tk.Label(self, text="Podcast Script Preview", bg=BG, fg=FG, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=14, pady=(10, 2)) self._script_box = scrolledtext.ScrolledText( self, height=14, bg="#181825", fg=FG, insertbackground=FG, font=("Consolas", 9), relief="flat", wrap="word") self._script_box.pack(fill="both", expand=True, padx=12, pady=(0, 4)) # Log tk.Label(self, text="Log", bg=BG, fg=FG, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=14, pady=(4, 2)) self._log_box = scrolledtext.ScrolledText( self, height=7, bg="#11111b", fg="#a6e3a1", font=("Consolas", 8), relief="flat", state="disabled") self._log_box.pack(fill="x", padx=12, pady=(0, 12)) self.geometry("720x780") # ── Dialogs ─────────────────────────────────────────────────────────────── def _browse(self): path = filedialog.askopenfilename( title="Select Markdown file", filetypes=[("Markdown", "*.md *.markdown"), ("All files", "*.*")]) if path: self._md_path = path self._file_var.set(os.path.basename(path)) base = os.path.splitext(os.path.basename(path))[0] self._out_var.set(f"{base}_podcast.wav") for b in (self._btn_script, self._btn_all): b.config(state="normal") self._log(f"Loaded: {path}") def _browse_out(self): path = filedialog.asksaveasfilename( title="Save audio as", defaultextension=".wav", filetypes=[("WAV audio", "*.wav"), ("All files", "*.*")]) if path: self._out_var.set(path) # ── Logging ─────────────────────────────────────────────────────────────── def _log(self, msg: str): self._log_box.config(state="normal") self._log_box.insert("end", msg + "\n") self._log_box.see("end") self._log_box.config(state="disabled") self.update_idletasks() # ── Busy state ──────────────────────────────────────────────────────────── def _set_busy(self, busy: bool): state = "disabled" if busy else "normal" for b in (self._btn_script, self._btn_audio, self._btn_all): b.config(state=state) if busy: self._progress.start(12) else: self._progress.stop() self._progress["value"] = 0 # ── Workers ─────────────────────────────────────────────────────────────── def _run_script(self): threading.Thread(target=self._worker_script, daemon=True).start() def _run_audio(self): threading.Thread(target=self._worker_audio, daemon=True).start() def _run_all(self): threading.Thread(target=self._worker_all, daemon=True).start() def _worker_script(self): self._set_busy(True) try: md = open(self._md_path, encoding="utf-8").read() global LM_STUDIO_URL LM_STUDIO_URL = self._lm_url_var.get().strip() self._script = generate_script(md, self._log) self._lines = parse_script(self._script) self._script_box.delete("1.0", "end") self._script_box.insert("end", self._script) self._log(f"{len(self._lines)} lines parsed.") self._btn_audio.config(state="normal") except Exception as exc: messagebox.showerror("Error", str(exc)) self._log(f"ERROR: {exc}") finally: self._set_busy(False) def _worker_audio(self): if not self._lines: messagebox.showwarning("No script", "Please generate the script first.") return self._set_busy(True) try: out = self._out_var.get().strip() or "podcast_output.wav" synthesise_audio(self._lines, out, self._log) messagebox.showinfo("Done!", f"Podcast saved to:\n{os.path.abspath(out)}") except Exception as exc: messagebox.showerror("TTS Error", str(exc)) self._log(f"ERROR: {exc}") finally: self._set_busy(False) def _worker_all(self): self._set_busy(True) try: md = open(self._md_path, encoding="utf-8").read() global LM_STUDIO_URL LM_STUDIO_URL = self._lm_url_var.get().strip() self._script = generate_script(md, self._log) self._lines = parse_script(self._script) self._script_box.delete("1.0", "end") self._script_box.insert("end", self._script) self._log(f"{len(self._lines)} lines parsed.") self._btn_audio.config(state="normal") out = self._out_var.get().strip() or "podcast_output.wav" synthesise_audio(self._lines, out, self._log) messagebox.showinfo("Done!", f"Podcast saved to:\n{os.path.abspath(out)}") except Exception as exc: messagebox.showerror("Error", str(exc)) self._log(f"ERROR: {exc}") finally: self._set_busy(False) # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": app = PodcastApp() app.mainloop()