Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: [ATECCA]
custom: ["https://opencollective.com/"]
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
*.db
48 changes: 48 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 96 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions capture/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""NewSlide package module."""
101 changes: 101 additions & 0 deletions capture/slide_detector.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ui.cli import run


if __name__ == "__main__":
run()
1 change: 1 addition & 0 deletions merge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""NewSlide package module."""
75 changes: 75 additions & 0 deletions merge/pptx_merge.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions notes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""NewSlide package module."""
Loading