From 8ec53e2f132c70d514306e40392264d506833051 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:05:27 +0000 Subject: [PATCH 1/5] Scaffold core NewSlide prototype modules --- README.md | 58 ++++++++++++++- capture/__init__.py | 1 + capture/slide_detector.py | 88 ++++++++++++++++++++++ main.py | 5 ++ merge/__init__.py | 1 + merge/pptx_merge.py | 19 +++++ notes/__init__.py | 1 + notes/storage.py | 125 ++++++++++++++++++++++++++++++++ requirements.txt | 6 ++ transcribe/__init__.py | 1 + transcribe/whisper_segmenter.py | 92 +++++++++++++++++++++++ ui/__init__.py | 1 + ui/cli.py | 95 ++++++++++++++++++++++++ 13 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 capture/__init__.py create mode 100644 capture/slide_detector.py create mode 100644 main.py create mode 100644 merge/__init__.py create mode 100644 merge/pptx_merge.py create mode 100644 notes/__init__.py create mode 100644 notes/storage.py create mode 100644 requirements.txt create mode 100644 transcribe/__init__.py create mode 100644 transcribe/whisper_segmenter.py create mode 100644 ui/__init__.py create mode 100644 ui/cli.py diff --git a/README.md b/README.md index 6bedbc4..3b11297 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ # 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 prototype for students. It detects slide changes during a lecture, transcribes continuous audio with Whisper, and stores slide-aligned note segments in SQLite. + +## Core mechanism (prototype) + +1. Screen frames are sampled periodically. +2. Each frame gets a perceptual hash (`imagehash.phash`). +3. A new slide boundary is detected when hash distance crosses a threshold. +4. Each boundary is saved as an RTC wall-clock timestamp (`HH:MM:SS`). +5. Whisper transcribes the full lecture audio. +6. Transcript segments are split by slide-boundary timestamps to produce per-slide notes. + +## Project structure + +- `/capture` — slide capture and change detection +- `/transcribe` — Whisper integration and transcript segmentation +- `/notes` — lecture + note-segment SQLite storage +- `/merge` — PPTX text extraction for note merge workflows +- `/ui` — CLI prototype + +## Requirements + +- Python 3.14 +- `ffmpeg` installed and available on PATH + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Usage (CLI prototype) + +```bash +python main.py \ + --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 +``` + +This will: + +- create a lecture record (`course_code`, auto-incremented lecture count, topic, topic progress) +- detect slide boundaries from live screen captures +- transcribe audio with Whisper +- split transcript into slide-aligned segments +- save note segments to SQLite (`newslide.db` by default) + +## Notes + +- Screen capture uses `PIL.ImageGrab` in this prototype. +- The `/merge` module currently includes PPTX slide-text extraction; full timestamp alignment/export can be built on top of it. 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..afdde53 --- /dev/null +++ b/capture/slide_detector.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +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]) -> list[SlideChangeEvent]: + events: list[SlideChangeEvent] = [] + previous_hash = None + + for frame_index, image_path in enumerate(image_paths): + current_hash = self._phash(Image.open(image_path)) + 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=datetime.now().strftime("%H:%M:%S"), + 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, + ) -> 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=datetime.now().strftime("%H:%M:%S"), + 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) 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..64002a3 --- /dev/null +++ b/merge/pptx_merge.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation + + +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 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..e70dd05 --- /dev/null +++ b/notes/storage.py @@ -0,0 +1,125 @@ +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 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..f3d1ccc --- /dev/null +++ b/transcribe/whisper_segmenter.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +import whisper + + +@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.""" + model = whisper.load_model(model_name) + result = model.transcribe(str(audio_path)) + return result.get("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 _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.timestamp() + seconds_offset + return datetime.fromtimestamp(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..6c5031f --- /dev/null +++ b/ui/cli.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import argparse +from datetime import datetime +from pathlib import Path + +from capture.slide_detector import SlideChangeDetector +from notes.storage import NotesRepository +from transcribe.whisper_segmenter import split_transcript_by_slide_changes, transcribe_audio + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="NewSlide lecture capture prototype") + + parser.add_argument("--db", type=Path, default=Path("newslide.db"), help="SQLite database path") + parser.add_argument("--course-code", required=True, help="Course code (e.g. CS101)") + parser.add_argument("--topic", required=True, help="Current lecture topic") + parser.add_argument( + "--topic-progress", + type=float, + default=0.0, + help="Progress through the course topic list (0.0-1.0)", + ) + parser.add_argument("--audio", type=Path, required=True, help="Path to recorded lecture audio file") + parser.add_argument( + "--sample-count", + type=int, + default=10, + help="How many screen snapshots to analyze for slide changes", + ) + parser.add_argument( + "--sample-interval", + type=float, + default=2.0, + help="Seconds between screen captures", + ) + parser.add_argument( + "--threshold", + type=int, + default=8, + help="Perceptual hash distance threshold for slide change", + ) + parser.add_argument( + "--model", + default="base", + help="Whisper model name (tiny/base/small/medium/large)", + ) + + return parser + + +def run() -> None: + args = build_parser().parse_args() + + 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, + ) + + 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("\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}") + + +if __name__ == "__main__": + run() From c688da0d47e906865dc71f201bc88e7d8523808f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:06:33 +0000 Subject: [PATCH 2/5] Ignore Python cache artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore 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 From f384043b8626d0a5e6927e59b2e988c686a2ec81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:13:03 +0000 Subject: [PATCH 3/5] Add funding and contributing docs --- .github/FUNDING.yml | 2 ++ CONTRIBUTING.md | 48 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 15 ++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 CONTRIBUTING.md 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/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 3b11297..9fc0798 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ NewSlide is a lecture-capture prototype for students. It detects slide changes during a lecture, transcribes continuous audio with Whisper, 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**. + +That gives NewSlide a clear positioning: + +- slide-aware segmentation instead of pause-based chunking +- transcript slices naturally aligned to presentation flow +- note structure that maps directly to what students saw while listening + ## Core mechanism (prototype) 1. Screen frames are sampled periodically. @@ -52,6 +63,10 @@ This will: - split transcript into slide-aligned segments - save note segments to SQLite (`newslide.db` by default) +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for contribution workflow, coding guidance, and labels. + ## Notes - Screen capture uses `PIL.ImageGrab` in this prototype. From c5ecbf940bbdde25747525226cffec8c2fef5130 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:20:10 +0000 Subject: [PATCH 4/5] Implement end-to-end CLI and merge workflow --- capture/slide_detector.py | 23 +++++++-- merge/pptx_merge.py | 56 ++++++++++++++++++++ notes/storage.py | 43 ++++++++++++++++ transcribe/whisper_segmenter.py | 45 ++++++++++++++--- ui/cli.py | 90 +++++++++++++++++++++++++++++---- 5 files changed, 235 insertions(+), 22 deletions(-) diff --git a/capture/slide_detector.py b/capture/slide_detector.py index afdde53..593edd7 100644 --- a/capture/slide_detector.py +++ b/capture/slide_detector.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Iterable @@ -24,12 +24,18 @@ class SlideChangeDetector: def __init__(self, distance_threshold: int = 8) -> None: self.distance_threshold = distance_threshold - def detect_from_images(self, image_paths: Iterable[Path]) -> list[SlideChangeEvent]: + 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): - current_hash = self._phash(Image.open(image_path)) + with Image.open(image_path) as image: + current_hash = self._phash(image) if previous_hash is None: previous_hash = current_hash continue @@ -38,7 +44,7 @@ def detect_from_images(self, image_paths: Iterable[Path]) -> list[SlideChangeEve if hash_distance >= self.distance_threshold: events.append( SlideChangeEvent( - rtc_timestamp=datetime.now().strftime("%H:%M:%S"), + rtc_timestamp=_rtc_at_offset(recording_start_rtc, frame_index * sample_interval_seconds), hash_distance=hash_distance, frame_index=frame_index, ) @@ -55,6 +61,7 @@ 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 @@ -71,7 +78,7 @@ def detect_live( if hash_distance >= self.distance_threshold: events.append( SlideChangeEvent( - rtc_timestamp=datetime.now().strftime("%H:%M:%S"), + rtc_timestamp=_rtc_at_offset(recording_start_rtc, frame_index * sample_interval_seconds), hash_distance=hash_distance, frame_index=frame_index, ) @@ -86,3 +93,9 @@ def detect_live( @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/merge/pptx_merge.py b/merge/pptx_merge.py index 64002a3..9a1844c 100644 --- a/merge/pptx_merge.py +++ b/merge/pptx_merge.py @@ -1,10 +1,21 @@ 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] = [] @@ -17,3 +28,48 @@ def extract_slide_text(pptx_path: Path) -> list[str]: 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/storage.py b/notes/storage.py index e70dd05..850f96e 100644 --- a/notes/storage.py +++ b/notes/storage.py @@ -79,6 +79,49 @@ def create_lecture(self, course_code: str, topic: str, topic_progress: float) -> 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( diff --git a/transcribe/whisper_segmenter.py b/transcribe/whisper_segmenter.py index f3d1ccc..e8e2e54 100644 --- a/transcribe/whisper_segmenter.py +++ b/transcribe/whisper_segmenter.py @@ -1,12 +1,11 @@ from __future__ import annotations +import json from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any -import whisper - @dataclass(frozen=True) class SegmentedNote: @@ -18,9 +17,30 @@ class SegmentedNote: 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 result.get("segments", []) + 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( @@ -54,6 +74,19 @@ def split_transcript_by_slide_changes( 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, @@ -88,5 +121,5 @@ def _seconds_to_rtc(start_rtc: str, seconds_offset: float | None) -> str: raise ValueError("seconds_offset cannot be None") start_time = datetime.strptime(start_rtc, "%H:%M:%S") - timestamp = start_time.timestamp() + seconds_offset - return datetime.fromtimestamp(timestamp).strftime("%H:%M:%S") + timestamp = start_time + timedelta(seconds=seconds_offset) + return timestamp.strftime("%H:%M:%S") diff --git a/ui/cli.py b/ui/cli.py index 6c5031f..9649aee 100644 --- a/ui/cli.py +++ b/ui/cli.py @@ -5,53 +5,84 @@ from pathlib import Path from capture.slide_detector import SlideChangeDetector +from merge.pptx_merge import align_slides_with_notes, export_merged_markdown, extract_slide_text from notes.storage import NotesRepository -from transcribe.whisper_segmenter import split_transcript_by_slide_changes, transcribe_audio +from transcribe.whisper_segmenter import ( + load_transcript_segments, + split_transcript_by_slide_changes, + transcribe_audio, +) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="NewSlide lecture capture prototype") + subparsers = parser.add_subparsers(dest="command", required=True) - parser.add_argument("--db", type=Path, default=Path("newslide.db"), help="SQLite database path") - parser.add_argument("--course-code", required=True, help="Course code (e.g. CS101)") - parser.add_argument("--topic", required=True, help="Current lecture topic") - parser.add_argument( + 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)", ) - parser.add_argument("--audio", type=Path, required=True, help="Path to recorded lecture audio file") - parser.add_argument( + 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", ) - parser.add_argument( + run_parser.add_argument( "--sample-interval", type=float, default=2.0, help="Seconds between screen captures", ) - parser.add_argument( + run_parser.add_argument( "--threshold", type=int, default=8, help="Perceptual hash distance threshold for slide change", ) - parser.add_argument( + 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: + _validate_transcription_inputs(args.audio, args.transcript_json) + repository = NotesRepository(args.db) lecture = repository.create_lecture( course_code=args.course_code, @@ -64,9 +95,10 @@ def run() -> None: events = detector.detect_live( sample_count=args.sample_count, sample_interval_seconds=args.sample_interval, + recording_start_rtc=start_rtc, ) - whisper_segments = transcribe_audio(args.audio, model_name=args.model) + whisper_segments = _load_segments(args.audio, args.transcript_json, args.model) segmented_notes = split_transcript_by_slide_changes( whisper_segments=whisper_segments, recording_start_rtc=start_rtc, @@ -82,6 +114,7 @@ def run() -> None: 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}") @@ -91,5 +124,40 @@ def run() -> None: print(f"[{segment.rtc_timestamp}] {segment.content}") +def _run_merge_markdown(args: argparse.Namespace) -> None: + 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 _load_segments(audio_path: Path | None, transcript_json: Path | None, model_name: str) -> list[dict[str, object]]: + if transcript_json is not None: + return load_transcript_segments(transcript_json) + if audio_path is None: + raise ValueError("Provide --audio or --transcript-json") + return transcribe_audio(audio_path, model_name=model_name) + + +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() From ecccf3d5bef912173ee4191585e3a9b09611c5b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:21:02 +0000 Subject: [PATCH 5/5] Polish CLI usability and update workflow docs --- README.md | 72 ++++++++++++++++++++++++++++++++++++------------------- ui/cli.py | 32 ++++++++++++------------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9fc0798..8d79537 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,33 @@ # NewSlide -NewSlide is a lecture-capture prototype for students. It detects slide changes during a lecture, transcribes continuous audio with Whisper, and stores slide-aligned note segments in SQLite. +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**. -That gives NewSlide a clear positioning: - - slide-aware segmentation instead of pause-based chunking -- transcript slices naturally aligned to presentation flow -- note structure that maps directly to what students saw while listening +- transcript slices aligned to presentation flow +- note structure mapped to what students saw while listening -## Core mechanism (prototype) +## Core mechanism 1. Screen frames are sampled periodically. 2. Each frame gets a perceptual hash (`imagehash.phash`). -3. A new slide boundary is detected when hash distance crosses a threshold. -4. Each boundary is saved as an RTC wall-clock timestamp (`HH:MM:SS`). -5. Whisper transcribes the full lecture audio. -6. Transcript segments are split by slide-boundary timestamps to produce per-slide notes. +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` — slide capture and change detection -- `/transcribe` — Whisper integration and transcript segmentation +- `/capture` — screen capture and slide-change detection +- `/transcribe` — Whisper integration and timestamp segmentation - `/notes` — lecture + note-segment SQLite storage -- `/merge` — PPTX text extraction for note merge workflows -- `/ui` — CLI prototype +- `/merge` — PPTX extraction + note merge/export helpers +- `/ui` — CLI interface +- `/main.py` — entrypoint ## Requirements @@ -41,10 +40,12 @@ Install dependencies: pip install -r requirements.txt ``` -## Usage (CLI prototype) +## Workflow 1: capture + segment notes + +### A) Using Whisper audio transcription ```bash -python main.py \ +python main.py run \ --course-code CS101 \ --topic "Sorting Algorithms" \ --topic-progress 0.35 \ @@ -55,13 +56,36 @@ python main.py \ --model base ``` -This will: +### 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 +``` -- create a lecture record (`course_code`, auto-incremented lecture count, topic, topic progress) -- detect slide boundaries from live screen captures -- transcribe audio with Whisper -- split transcript into slide-aligned segments -- save note segments to SQLite (`newslide.db` by default) +This aligns extracted slide text with lecture note segments and exports a combined study document. ## Contributing @@ -69,5 +93,5 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for contribution workflow, coding guida ## Notes -- Screen capture uses `PIL.ImageGrab` in this prototype. -- The `/merge` module currently includes PPTX slide-text extraction; full timestamp alignment/export can be built on top of it. +- Live capture uses `PIL.ImageGrab` in this prototype. +- The merge output currently exports Markdown. diff --git a/ui/cli.py b/ui/cli.py index 9649aee..6260542 100644 --- a/ui/cli.py +++ b/ui/cli.py @@ -4,14 +4,7 @@ from datetime import datetime from pathlib import Path -from capture.slide_detector import SlideChangeDetector -from merge.pptx_merge import align_slides_with_notes, export_merged_markdown, extract_slide_text from notes.storage import NotesRepository -from transcribe.whisper_segmenter import ( - load_transcript_segments, - split_transcript_by_slide_changes, - transcribe_audio, -) def build_parser() -> argparse.ArgumentParser: @@ -81,6 +74,13 @@ def run() -> None: 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) @@ -98,7 +98,13 @@ def _run_capture_and_segment(args: argparse.Namespace) -> None: recording_start_rtc=start_rtc, ) - whisper_segments = _load_segments(args.audio, args.transcript_json, args.model) + 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, @@ -125,6 +131,8 @@ def _run_capture_and_segment(args: argparse.Namespace) -> None: 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: @@ -142,14 +150,6 @@ def _run_merge_markdown(args: argparse.Namespace) -> None: print(f"Exported merged notes to {output_path}") -def _load_segments(audio_path: Path | None, transcript_json: Path | None, model_name: str) -> list[dict[str, object]]: - if transcript_json is not None: - return load_transcript_segments(transcript_json) - if audio_path is None: - raise ValueError("Provide --audio or --transcript-json") - return transcribe_audio(audio_path, model_name=model_name) - - 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")