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>
363 lines
No EOL
12 KiB
YAML
363 lines
No EOL
12 KiB
YAML
# 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"] } |