Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ TONE_HEAD_PATH= # path to tone-head-vN.pt (custom backend)
TAG_THRESHOLD=0.6
TONE_TOKEN= # required when DEPLOY_MODE is tunnel or cloud
LOG_EXPORT_BUCKET= # cloud only: GCS bucket for SQLite export
# TONE_LOG_DIR= # optional override; default is repo-root logs/

# --- Summaries (language/, Platform team) — benchmark scripts only, NEVER the iOS app ---
OPENAI_API_KEY=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ service-account*.json
data/
datasets/
wandb/
/logs/
*.npz
*.npy
*.pt
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ doc, say so. Don't silently diverge. If a decision changes, update the doc in th
with a stub/mock, so swapping models touches nothing else.
- **Features are defined once** in `ml/common/features.py`; Swift mirrors it, tested against
`ml/common/fixtures/`. Changing it invalidates trained models: flag it loudly.
- **Events:** `schemas/events.schema.json` (v1.0) is the cross-team contract. Pydantic
- **Events:** `schemas/events.schema.json` (v1.1) is the cross-team contract. Pydantic
(`datamodel-code-generator`) and Swift (`quicktype`) types are generated from it, never
hand-edited. Schema change → bump `schema_version`, regenerate, update `schemas/tests/`.
- All timestamps: seconds since session start on the **phone's** monotonic clock.
Expand Down
35 changes: 31 additions & 4 deletions apps/ios/Packages/GraptionCore/Sources/GraptionCore/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

