scaffold: OpenScribe open-source self-hosted AI voice recorder
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:
commit
031074c9a9
36 changed files with 2922 additions and 0 deletions
363
api/openapi.yaml
Normal file
363
api/openapi.yaml
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
# SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
# OpenScribe open API - single source of truth for the device and server REST interfaces.
|
||||
# The device implements the "device" paths on the LAN; the server implements the "server"
|
||||
# paths. Both share the Recording schema. Keep firmware and server in sync with this file.
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OpenScribe API
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Open API for the OpenScribe self-hosted AI voice recorder. Two surfaces share this
|
||||
document: the on-device REST API (served by the ESP32-S3 on the LAN) and the
|
||||
self-hosted server API (ingest, transcription, summaries, exports). Everything the
|
||||
owner needs to list, download, export and delete their recordings is here.
|
||||
license:
|
||||
name: CC-BY-SA-4.0
|
||||
identifier: CC-BY-SA-4.0
|
||||
|
||||
servers:
|
||||
- url: http://openscribe.local/api/v1
|
||||
description: Device on the LAN (mDNS name; also reachable by IP)
|
||||
- url: http://your-server.lan:8000/api/v1
|
||||
description: Self-hosted server
|
||||
|
||||
tags:
|
||||
- name: device
|
||||
description: Implemented by the firmware on the device (LAN).
|
||||
- name: server
|
||||
description: Implemented by the self-hosted server.
|
||||
- name: recordings
|
||||
description: Recording listing, download, export, delete (both surfaces where noted).
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
# ---------------- Device surface ----------------
|
||||
/device:
|
||||
get:
|
||||
tags: [device]
|
||||
summary: Device status
|
||||
responses:
|
||||
"200":
|
||||
description: Current device status
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DeviceStatus" }
|
||||
|
||||
/device/config:
|
||||
get:
|
||||
tags: [device]
|
||||
summary: Read device config (secrets redacted)
|
||||
responses:
|
||||
"200":
|
||||
description: Config
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DeviceConfig" }
|
||||
put:
|
||||
tags: [device]
|
||||
summary: Update device config (WiFi, upload target, codec)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/DeviceConfigUpdate" }
|
||||
responses:
|
||||
"200": { description: Updated }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/record/start:
|
||||
post:
|
||||
tags: [device]
|
||||
summary: Start a recording
|
||||
responses:
|
||||
"200":
|
||||
description: Recording started
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Recording" }
|
||||
"409": { description: Already recording }
|
||||
/record/stop:
|
||||
post:
|
||||
tags: [device]
|
||||
summary: Stop the current recording
|
||||
responses:
|
||||
"200":
|
||||
description: Recording stopped
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Recording" }
|
||||
"409": { description: Not recording }
|
||||
|
||||
/ota:
|
||||
post:
|
||||
tags: [device]
|
||||
summary: Upload a firmware image to update the device (OTA)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
responses:
|
||||
"202": { description: Update accepted; device will apply and reboot }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
# ---------------- Shared: recordings ----------------
|
||||
/recordings:
|
||||
get:
|
||||
tags: [recordings]
|
||||
summary: List recordings
|
||||
description: Implemented by both device (local files) and server (ingested).
|
||||
parameters:
|
||||
- { in: query, name: limit, schema: { type: integer, default: 50 } }
|
||||
- { in: query, name: cursor, schema: { type: string } }
|
||||
- in: query
|
||||
name: sync_state
|
||||
schema: { $ref: "#/components/schemas/SyncState" }
|
||||
responses:
|
||||
"200":
|
||||
description: A page of recordings
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/RecordingPage" }
|
||||
|
||||
/recordings/{id}:
|
||||
parameters:
|
||||
- { in: path, name: id, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [recordings]
|
||||
summary: Get recording metadata
|
||||
responses:
|
||||
"200":
|
||||
description: Recording metadata
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Recording" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
delete:
|
||||
tags: [recordings]
|
||||
summary: Delete a recording (and its artefacts)
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/recordings/{id}/audio:
|
||||
parameters:
|
||||
- { in: path, name: id, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [recordings]
|
||||
summary: Download the audio (supports HTTP Range for resumable/partial fetch)
|
||||
responses:
|
||||
"200":
|
||||
description: Full audio
|
||||
content:
|
||||
audio/wav: { schema: { type: string, format: binary } }
|
||||
audio/ogg: { schema: { type: string, format: binary } }
|
||||
"206": { description: Partial content (Range) }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
# ---------------- Server surface: AI + exports ----------------
|
||||
/recordings/{id}/transcript:
|
||||
parameters:
|
||||
- { in: path, name: id, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [server]
|
||||
summary: Get the transcript
|
||||
responses:
|
||||
"200":
|
||||
description: Transcript with word/segment timings
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Transcript" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { description: Not transcribed yet }
|
||||
|
||||
/recordings/{id}/summary:
|
||||
parameters:
|
||||
- { in: path, name: id, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [server]
|
||||
summary: Get the LLM summary (overview, key points, action items)
|
||||
responses:
|
||||
"200":
|
||||
description: Summary
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Summary" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"409": { description: Not summarised yet }
|
||||
|
||||
/recordings/{id}/export:
|
||||
parameters:
|
||||
- { in: path, name: id, required: true, schema: { type: string } }
|
||||
- in: query
|
||||
name: format
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [wav, ogg, txt, srt, vtt, md, json]
|
||||
description: >
|
||||
Export format. audio (wav/ogg), plain text (txt), subtitles (srt/vtt),
|
||||
Markdown summary (md), or the full record as json.
|
||||
get:
|
||||
tags: [server]
|
||||
summary: Export a recording in the requested format
|
||||
responses:
|
||||
"200":
|
||||
description: The exported artefact
|
||||
content:
|
||||
application/octet-stream: { schema: { type: string, format: binary } }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/ingest:
|
||||
post:
|
||||
tags: [server]
|
||||
summary: Ingest a recording (direct upload; also used by the device uploader)
|
||||
description: >
|
||||
Accepts audio + metadata and queues transcription and summarisation. The device
|
||||
normally uploads to object storage and notifies here; direct multipart upload is
|
||||
also supported for the app or manual import.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
metadata: { $ref: "#/components/schemas/Recording" }
|
||||
audio: { type: string, format: binary }
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted for processing
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Recording" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: >
|
||||
Bearer token. On the device the token is set in config and stored in NVS; on the
|
||||
server it is an API key. Read-only listing may be unauthenticated on a trusted LAN
|
||||
if the owner enables that in config.
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Missing or invalid token
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
NotFound:
|
||||
description: No such recording
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
schemas:
|
||||
SyncState:
|
||||
type: string
|
||||
enum: [local, uploaded, ingested, transcribed, summarised]
|
||||
|
||||
Recording:
|
||||
type: object
|
||||
required: [id, started_at, duration_s, sample_rate, channels, codec]
|
||||
properties:
|
||||
id: { type: string, example: "rec_20260703T101500Z_ab12" }
|
||||
device_id: { type: string, example: "openscribe-abc123" }
|
||||
started_at: { type: string, format: date-time }
|
||||
duration_s: { type: number, example: 372.5 }
|
||||
sample_rate: { type: integer, example: 16000 }
|
||||
channels: { type: integer, example: 1 }
|
||||
codec: { type: string, example: "wav_pcm_s16le" }
|
||||
size_bytes: { type: integer, example: 11920000 }
|
||||
sha256: { type: string }
|
||||
source: { type: string, enum: [device, upload], default: device }
|
||||
sync_state: { $ref: "#/components/schemas/SyncState" }
|
||||
transcript_ref: { type: [string, "null"] }
|
||||
summary_ref: { type: [string, "null"] }
|
||||
|
||||
RecordingPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Recording" }
|
||||
next_cursor: { type: [string, "null"] }
|
||||
|
||||
TranscriptSegment:
|
||||
type: object
|
||||
properties:
|
||||
start: { type: number }
|
||||
end: { type: number }
|
||||
text: { type: string }
|
||||
speaker: { type: [string, "null"], description: "Reserved for future diarisation" }
|
||||
|
||||
Transcript:
|
||||
type: object
|
||||
properties:
|
||||
recording_id: { type: string }
|
||||
language: { type: string, example: "en" }
|
||||
model: { type: string, example: "faster-whisper:base" }
|
||||
text: { type: string }
|
||||
segments:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/TranscriptSegment" }
|
||||
|
||||
Summary:
|
||||
type: object
|
||||
properties:
|
||||
recording_id: { type: string }
|
||||
model: { type: string, example: "ollama:llama3.1" }
|
||||
overview: { type: string }
|
||||
key_points:
|
||||
type: array
|
||||
items: { type: string }
|
||||
action_items:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
DeviceStatus:
|
||||
type: object
|
||||
properties:
|
||||
device_id: { type: string }
|
||||
firmware_version: { type: string }
|
||||
state: { type: string, enum: [idle, recording, uploading] }
|
||||
power: { type: string, enum: [battery, charging, powered] }
|
||||
battery_pct: { type: [integer, "null"] }
|
||||
storage_free_bytes: { type: integer }
|
||||
storage_total_bytes: { type: integer }
|
||||
recordings_count: { type: integer }
|
||||
wifi_connected: { type: boolean }
|
||||
ip: { type: [string, "null"] }
|
||||
|
||||
DeviceConfig:
|
||||
type: object
|
||||
properties:
|
||||
device_id: { type: string }
|
||||
wifi_ssid: { type: [string, "null"] }
|
||||
upload_target: { type: string, enum: [none, s3, webdav], default: none }
|
||||
upload_endpoint: { type: [string, "null"] }
|
||||
upload_bucket_or_path: { type: [string, "null"] }
|
||||
codec: { type: string, enum: [wav_pcm_s16le, adpcm, opus], default: wav_pcm_s16le }
|
||||
sample_rate: { type: integer, default: 16000 }
|
||||
# secrets (wifi_psk, s3 keys) are write-only via DeviceConfigUpdate and never returned
|
||||
|
||||
DeviceConfigUpdate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/DeviceConfig"
|
||||
- type: object
|
||||
properties:
|
||||
wifi_psk: { type: string, writeOnly: true }
|
||||
upload_access_key: { type: string, writeOnly: true }
|
||||
upload_secret_key: { type: string, writeOnly: true }
|
||||
api_token: { type: string, writeOnly: true }
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
detail: { type: [string, "null"] }
|
||||
Loading…
Add table
Add a link
Reference in a new issue