scaffold: OpenScribe open-source self-hosted AI voice recorder
Some checks failed
ci / firmware (push) Failing after 2s
ci / server (push) Failing after 45s

Bootstrap of the project (M0). Sets up the monorepo, design docs, hardware BOM,
the open API contract, component skeletons, licensing and CI, following the
Default Workflow SOP.

What changed:
- CLAUDE.md + docs/: copied the Default Workflow so sessions load the SOP.
- state/: PROJECT, ARCHITECTURE, DECISIONS, TODO, NOTES filled in for OpenScribe.
  ARCHITECTURE captures the four-part design (firmware, server, app, case) and the
  three sync paths; DECISIONS records the hardware, AI-stack, storage, app and
  licensing choices; TODO lays out milestones M1-M9.
- hardware/BOM.md: two build options (compact XIAO ESP32-S3 Sense; dev ESP32-S3 +
  I2S mic + SD), wiring/pinout, indicative cost.
- api/openapi.yaml: the completely open API (device + server surfaces), including
  recording list/download/delete and exports (wav/ogg/txt/srt/vtt/md/json).
- firmware/: PlatformIO ESP32-S3 project, two board profiles, pin map, boot scaffold
  with module seams for M1-M4.
- server/: FastAPI skeleton mirroring the OpenAPI, config for self-hosted MinIO,
  faster-whisper and Ollama; stub routes browsable at /docs.
- app/, case/: Flutter app plan; parametric OpenSCAD enclosure.
- Licensing: GPL-3.0 (code), CERN-OHL-S-2.0 (hardware), CC-BY-SA-4.0 (case/docs),
  REUSE-style LICENSES/ with SPDX headers; LICENSING.md explains the split.
- CI: Forgejo Actions workflow builds firmware (both profiles) and lints/imports server.

Why:
- Everything self-hosted and openly licensed per the user's requirements: an open
  API, three sync paths (BLE control, WiFi transfer, independent WiFi upload on
  charge to generic cloud storage), and a full self-hosted transcription+summary stack.

Notes:
- No custom PCB in v1; off-the-shelf modules. Physical verification waits on parts.
- Component code is stubs at M0; features land milestone by milestone, each as its
  own branch/PR per the workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Laurence 2026-07-03 10:21:37 +01:00
commit 031074c9a9
36 changed files with 2922 additions and 0 deletions

48
server/README.md Normal file
View file

@ -0,0 +1,48 @@
# OpenScribe server
Self-hosted FastAPI server: ingests recordings, transcribes them (faster-whisper),
summarises them (Ollama), and serves the open API with exports. Everything runs on
hardware you own.
> Status: M0 scaffold. The API shape is live and browsable at `/docs` with in-memory
> stubs. Transcription lands in M5, summaries in M6, real storage/DB alongside.
## Run (dev)
```bash
cd server
python -m venv .venv
. .venv/Scripts/activate # Windows; or: . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # edit as needed
uvicorn app.main:app --reload
```
- API docs (Swagger UI): http://localhost:8000/docs
- Health: http://localhost:8000/health
## Self-hosted dependencies
For the AI features (M5/M6) you run, on your own kit:
- **MinIO** (or WebDAV / NAS) for object storage - `OPENSCRIBE_STORAGE_BACKEND=s3`.
- **Ollama** for summaries - `ollama serve` and `ollama pull llama3.1`.
- **faster-whisper** downloads its model on first use; CPU works, CUDA is faster.
None of these are required for plain recording and transfer; they add transcription and
summaries.
## Layout
```
app/main.py FastAPI app + routes (mirrors ../api/openapi.yaml)
app/config.py Settings from env / .env
app/models.py Pydantic models (kept in sync with the OpenAPI schemas)
requirements.txt
.env.example
```
## API
The contract is `../api/openapi.yaml`. The device implements the LAN "device" paths; this
server implements ingest, transcript, summary and export.

4
server/app/__init__.py Normal file
View file

@ -0,0 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-only
"""OpenScribe self-hosted server package."""
__version__ = "0.1.0"