/// Graption event contract v1.0 (docs/GRAPTION_CONTEXT.md section 5). All times are seconds
/// Graption event contract v1.1 (docs/GRAPTION_CONTEXT.md section 5). All times are seconds
/// since session start on the phone's monotonic host clock. Changing this file = bump
/// schema_version and regenerate Pydantic + Swift types.
///
Expand All @@ -22,6 +22,9 @@ import Foundation
/// Tone server -> phone. tag is null when not confident (a wrong tag is worse than none).
/// Tag vocabulary depends on backend: custom = the 6 emotions minus neutral; audeering
/// baseline = excited | upset | calm.
///
/// Tone server -> client. Malformed request rejected; connection stays open. Request is
/// echoed only to the originating client, never stored or logged.
// MARK: - Events
struct Events: Codable {
let faces: [EventsSchema]?
Expand All @@ -47,6 +50,12 @@ struct Events: Codable {
let modelVersion: String?
let probs: Probs?
let tag: String?
let code: String?
let message: String?
/// Exact received text, or base64 for an unsupported binary frame. May contain audio: never
/// store or log.
let request: String?
let requestEncoding: RequestEncoding?

enum CodingKeys: String, CodingKey {
case faces = "faces"
Expand All @@ -70,6 +79,10 @@ struct Events: Codable {
case modelVersion = "model_version"
case probs = "probs"
case tag = "tag"
case code = "code"
case message = "message"
case request = "request"
case requestEncoding = "request_encoding"
}
}

Expand Down Expand Up @@ -112,7 +125,11 @@ extension Events {
latencyMS: [String: Double]?? = nil,
modelVersion: String?? = nil,
probs: Probs?? = nil,
tag: String?? = nil
tag: String?? = nil,
code: String?? = nil,
message: String?? = nil,
request: String?? = nil,
requestEncoding: RequestEncoding?? = nil
) -> Events {
return Events(
faces: faces ?? self.faces,
Expand All @@ -135,7 +152,11 @@ extension Events {
latencyMS: latencyMS ?? self.latencyMS,
modelVersion: modelVersion ?? self.modelVersion,
probs: probs ?? self.probs,
tag: tag ?? self.tag
tag: tag ?? self.tag,
code: code ?? self.code,
message: message ?? self.message,
request: request ?? self.request,
requestEncoding: requestEncoding ?? self.requestEncoding
)
}

Expand Down Expand Up @@ -227,7 +248,7 @@ extension CaptionClass {
}

enum SchemaVersion: String, Codable {
case the10 = "1.0"
case the11 = "1.1"
}

enum CaptionType: String, Codable {
Expand Down Expand Up @@ -352,9 +373,15 @@ extension Probs {
}
}

enum RequestEncoding: String, Codable {
case base64 = "base64"
case text = "text"
}

enum EventsType: String, Codable {
case caption = "caption"
case captionLog = "caption_log"
case sessionError = "session_error"
case soundEvent = "sound_event"
case speakerScores = "speaker_scores"
case toneRequest = "tone_request"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class GraptionCoreTests: XCTestCase {
{
"type": "speaker_scores",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"t": 4.5,
"faces": [
{
Expand All @@ -26,7 +26,7 @@ final class GraptionCoreTests: XCTestCase {
{
"type": "caption",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"caption_id": "12345678-abcd-ef01-2345-6789abcdef01",
"t_start": 1.0,
"t_end": 3.5,
Expand All @@ -43,7 +43,7 @@ final class GraptionCoreTests: XCTestCase {
{
"type": "sound_event",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"t": 12.4,
"label": "doorbell",
"confidence": 0.92
Expand All @@ -54,7 +54,7 @@ final class GraptionCoreTests: XCTestCase {
{
"type": "tone_request",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"caption_id": "12345678-abcd-ef01-2345-6789abcdef01",
"t_start": 1.0,
"t_end": 3.5,
Expand All @@ -67,11 +67,11 @@ final class GraptionCoreTests: XCTestCase {
{
"type": "caption_log",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"caption": {
"type": "caption",
"session_id": "session-xyz",
"schema_version": "1.0",
"schema_version": "1.1",
"caption_id": "12345678-abcd-ef01-2345-6789abcdef01",
"t_start": 1.0,
"t_end": 3.5,
Expand All @@ -85,17 +85,27 @@ final class GraptionCoreTests: XCTestCase {

let sampleToneResultJson = """
{
"type": "speaker_scores",
"type": "tone_result",
"session_id": "session-xyz",
"schema_version": "1.0",
"t": 4.5,
"faces": [
{
"face_id": "f1",
"p": 0.85,
"bbox": [10.0, 20.0, 150.0, 200.0]
}
]
"schema_version": "1.1",
"caption_id": "12345678-abcd-ef01-2345-6789abcdef01",
"tag": null,
"probs": { "anger": 0.0, "disgust": 0.0, "fear": 0.0, "happy": 0.0, "neutral": 0.0, "sad": 0.0 },
"model_version": "tone-stub-v1",
"latency_ms": { "recv_to_send": 0.13 }
}
""".data(using: .utf8)!

let sampleSessionErrorJson = """
{
"type": "session_error",
"session_id": "session-xyz",
"schema_version": "1.1",
"code": "invalid_json",
"message": "Request is not valid JSON.",
"request": "{not json",
"request_encoding": "text",
"model_version": "tone-stub-v1"
}
""".data(using: .utf8)!

Expand All @@ -110,6 +120,13 @@ final class GraptionCoreTests: XCTestCase {
XCTAssertNoThrow(try decoder.decode(Events.self, from: sampleToneRequestJson))
XCTAssertNoThrow(try decoder.decode(Events.self, from: sampleCaptionLogJson))
XCTAssertNoThrow(try decoder.decode(Events.self, from: sampleToneResultJson))
XCTAssertNoThrow(try decoder.decode(Events.self, from: sampleSessionErrorJson))

// 3. Old schema versions are rejected
let staleToneResultJson = String(decoding: sampleToneResultJson, as: UTF8.self)
.replacingOccurrences(of: "\"1.1\"", with: "\"1.0\"")
.data(using: .utf8)!
XCTAssertThrowsError(try decoder.decode(Events.self, from: staleToneResultJson))
}

func testClockSavesTime() {
Expand Down
50 changes: 47 additions & 3 deletions docs/GRAPTION_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ The repo starts with only READMEs in most folders; each team creates the files a
All timestamps are **seconds since session start, measured on the phone's monotonic host clock**. Convert camera and audio `CMSampleBuffer` presentation timestamps to host time. The Mac never generates timestamps used for alignment.

### Event schema (`schemas/events.schema.json`)
Every event includes `type`, `session_id`, and `schema_version` (start at `"1.0"`).
Every event includes `type`, `session_id`, and `schema_version` (currently `"1.1"`).

**On-device (Swift types, generated from the schema):**
```jsonc
Expand Down Expand Up @@ -187,6 +187,29 @@ Every event includes `type`, `session_id`, and `schema_version` (start at `"1.0"
"model_version":"tone-head-v2", "latency_ms":{"recv_to_send":180} }
```

**Malformed request → client (schema v1.1):**
```json
{
"type": "session_error",
"session_id": "demo",
"schema_version": "1.1",
"code": "invalid_json",
"message": "Invalid JSON at line 1, column 1: Expecting value",
"request": "not json",
"request_encoding": "text",
"model_version": "tone-stub-v1"
}
```
The connection stays open after request errors, so the client can correct and resend.
`request` is the exact received text, including any audio supplied; unsupported binary
frames are echoed as base64 with `request_encoding: "base64"`. These replies are sent
only to the originating client and must never enter server logs or storage. Authentication
failures still reject the handshake. Version 1.1 adds `session_error`; clients must use
`schema_version: "1.1"` in all events. Pydantic types live in `server/app/events.py` and
Swift types in `apps/ios/Packages/GraptionCore/Sources/GraptionCore/Events.swift`, generated with
`npx quicktype@23.2.6 -s schema schemas/events.schema.json --lang swift --top-level Events
--density normal -o apps/ios/Packages/GraptionCore/Sources/GraptionCore/Events.swift`.

Base64 JSON is fine for the MVP (~130 KB for a 3s segment). Binary frames are a later optimization.

---
Expand Down Expand Up @@ -287,7 +310,19 @@ for caption [t0, t1]:

## 7. Mac tone server spec

- **Skeleton implemented:** `GET /health` returns plain text `ok`. The session endpoint
replies to each schema-v1.1 `tone_request` with a matching `tone_result`, `tag: null`,
zero probabilities (no prediction), and `model_version: "tone-stub-v1"` through
`MockToneBackend`. `caption_log` is accepted and discarded until logging is implemented.
Audio stays in memory only. Real inference, SQLite logging, and deployment packaging
remain later work; the skeleton always uses the stub regardless of `TONE_BACKEND`.
Invalid session messages return `session_error` and keep the connection open. Rejection
warnings contain only a fixed rejection code, generated connection ID, and backend model version. The response
echoes the received request to its originating client; it is never stored or logged.
- **Run (Mac):** `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`
- **Run (verbose, repo root):** `uv run --directory server python -m app --verbose`.
The launcher defaults to `--host 0.0.0.0 --port 8000`; either option can be overridden.
`--verbose` enables application diagnostics while leaving uvicorn frame logging at INFO.
- **Run (tunnel):** also run `cloudflared tunnel --url http://localhost:8000`, which gives a free `wss://…trycloudflare.com` URL.
- **Run (cloud):** build `server/Dockerfile` and deploy to Cloud Run.
- CPU only, 2 vCPU / 4 GB, min instances 0 (scales to zero between sessions, which keeps credit use low).
Expand All @@ -306,12 +341,20 @@ for caption [t0, t1]:
- arousal > 0.65 and valence < 0.4 → `upset`
- arousal < 0.3 → `calm`
- otherwise `null`
- **Config (env vars):** `TONE_BACKEND` (`audeering` | `custom`), `TONE_HEAD_PATH`, `TAG_THRESHOLD`, `TONE_TOKEN`, `DEPLOY_MODE` (`local` | `tunnel` | `cloud`), `LOG_EXPORT_BUCKET` (cloud only).
- **Config (env vars):** `TONE_BACKEND` (`audeering` | `custom`), `TONE_HEAD_PATH`, `TAG_THRESHOLD`, `TONE_TOKEN`, `DEPLOY_MODE` (`local` | `tunnel` | `cloud`), `LOG_EXPORT_BUCKET` (cloud only), `TONE_LOG_DIR` (default repo-root `logs/`).
- **CPU performance:** the audEERING baseline is the large wav2vec2 variant (~165M params) and may be slow on CPU. Benchmark per-sentence latency on Cloud Run before user testing. If it's over ~1s, use the custom WavLM-base head in cloud mode.
- **SQLite tables:** `sessions`, `captions` (from `caption_log`), `tone_results` (probs, model_version, latency).
- Cloud Run disks are ephemeral, so in cloud mode, export the SQLite file to `LOG_EXPORT_BUCKET` at session end.
- **Privacy:** never write request audio to disk or logs in any mode. Only text, tags, probabilities, and timings are stored.
- **Logging:** per-request receive, inference, and send timings.
- **Logging:** application logs go to the console and a fresh gitignored repo-root
`logs/server-<UTC-timestamp>-<unique-id>.jsonl` on each server start; the startup log prints
the file path. Each run rotates at 5 MiB with three backups; previous runs are preserved. Startup/shutdown and warnings/errors are always logged.
`--verbose` adds connection lifecycle, accepted tone/caption requests, frame/audio byte
counts, receive-to-send/inference/send timings, and per-connection totals on disconnect.
Entries include model versions and generated connection IDs. Never log raw frames,
caller-provided session IDs, caption text, tokens, echoed requests, or exception details.
UTC log timestamps are diagnostics only; alignment timestamps still come from the phone.
Use the application verbose flag rather than uvicorn debug frame logging, which can expose audio.
- **Campus Wi-Fi** often blocks device-to-device traffic. Use a phone hotspot or a personal router.

---
Expand Down Expand Up @@ -483,6 +526,7 @@ If the device is overloaded, degrade in this order:
- **Face only in v1:** multi-person pose and hands would overload the phone and add little for "who's talking."
- **CREMA-D, not MSP-Podcast; openSMILE dropped:** no license paperwork. The audEERING baseline (trained on MSP-Podcast) gives natural-speech coverage for free. Both remain stretch goals, used only if tests show gaps.
- **Tone never blocks captions,** and tone tags only show when confident. A wrong tag is worse than none for DHH users.
- **Recoverable request errors (schema v1.1):** return descriptive `session_error` messages with the received request so manual clients can correct mistakes without reconnecting. Never log or store the echoed payload.
- **Rule-based fusion before learned fusion:** it produces the logged data a learned version would need.
- **Committed `.xcodeproj`, not XcodeGen:** synchronized folders plus per-team Swift packages already avoid most merge conflicts, and it saves every teammate a tool install. Signing is per developer via a gitignored `Local.xcconfig`, because free Apple IDs can't share a bundle ID.
- **Platform owns summaries and the dashboard:** there's no separate language team; it's non-blocking backend-style work that builds on the caption log Platform already moves around.
Expand Down
16 changes: 15 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ graption-ml = { workspace = true }
graption-language = { workspace = true }

[dependency-groups]
dev = ["ruff>=0.6", "pytest>=8", "jsonschema>=4.23"]
dev = [
"ruff>=0.6",
"pytest>=8",
"jsonschema>=4.23",
"httpx>=0.28.1",
"datamodel-code-generator>=0.83.0",
]

[tool.ruff]
line-length = 100
Expand All @@ -26,8 +32,16 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP"]

[tool.ruff.lint.per-file-ignores]
# Descriptions are copied verbatim from the schema by datamodel-code-generator.
"server/app/events.py" = ["E501"]

[tool.ruff.lint.isort]
known-first-party = ["app"]

[tool.pytest.ini_options]
testpaths = ["schemas", "server", "ml", "language"]
pythonpath = ["server"]
addopts = "--import-mode=importlib"

[tool.ruff.lint.flake8-bugbear]
Expand Down
23 changes: 20 additions & 3 deletions schemas/events.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/gt-big-data/graption/schemas/events.schema.json",
"title": "GraptionEvent",
"description": "Graption event contract v1.0 (docs/GRAPTION_CONTEXT.md section 5). All times are seconds since session start on the phone's monotonic host clock. Changing this file = bump schema_version and regenerate Pydantic + Swift types.",
"description": "Graption event contract v1.1 (docs/GRAPTION_CONTEXT.md section 5). All times are seconds since session start on the phone's monotonic host clock. Changing this file = bump schema_version and regenerate Pydantic + Swift types.",
"oneOf": [
{ "$ref": "#/$defs/SpeakerScores" },
{ "$ref": "#/$defs/Caption" },
{ "$ref": "#/$defs/SoundEvent" },
{ "$ref": "#/$defs/ToneRequest" },
{ "$ref": "#/$defs/CaptionLog" },
{ "$ref": "#/$defs/ToneResult" }
{ "$ref": "#/$defs/ToneResult" },
{ "$ref": "#/$defs/SessionError" }
],
"$defs": {
"SchemaVersion": { "type": "string", "const": "1.0" },
"SchemaVersion": { "type": "string", "const": "1.1" },
"SessionError": {
"type": "object",
"description": "Tone server -> client. Malformed request rejected; connection stays open. Request is echoed only to the originating client, never stored or logged.",
"additionalProperties": false,
"required": ["type", "session_id", "schema_version", "code", "message", "request", "request_encoding", "model_version"],
"properties": {
"type": { "const": "session_error" },
"session_id": { "$ref": "#/$defs/SessionId" },
"schema_version": { "$ref": "#/$defs/SchemaVersion" },
"code": { "type": "string" },
"message": { "type": "string" },
"request": { "type": "string", "description": "Exact received text, or base64 for an unsupported binary frame. May contain audio: never store or log." },
"request_encoding": { "enum": ["text", "base64"] },
"model_version": { "type": "string" }
}
},
"SessionId": { "type": "string", "minLength": 1 },
"Seconds": { "type": "number", "minimum": 0 },
"Probability": { "type": "number", "minimum": 0, "maximum": 1 },
Expand Down
Loading
Loading