Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ jobs:
node-version: '20'
- name: Build studio bundle
run: |
npm version --no-git-tag-version --allow-same-version "${GITHUB_REF_NAME#v}"
npm ci
sh scripts/build-studio.sh dist/studio
tar -czf studio-bundle.tar.gz -C dist/studio .
Expand Down
22 changes: 17 additions & 5 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import sys
import textwrap
import time
from version import VERSION

# Windows stdout/stderr default to cp1252, which can't encode chars like '→'; output is UTF-8.
for _stream in (sys.stdout, sys.stderr):
Expand Down Expand Up @@ -360,6 +361,11 @@ def cmd_studio(args):
cmd += ["--paragraph", args.paragraph]
if args.language:
cmd += ["--language", args.language]
if getattr(args, "engine", None):
cmd += ["--engine", args.engine]
env = os.environ.copy()
if getattr(args, "assemblyai_api_key", None):
env["ASSEMBLYAI_API_KEY"] = args.assemblyai_api_key
cmd += [
"--caption-style", args.caption_style,
"--crop", args.crop,
Expand All @@ -386,7 +392,7 @@ def cmd_studio(args):
cmd += ["--no-outro"]

import subprocess
rc = subprocess.run(cmd).returncode
rc = subprocess.run(cmd, env=env).returncode
sys.exit(rc)


Expand Down Expand Up @@ -437,6 +443,8 @@ def cmd_process(args):
# CLI overrides
if getattr(args, "engine", None):
os.environ["PODCLI_ENGINE"] = args.engine
if getattr(args, "assemblyai_api_key", None):
os.environ["ASSEMBLYAI_API_KEY"] = args.assemblyai_api_key
if args.caption_style:
config["caption_style"] = args.caption_style
if args.crop:
Expand Down Expand Up @@ -585,7 +593,9 @@ def cmd_process(args):
result = cached
print(f" {len(segments)} segments, {len(words)} words")
else:
print(" [1/4] Transcribing with Whisper...")
from services.engines import is_assemblyai_engine
engine_label = "AssemblyAI" if is_assemblyai_engine(os.environ.get("PODCLI_ENGINE", "")) else "Whisper"
print(f" [1/4] Transcribing with {engine_label}...")
_ensure_ssl_certs()
import warnings
warnings.filterwarnings("ignore", message="FP16 is not supported on CPU")
Expand Down Expand Up @@ -615,6 +625,7 @@ def _transcribe_progress(pct, msg):
result = transcribe_file(
file_path=video_path,
model_size=config.get("whisper_model", "base"),
engine=os.environ.get("PODCLI_ENGINE") or None,
enable_diarization=not config.get("no_speakers", False),
progress_callback=_transcribe_progress,
)
Expand Down Expand Up @@ -3095,8 +3106,6 @@ def cmd_info(args):
print()


VERSION = "1.0.0"

BANNER = """
\033[38;2;212;135;74m ┌─────────────────────────────────────┐
│ │
Expand Down Expand Up @@ -3309,7 +3318,8 @@ def main():
proc.add_argument("-n", "--top", type=int, help="Number of top clips to export (default: 5)")
proc.add_argument("-o", "--output", help="Output directory (default: ./clips)")
proc.add_argument("-p", "--preset", help="Load a saved preset")
proc.add_argument("--engine", choices=["whisper-py", "whispercpp"], help="Transcription engine (default: whisper-py; whispercpp is the native, PyTorch-free path)")
proc.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], help="Transcription engine (default: whisper-py; whispercpp is local; assemblyai uses ASSEMBLYAI_API_KEY)")
proc.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai. Prefer ASSEMBLYAI_API_KEY; command-line secrets can appear in process listings.")
proc.add_argument("--fast", action="store_true", help="Draft mode: tiny Whisper, heuristic selection, center crop, low quality")
proc.add_argument("--thumbnails", dest="thumbnails", action="store_true", default=None, help="Force thumbnail generation on")
proc.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", help="Skip thumbnail generation")
Expand All @@ -3335,6 +3345,8 @@ def main():
studio.add_argument("--end", type=float, help="Fragment end (seconds)")
studio.add_argument("--paragraph", help="Find the fragment by matching this text in the transcript")
studio.add_argument("--language", help="Transcription language (e.g. es). Auto-detect if omitted.")
studio.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], help="Transcription engine")
studio.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai. Prefer ASSEMBLYAI_API_KEY; command-line secrets can appear in process listings.")
studio.add_argument("--caption-style", choices=["hormozi", "karaoke", "subtle", "branded"], default="hormozi")
studio.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"], default="face")
studio.add_argument("-o", "--output", help="Final output path")
Expand Down
11 changes: 7 additions & 4 deletions backend/clip_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,14 @@ def _probe_duration(path: str) -> float:
return 0.0


def _transcribe(video: str, language: str | None):
def _transcribe(video: str, language: str | None, engine: str | None):
"""Transcribe (cached) and return the word list."""
from services.engines import is_assemblyai_engine
from services.transcription import transcribe_file
print(" [transcribe] running Whisper...", flush=True)
label = "AssemblyAI" if is_assemblyai_engine(engine or os.environ.get("PODCLI_ENGINE", "")) else "Whisper"
print(f" [transcribe] running {label}...", flush=True)
res = transcribe_file(
file_path=video, model_size="base", language=language,
file_path=video, model_size="base", engine=engine, language=language,
enable_diarization=False,
progress_callback=lambda p, m: print(f" {p}% {m}", flush=True),
)
Expand Down Expand Up @@ -199,6 +201,7 @@ def main():
ap.add_argument("--end", type=float, help="Fragment end (seconds)")
ap.add_argument("--paragraph", help="Find fragment by matching this text in the transcript")
ap.add_argument("--language", default=None, help="Transcription language (e.g. es). Auto-detect if omitted.")
ap.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], default=None, help="Transcription engine")
ap.add_argument("--caption-style", default="hormozi", choices=["hormozi", "karaoke", "subtle", "branded"])
ap.add_argument("--crop", default="face", choices=["center", "face", "speaker", "speaker-hardcut"])
ap.add_argument("--output", default=None, help="Final output path")
Expand Down Expand Up @@ -244,7 +247,7 @@ def main():
os.makedirs(out_dir, exist_ok=True)

# Need a transcript if cutting by paragraph or if rendering captions.
words = _transcribe(video, args.language)
words = _transcribe(video, args.language, args.engine)

if args.paragraph:
start, end = _find_paragraph(words, args.paragraph)
Expand Down
42 changes: 31 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
except ImportError:
pass
import traceback
from version import VERSION


def emit_progress(task_id: str, stage: str, percent: int, message: str, **extra):
"""Write a progress event to stderr (picked up by TypeScript executor)."""
Expand Down Expand Up @@ -53,25 +55,43 @@ def emit_result(task_id: str, status: str, data=None, error=None):

def handle_ping(task_id: str, params: dict):
"""Simple health check."""
emit_result(task_id, "success", data={"message": "pong", "version": "1.0.0"})
emit_result(task_id, "success", data={"message": "pong", "version": VERSION})


def handle_transcribe(task_id: str, params: dict):
"""Transcribe a podcast video/audio file with speaker detection."""
from services.transcription import transcribe_file
from services.corrections import apply_corrections
from services.transcript_packer import compute_cache_hash, write_packed
from services.transcript_packer import compute_cache_hash, engine_cache_suffix, write_packed

emit_progress(task_id, "transcribing", 0, "Starting transcription...")
file_path = params["file_path"]
result = transcribe_file(
file_path=file_path,
model_size=params.get("model_size", "base"),
language=params.get("language"),
enable_diarization=params.get("enable_diarization", True),
num_speakers=params.get("num_speakers"),
progress_callback=lambda pct, msg: emit_progress(task_id, "transcribing", pct, msg),
)
engine = params.get("engine")
previous_engine = os.environ.get("PODCLI_ENGINE")
previous_assemblyai_key = os.environ.get("ASSEMBLYAI_API_KEY")
if engine:
os.environ["PODCLI_ENGINE"] = engine
if params.get("assemblyai_api_key"):
os.environ["ASSEMBLYAI_API_KEY"] = params["assemblyai_api_key"]
try:
result = transcribe_file(
file_path=file_path,
model_size=params.get("model_size", "base"),
engine=engine,
language=params.get("language"),
enable_diarization=params.get("enable_diarization", True),
num_speakers=params.get("num_speakers"),
progress_callback=lambda pct, msg: emit_progress(task_id, "transcribing", pct, msg),
)
finally:
if previous_engine is None:
os.environ.pop("PODCLI_ENGINE", None)
else:
os.environ["PODCLI_ENGINE"] = previous_engine
if previous_assemblyai_key is None:
os.environ.pop("ASSEMBLYAI_API_KEY", None)
else:
os.environ["ASSEMBLYAI_API_KEY"] = previous_assemblyai_key
# Apply word corrections (Whisper misheard proper nouns)
apply_corrections(result.get("words", []), result.get("segments", []))

Expand All @@ -80,7 +100,7 @@ def handle_transcribe(task_id: str, params: dict):
try:
from services.audio_analyzer import extract_audio_energy

cache_hash = compute_cache_hash(file_path)
cache_hash = compute_cache_hash(file_path) + engine_cache_suffix(result.get("engine") or engine)
energy_data = None
try:
energy_data = extract_audio_energy(file_path)
Expand Down
8 changes: 5 additions & 3 deletions backend/services/content_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import os
import subprocess
import sys
import tempfile
import threading
from typing import Optional, Callable
Expand Down Expand Up @@ -234,7 +235,8 @@ def generate_custom_content(
project_dir=project_dir,
timeout=120,
)
except Exception:
except Exception as exc:
print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr)
continue
if cr.returncode != 0 or not cr.stdout.strip():
continue
Expand All @@ -245,8 +247,8 @@ def generate_custom_content(
finally:
try:
os.unlink(prompt_file)
except Exception:
pass
except Exception as exc:
print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr)


def generate_clip_content(
Expand Down
11 changes: 11 additions & 0 deletions backend/services/engines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
def normalize_engine(name: str | None) -> str:
value = (name or "whisper-py").strip().lower()
if value in ("whispercpp", "whisper-cpp", "whisper.cpp", "cpp"):
return "whispercpp"
if value in ("assemblyai", "assembly-ai", "aai"):
return "assemblyai"
return "whisper-py"


def is_assemblyai_engine(name: str | None) -> bool:
return normalize_engine(name) == "assemblyai"
8 changes: 8 additions & 0 deletions backend/services/env_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
"url": "https://huggingface.co/settings/tokens",
"secret": True,
},
{
"key": "ASSEMBLYAI_API_KEY",
"label": "AssemblyAI API key",
"help": "Enables AssemblyAI transcription in the Studio and CLI.",
"url": "https://www.assemblyai.com/dashboard/activation",
"secret": True,
"placeholder": "aai_...",
},
{
"key": "PODCLI_CLAUDE_PATH",
"label": "Claude Code CLI path",
Expand Down
11 changes: 9 additions & 2 deletions backend/services/transcript_packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing import Any, Optional

from config.paths import paths
from services.engines import normalize_engine

def _transcripts_cache_dir() -> str:
return paths["transcripts"]
Expand Down Expand Up @@ -66,9 +67,15 @@ def _engine_cache_suffix() -> str:
"""Namespace the cache by engine so a whisper.cpp run doesn't reuse a
whisper-py transcript (their timings/word splits differ). whisper-py keeps
the bare filename, which the TS transcript cache also writes."""
engine = os.environ.get("PODCLI_ENGINE", "whisper-py").strip().lower()
if engine in ("whispercpp", "whisper-cpp", "whisper.cpp", "cpp"):
return engine_cache_suffix(os.environ.get("PODCLI_ENGINE", "whisper-py"))


def engine_cache_suffix(engine: str | None) -> str:
engine = normalize_engine(engine)
if engine == "whispercpp":
return "-whispercpp"
if engine == "assemblyai":
return "-assemblyai"
return ""


Expand Down
Loading
Loading