41
server/app/config.py Normal file
View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: GPL-3.0-only
"""Server configuration, loaded from environment / .env (see .env.example).
Everything points at self-hosted services by default: local object storage, a local
Ollama, and a local faster-whisper model. Nothing here requires a proprietary cloud.
"""
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSCRIBE_", env_file=".env")
# API
api_token: str = "change-me" # bearer token for write endpoints
# Storage: "local" | "s3" | "webdav"
storage_backend: str = "local"
local_media_dir: str = "./media"
# S3-compatible (MinIO) - used when storage_backend == "s3"
s3_endpoint: str = "http://localhost:9000"
s3_bucket: str = "openscribe"
s3_access_key: str = ""
s3_secret_key: str = ""
# Metadata DB (SQLite to start; Postgres URL later)
database_url: str = "sqlite:///./openscribe.db"
# Transcription (faster-whisper)
whisper_model: str = "base" # tiny|base|small|medium|large-v3
whisper_device: str = "cpu" # cpu|cuda
whisper_compute_type: str = "int8"
# Summarisation (Ollama, self-hosted)
ollama_url: str = "http://localhost:11434"
ollama_model: str = "llama3.1"
settings = Settings()

68
server/app/main.py Normal file
View file

@ -0,0 +1,68 @@
# SPDX-License-Identifier: GPL-3.0-only
"""OpenScribe server - FastAPI app.
M0 scaffold: wires the routes from api/openapi.yaml with in-memory stubs so the API shape
is real and browsable at /docs. The AI pipeline (faster-whisper transcription in M5,
Ollama summaries in M6) and real storage/DB replace the stubs in later milestones.
"""
from __future__ import annotations
from fastapi import FastAPI, HTTPException
from .config import settings
from .models import Recording, RecordingPage, Summary, Transcript
app = FastAPI(
title="OpenScribe API",
version="0.1.0",
description="Self-hosted AI voice recorder server. See api/openapi.yaml.",
)
# In-memory store stands in for the DB + object storage until M5.
_recordings: dict[str, Recording] = {}
@app.get("/health")
def health() -> dict:
return {
"status": "ok",
"storage_backend": settings.storage_backend,
"whisper_model": settings.whisper_model,
"ollama_model": settings.ollama_model,
}
@app.get("/api/v1/recordings", response_model=RecordingPage, tags=["recordings"])
def list_recordings(limit: int = 50) -> RecordingPage:
return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None)
@app.get("/api/v1/recordings/{rec_id}", response_model=Recording, tags=["recordings"])
def get_recording(rec_id: str) -> Recording:
rec = _recordings.get(rec_id)
if rec is None:
raise HTTPException(status_code=404, detail="No such recording")
return rec
@app.get("/api/v1/recordings/{rec_id}/transcript", response_model=Transcript, tags=["server"])
def get_transcript(rec_id: str) -> Transcript:
# Implemented in M5 (faster-whisper). Until then, signal "not transcribed yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not transcribed yet (M5)")
@app.get("/api/v1/recordings/{rec_id}/summary", response_model=Summary, tags=["server"])
def get_summary(rec_id: str) -> Summary:
# Implemented in M6 (Ollama). Until then, signal "not summarised yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not summarised yet (M6)")
@app.post("/api/v1/ingest", response_model=Recording, status_code=202, tags=["server"])
def ingest(recording: Recording) -> Recording:
# M5 will store audio to the object store and queue transcription + summary.
_recordings[recording.id] = recording
return recording

59
server/app/models.py Normal file
View file

@ -0,0 +1,59 @@
# SPDX-License-Identifier: GPL-3.0-only
"""Pydantic models mirroring api/openapi.yaml. Keep the two in sync."""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel
class SyncState(str, Enum):
local = "local"
uploaded = "uploaded"
ingested = "ingested"
transcribed = "transcribed"
summarised = "summarised"
class Recording(BaseModel):
id: str
device_id: str | None = None
started_at: str
duration_s: float
sample_rate: int
channels: int
codec: str
size_bytes: int | None = None
sha256: str | None = None
source: str = "device"
sync_state: SyncState = SyncState.ingested
transcript_ref: str | None = None
summary_ref: str | None = None
class RecordingPage(BaseModel):
items: list[Recording]
next_cursor: str | None = None
class TranscriptSegment(BaseModel):
start: float
end: float
text: str
speaker: str | None = None
class Transcript(BaseModel):
recording_id: str
language: str
model: str
text: str
segments: list[TranscriptSegment]
class Summary(BaseModel):
recording_id: str
model: str
overview: str
key_points: list[str]
action_items: list[str]

19
server/requirements.txt Normal file
View file

@ -0,0 +1,19 @@
# OpenScribe server dependencies.
# Core API
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2.7
pydantic-settings>=2.3
python-multipart>=0.0.9
# Storage clients (self-hosted targets)
boto3>=1.34 # S3-compatible (MinIO)
webdavclient3>=3.14 # WebDAV / NAS
# AI pipeline (self-hosted). Installed when M5/M6 land; listed here for reference.
faster-whisper>=1.0 # transcription (CTranslate2)
httpx>=0.27 # talk to Ollama for summaries
# Dev
pytest>=8
ruff>=0.4