diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ca04e33 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [ATECCA] +custom: ["https://opencollective.com/"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..172fe4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +*.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fa984a0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to NewSlide + +Thanks for contributing to NewSlide. + +## What to work on first + +- Look for issues labeled `good first issue`. +- Look for issues labeled `help wanted`. +- If you want to propose a larger change, open an issue first. + +## Development setup + +1. Install Python 3.14. +2. Install `ffmpeg` and ensure it is on your PATH. +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +## Project layout + +- `capture/` — slide capture and slide-change detection +- `transcribe/` — Whisper transcription and transcript segmentation +- `notes/` — SQLite lecture and note segment storage +- `merge/` — PPTX + note merge utilities +- `ui/` — CLI/UI layer + +## Coding style + +- Follow PEP 8. +- Add type hints for new code. +- Keep changes focused and small. +- Reuse existing module boundaries (`capture`, `transcribe`, `notes`, `merge`, `ui`). + +## Pull request checklist + +- [ ] Scope is focused and linked to an issue (or clearly explained). +- [ ] Documentation is updated when behavior changes. +- [ ] New/changed code is readable and typed. +- [ ] Local checks run successfully. + +## Labels used in this repository + +- `good first issue` — beginner-friendly tasks. +- `help wanted` — tasks open to external contributors. +- `bug` — incorrect behavior. +- `enhancement` — feature or improvement request. +- `documentation` — docs-only updates. diff --git a/README.md b/README.md index 6bedbc4..8d79537 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ # NewSlide -an app for students and lecturers to partner notes and collective extensions with other softwares. a visual aspect of learning + +NewSlide is a lecture-capture app prototype for students. It detects slide changes during a lecture, transcribes continuous audio, and stores slide-aligned note segments in SQLite. + +## Why NewSlide + +Most open-source lecture/meeting note tools segment by silence or fixed time windows. +NewSlide is built around a different boundary: **a new slide appearing on screen**. + +- slide-aware segmentation instead of pause-based chunking +- transcript slices aligned to presentation flow +- note structure mapped to what students saw while listening + +## Core mechanism + +1. Screen frames are sampled periodically. +2. Each frame gets a perceptual hash (`imagehash.phash`). +3. A slide boundary is detected when hash distance crosses a threshold. +4. Each boundary is stored as RTC wall-clock timestamp (`HH:MM:SS`). +5. Audio is transcribed with Whisper (or loaded from transcript JSON). +6. Transcript is split by slide-boundary timestamps into per-slide note segments. + +## Project structure + +- `/capture` — screen capture and slide-change detection +- `/transcribe` — Whisper integration and timestamp segmentation +- `/notes` — lecture + note-segment SQLite storage +- `/merge` — PPTX extraction + note merge/export helpers +- `/ui` — CLI interface +- `/main.py` — entrypoint + +## Requirements + +- Python 3.14 +- `ffmpeg` installed and available on PATH + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Workflow 1: capture + segment notes + +### A) Using Whisper audio transcription + +```bash +python main.py run \ + --course-code CS101 \ + --topic "Sorting Algorithms" \ + --topic-progress 0.35 \ + --audio /absolute/path/to/lecture_audio.wav \ + --sample-count 30 \ + --sample-interval 2 \ + --threshold 8 \ + --model base +``` + +### B) Using transcript JSON (portfolio/demo mode) + +```bash +python main.py run \ + --course-code CS101 \ + --topic "Sorting Algorithms" \ + --topic-progress 0.35 \ + --transcript-json /absolute/path/to/transcript_segments.json \ + --sample-count 30 \ + --sample-interval 2 \ + --threshold 8 +``` + +Transcript JSON may be either: +- a list of segments: `[{"start": 0.0, "end": 4.2, "text": "..."}]` +- or an object with `segments` key. + +This workflow creates a lecture record (`course_code`, auto-incremented lecture count, topic, progress), detects slide boundaries, segments transcript text, and stores notes in SQLite (`newslide.db` by default). + +## Workflow 2: merge PPTX + notes into markdown + +```bash +python main.py merge-markdown \ + --db newslide.db \ + --lecture-id 1 \ + --pptx /absolute/path/to/slides.pptx \ + --output /absolute/path/to/study_notes.md +``` + +This aligns extracted slide text with lecture note segments and exports a combined study document. + +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for contribution workflow, coding guidance, and labels. + +## Notes + +- Live capture uses `PIL.ImageGrab` in this prototype. +- The merge output currently exports Markdown. diff --git a/capture/__init__.py b/capture/__init__.py new file mode 100644 index 0000000..c10d3e7 --- /dev/null +++ b/capture/__init__.py @@ -0,0 +1 @@ +"""NewSlide package module.""" diff --git a/capture/slide_detector.py b/capture/slide_detector.py new file mode 100644 index 0000000..593edd7 --- /dev/null +++ b/capture/slide_detector.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Iterable + +import imagehash +from PIL import Image, ImageGrab + + +@dataclass(frozen=True) +class SlideChangeEvent: + """Represents a detected slide change boundary.""" + + rtc_timestamp: str + hash_distance: int + frame_index: int + + +class SlideChangeDetector: + """Detects slide changes using perceptual-hash frame differencing.""" + + def __init__(self, distance_threshold: int = 8) -> None: + self.distance_threshold = distance_threshold + + def detect_from_images( + self, + image_paths: Iterable[Path], + recording_start_rtc: str, + sample_interval_seconds: float, + ) -> list[SlideChangeEvent]: + events: list[SlideChangeEvent] = [] + previous_hash = None + + for frame_index, image_path in enumerate(image_paths): + with Image.open(image_path) as image: + current_hash = self._phash(image) + if previous_hash is None: + previous_hash = current_hash + continue + + hash_distance = previous_hash - current_hash + if hash_distance >= self.distance_threshold: + events.append( + SlideChangeEvent( + rtc_timestamp=_rtc_at_offset(recording_start_rtc, frame_index * sample_interval_seconds), + hash_distance=hash_distance, + frame_index=frame_index, + ) + ) + previous_hash = current_hash + + return events + + def capture_frame(self) -> Image.Image: + """Capture the current primary screen frame.""" + return ImageGrab.grab() + + def detect_live( + self, + sample_count: int, + sample_interval_seconds: float, + recording_start_rtc: str, + ) -> list[SlideChangeEvent]: + """Sample live screen frames and return detected slide changes.""" + import time + + events: list[SlideChangeEvent] = [] + previous_hash = None + + for frame_index in range(sample_count): + frame = self.capture_frame() + current_hash = self._phash(frame) + + if previous_hash is not None: + hash_distance = previous_hash - current_hash + if hash_distance >= self.distance_threshold: + events.append( + SlideChangeEvent( + rtc_timestamp=_rtc_at_offset(recording_start_rtc, frame_index * sample_interval_seconds), + hash_distance=hash_distance, + frame_index=frame_index, + ) + ) + + previous_hash = current_hash + if frame_index < sample_count - 1: + time.sleep(sample_interval_seconds) + + return events + + @staticmethod + def _phash(image: Image.Image) -> imagehash.ImageHash: + return imagehash.phash(image) + + +def _rtc_at_offset(recording_start_rtc: str, seconds_offset: float) -> str: + start_time = datetime.strptime(recording_start_rtc, "%H:%M:%S") + next_time = start_time + timedelta(seconds=seconds_offset) + return next_time.strftime("%H:%M:%S") diff --git a/main.py b/main.py new file mode 100644 index 0000000..2eec0c7 --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +from ui.cli import run + + +if __name__ == "__main__": + run() diff --git a/merge/__init__.py b/merge/__init__.py new file mode 100644 index 0000000..c10d3e7 --- /dev/null +++ b/merge/__init__.py @@ -0,0 +1 @@ +"""NewSlide package module.""" diff --git a/merge/pptx_merge.py b/merge/pptx_merge.py new file mode 100644 index 0000000..9a1844c --- /dev/null +++ b/merge/pptx_merge.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from notes.storage import NoteSegment +from pptx import Presentation + + +@dataclass(frozen=True) +class MergedSlideSegment: + slide_number: int + slide_text: str + start_rtc: str + end_rtc: str | None + note_text: str + + +def extract_slide_text(pptx_path: Path) -> list[str]: + presentation = Presentation(str(pptx_path)) + slide_text: list[str] = [] + + for slide in presentation.slides: + chunks: list[str] = [] + for shape in slide.shapes: + if hasattr(shape, "text") and shape.text: + chunks.append(shape.text.strip()) + slide_text.append("\n".join(chunk for chunk in chunks if chunk)) + + return slide_text + + +def align_slides_with_notes(slide_texts: list[str], note_segments: list[NoteSegment]) -> list[MergedSlideSegment]: + merged: list[MergedSlideSegment] = [] + + for index, note_segment in enumerate(note_segments): + slide_text = slide_texts[index] if index < len(slide_texts) else "" + end_rtc = note_segments[index + 1].rtc_timestamp if index + 1 < len(note_segments) else None + + merged.append( + MergedSlideSegment( + slide_number=index + 1, + slide_text=slide_text, + start_rtc=note_segment.rtc_timestamp, + end_rtc=end_rtc, + note_text=note_segment.content, + ) + ) + + return merged + + +def export_merged_markdown(merged_segments: list[MergedSlideSegment], output_path: Path, title: str) -> Path: + lines: list[str] = [f"# {title}", ""] + + for segment in merged_segments: + lines.append(f"## Slide {segment.slide_number}") + if segment.end_rtc is None: + lines.append(f"**Time Range:** {segment.start_rtc} onward") + else: + lines.append(f"**Time Range:** {segment.start_rtc} - {segment.end_rtc}") + lines.append("") + + if segment.slide_text: + lines.append("### Slide Text") + lines.append(segment.slide_text) + lines.append("") + + lines.append("### Notes") + lines.append(segment.note_text if segment.note_text else "(No transcript text in this segment)") + lines.append("") + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + return output_path diff --git a/notes/__init__.py b/notes/__init__.py new file mode 100644 index 0000000..c10d3e7 --- /dev/null +++ b/notes/__init__.py @@ -0,0 +1 @@ +"""NewSlide package module.""" diff --git a/notes/storage.py b/notes/storage.py new file mode 100644 index 0000000..850f96e --- /dev/null +++ b/notes/storage.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Lecture: + id: int + course_code: str + lecture_count: int + topic: str + topic_progress: float + + +@dataclass(frozen=True) +class NoteSegment: + id: int + lecture_id: int + rtc_timestamp: str + content: str + + +class NotesRepository: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.db_path) + connection.row_factory = sqlite3.Row + return connection + + def _initialize(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS lectures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_code TEXT NOT NULL, + lecture_count INTEGER NOT NULL, + topic TEXT NOT NULL, + topic_progress REAL NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS note_segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lecture_id INTEGER NOT NULL, + rtc_timestamp TEXT NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (lecture_id) REFERENCES lectures(id) + ) + """ + ) + + def create_lecture(self, course_code: str, topic: str, topic_progress: float) -> Lecture: + lecture_count = self._next_lecture_count(course_code) + + with self._connect() as conn: + cursor = conn.execute( + """ + INSERT INTO lectures (course_code, lecture_count, topic, topic_progress) + VALUES (?, ?, ?, ?) + """, + (course_code, lecture_count, topic, topic_progress), + ) + lecture_id = int(cursor.lastrowid) + + return Lecture( + id=lecture_id, + course_code=course_code, + lecture_count=lecture_count, + topic=topic, + topic_progress=topic_progress, + ) + + def get_lecture(self, lecture_id: int) -> Lecture | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT id, course_code, lecture_count, topic, topic_progress + FROM lectures + WHERE id = ? + """, + (lecture_id,), + ).fetchone() + + if row is None: + return None + + return Lecture( + id=row["id"], + course_code=row["course_code"], + lecture_count=row["lecture_count"], + topic=row["topic"], + topic_progress=row["topic_progress"], + ) + + def list_lectures(self) -> list[Lecture]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, course_code, lecture_count, topic, topic_progress + FROM lectures + ORDER BY id DESC + """ + ).fetchall() + + return [ + Lecture( + id=row["id"], + course_code=row["course_code"], + lecture_count=row["lecture_count"], + topic=row["topic"], + topic_progress=row["topic_progress"], + ) + for row in rows + ] + + def add_note_segment(self, lecture_id: int, rtc_timestamp: str, content: str) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO note_segments (lecture_id, rtc_timestamp, content) + VALUES (?, ?, ?) + """, + (lecture_id, rtc_timestamp, content), + ) + + def list_note_segments(self, lecture_id: int) -> list[NoteSegment]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, lecture_id, rtc_timestamp, content + FROM note_segments + WHERE lecture_id = ? + ORDER BY id ASC + """, + (lecture_id,), + ).fetchall() + + return [ + NoteSegment( + id=row["id"], + lecture_id=row["lecture_id"], + rtc_timestamp=row["rtc_timestamp"], + content=row["content"], + ) + for row in rows + ] + + def _next_lecture_count(self, course_code: str) -> int: + with self._connect() as conn: + row = conn.execute( + """ + SELECT COALESCE(MAX(lecture_count), 0) AS max_count + FROM lectures + WHERE course_code = ? + """, + (course_code,), + ).fetchone() + + return int(row["max_count"]) + 1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0d3d9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +imagehash==4.3.1 +openai-whisper==20250625 +Pillow==11.3.0 +python-pptx==1.0.2 +numpy==2.3.2 +torch==2.8.0 diff --git a/transcribe/__init__.py b/transcribe/__init__.py new file mode 100644 index 0000000..c10d3e7 --- /dev/null +++ b/transcribe/__init__.py @@ -0,0 +1 @@ +"""NewSlide package module.""" diff --git a/transcribe/whisper_segmenter.py b/transcribe/whisper_segmenter.py new file mode 100644 index 0000000..e8e2e54 --- /dev/null +++ b/transcribe/whisper_segmenter.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class SegmentedNote: + slide_index: int + start_rtc: str + end_rtc: str | None + text: str + + +def transcribe_audio(audio_path: Path, model_name: str = "base") -> list[dict[str, Any]]: + """Run Whisper on an audio file and return model segments.""" + try: + import whisper + except ImportError as exc: + raise RuntimeError( + "openai-whisper is not available. Install dependencies or use --transcript-json." + ) from exc + + model = whisper.load_model(model_name) + result = model.transcribe(str(audio_path)) + return _normalize_segments(result.get("segments", [])) + + +def load_transcript_segments(transcript_json_path: Path) -> list[dict[str, Any]]: + """Load transcription segments from a JSON file for offline/demo workflows.""" + payload = json.loads(transcript_json_path.read_text(encoding="utf-8")) + + if isinstance(payload, dict): + raw_segments = payload.get("segments", []) + elif isinstance(payload, list): + raw_segments = payload + else: + raise ValueError("Transcript JSON must be a list of segments or object with a 'segments' key") + + return _normalize_segments(raw_segments) + + +def split_transcript_by_slide_changes( + whisper_segments: list[dict[str, Any]], + recording_start_rtc: str, + slide_change_rtc_boundaries: list[str], +) -> list[SegmentedNote]: + """Split a continuous transcript into slide-aligned note segments.""" + offsets = sorted( + { + _seconds_since_start(recording_start_rtc, boundary) + for boundary in slide_change_rtc_boundaries + } + ) + + note_ranges = [0.0, *offsets] + segmented_notes: list[SegmentedNote] = [] + + for slide_index, start_offset in enumerate(note_ranges): + end_offset = note_ranges[slide_index + 1] if slide_index + 1 < len(note_ranges) else None + merged_text = _collect_text_in_window(whisper_segments, start_offset, end_offset) + segmented_notes.append( + SegmentedNote( + slide_index=slide_index, + start_rtc=_seconds_to_rtc(recording_start_rtc, start_offset), + end_rtc=_seconds_to_rtc(recording_start_rtc, end_offset) if end_offset is not None else None, + text=merged_text, + ) + ) + + return segmented_notes + + +def _normalize_segments(raw_segments: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for segment in raw_segments: + normalized.append( + { + "start": float(segment.get("start", 0.0)), + "end": float(segment.get("end", 0.0)), + "text": str(segment.get("text", "")).strip(), + } + ) + return normalized + + +def _collect_text_in_window( + whisper_segments: list[dict[str, Any]], + start_offset: float, + end_offset: float | None, +) -> str: + text_parts: list[str] = [] + + for segment in whisper_segments: + segment_start = float(segment.get("start", 0.0)) + if segment_start < start_offset: + continue + if end_offset is not None and segment_start >= end_offset: + continue + + text_parts.append(str(segment.get("text", "")).strip()) + + return " ".join(part for part in text_parts if part).strip() + + +def _seconds_since_start(start_rtc: str, event_rtc: str) -> float: + start_time = datetime.strptime(start_rtc, "%H:%M:%S") + event_time = datetime.strptime(event_rtc, "%H:%M:%S") + + delta = (event_time - start_time).total_seconds() + if delta < 0: + delta += 24 * 60 * 60 + return delta + + +def _seconds_to_rtc(start_rtc: str, seconds_offset: float | None) -> str: + if seconds_offset is None: + raise ValueError("seconds_offset cannot be None") + + start_time = datetime.strptime(start_rtc, "%H:%M:%S") + timestamp = start_time + timedelta(seconds=seconds_offset) + return timestamp.strftime("%H:%M:%S") diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..c10d3e7 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +"""NewSlide package module.""" diff --git a/ui/cli.py b/ui/cli.py new file mode 100644 index 0000000..6260542 --- /dev/null +++ b/ui/cli.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import argparse +from datetime import datetime +from pathlib import Path + +from notes.storage import NotesRepository + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="NewSlide lecture capture prototype") + subparsers = parser.add_subparsers(dest="command", required=True) + + run_parser = subparsers.add_parser("run", help="Capture slide boundaries and create lecture notes") + run_parser.add_argument("--db", type=Path, default=Path("newslide.db"), help="SQLite database path") + run_parser.add_argument("--course-code", required=True, help="Course code (e.g. CS101)") + run_parser.add_argument("--topic", required=True, help="Current lecture topic") + run_parser.add_argument( + "--topic-progress", + type=float, + default=0.0, + help="Progress through the course topic list (0.0-1.0)", + ) + run_parser.add_argument("--audio", type=Path, help="Path to recorded lecture audio file") + run_parser.add_argument( + "--transcript-json", + type=Path, + help="Optional transcript segments JSON file (alternative to Whisper transcription)", + ) + run_parser.add_argument( + "--sample-count", + type=int, + default=10, + help="How many screen snapshots to analyze for slide changes", + ) + run_parser.add_argument( + "--sample-interval", + type=float, + default=2.0, + help="Seconds between screen captures", + ) + run_parser.add_argument( + "--threshold", + type=int, + default=8, + help="Perceptual hash distance threshold for slide change", + ) + run_parser.add_argument( + "--model", + default="base", + help="Whisper model name (tiny/base/small/medium/large)", + ) + + merge_parser = subparsers.add_parser("merge-markdown", help="Merge lecture notes with PPTX and export markdown") + merge_parser.add_argument("--db", type=Path, default=Path("newslide.db"), help="SQLite database path") + merge_parser.add_argument("--lecture-id", type=int, required=True, help="Lecture id to export") + merge_parser.add_argument("--pptx", type=Path, required=True, help="PPTX file path") + merge_parser.add_argument("--output", type=Path, required=True, help="Output markdown file path") + + return parser + + +def run() -> None: + args = build_parser().parse_args() + + if args.command == "run": + _run_capture_and_segment(args) + return + if args.command == "merge-markdown": + _run_merge_markdown(args) + return + + raise ValueError(f"Unsupported command: {args.command}") + + +def _run_capture_and_segment(args: argparse.Namespace) -> None: + from capture.slide_detector import SlideChangeDetector + from transcribe.whisper_segmenter import ( + load_transcript_segments, + split_transcript_by_slide_changes, + transcribe_audio, + ) + + _validate_transcription_inputs(args.audio, args.transcript_json) + + repository = NotesRepository(args.db) + lecture = repository.create_lecture( + course_code=args.course_code, + topic=args.topic, + topic_progress=max(0.0, min(args.topic_progress, 1.0)), + ) + + start_rtc = datetime.now().strftime("%H:%M:%S") + detector = SlideChangeDetector(distance_threshold=args.threshold) + events = detector.detect_live( + sample_count=args.sample_count, + sample_interval_seconds=args.sample_interval, + recording_start_rtc=start_rtc, + ) + + if args.transcript_json is not None: + whisper_segments = load_transcript_segments(args.transcript_json) + else: + if args.audio is None: + raise ValueError("Provide --audio or --transcript-json") + whisper_segments = transcribe_audio(args.audio, model_name=args.model) + + segmented_notes = split_transcript_by_slide_changes( + whisper_segments=whisper_segments, + recording_start_rtc=start_rtc, + slide_change_rtc_boundaries=[event.rtc_timestamp for event in events], + ) + + for note in segmented_notes: + repository.add_note_segment( + lecture_id=lecture.id, + rtc_timestamp=note.start_rtc, + content=note.text, + ) + + print(f"Lecture recorded: {lecture.course_code} L{lecture.lecture_count} - {lecture.topic}") + print(f"Topic progress: {lecture.topic_progress:.0%}") + print(f"Lecture id: {lecture.id}") + print("\nDetected slide boundaries:") + for event in events: + print(f"- frame={event.frame_index} rtc={event.rtc_timestamp} distance={event.hash_distance}") + + print("\nSegmented notes:") + for segment in repository.list_note_segments(lecture.id): + print(f"[{segment.rtc_timestamp}] {segment.content}") + + +def _run_merge_markdown(args: argparse.Namespace) -> None: + from merge.pptx_merge import align_slides_with_notes, export_merged_markdown, extract_slide_text + + repository = NotesRepository(args.db) + lecture = repository.get_lecture(args.lecture_id) + if lecture is None: + raise ValueError(f"Lecture id {args.lecture_id} was not found in {args.db}") + + note_segments = repository.list_note_segments(lecture.id) + if not note_segments: + raise ValueError(f"Lecture id {args.lecture_id} has no note segments to export") + + slide_texts = extract_slide_text(args.pptx) + merged = align_slides_with_notes(slide_texts, note_segments) + title = f"{lecture.course_code} L{lecture.lecture_count}: {lecture.topic}" + output_path = export_merged_markdown(merged, args.output, title) + + print(f"Exported merged notes to {output_path}") + + +def _validate_transcription_inputs(audio_path: Path | None, transcript_json: Path | None) -> None: + if audio_path is None and transcript_json is None: + raise ValueError("Provide at least one transcription source: --audio or --transcript-json") + if transcript_json is not None and not transcript_json.exists(): + raise ValueError(f"Transcript JSON not found: {transcript_json}") + if audio_path is not None and not audio_path.exists(): + raise ValueError(f"Audio file not found: {audio_path}") + + +if __name__ == "__main__": + run()