diff --git a/android/README.md b/android/README.md index 6c0fb58..4f8f2cb 100644 --- a/android/README.md +++ b/android/README.md @@ -14,13 +14,22 @@ The phone's local whisper.cpp engine remains as the offline fallback. (tiny/base for live, e.g. small for the final pass). Measured on the Fairphone 6: ~0.6–0.8× realtime, so keep local models small. -**server** — a remote [whisper.cpp server](../server/whisper-server/) +**server** — a remote [whisper.cpp server](../whisper-server/) (Docker Compose, Vulkan GPU) transcribes BOTH the live pass and the final pass; the model lives on the server (e.g. large-v3). The phone only records and displays. Set the server URL in the app (persisted; e.g. `http://100.103.83.12:8085` over Tailscale — the phone needs Tailscale too). Local model selection is greyed out in this mode. +## Recording library (meetrec-server) + +Finished recordings (WAV + txt/srt/json) upload automatically to the +[meetrec-server](../meetrec-server/) storage service. Set the **Library +URL** in the app (`http://100.103.83.12:8090`, persisted) or leave it +empty to disable upload. Uploads run in the background after the final +pass; the status line reports the result. (A browse UI for previous +recordings arrives with M3b.) + ## Requirements - Android Studio (or: SDK Platform 36, Build Tools 36, NDK 27.1, CMake 3.22.1) diff --git a/android/app/src/main/java/com/meetrec/android/MainActivity.kt b/android/app/src/main/java/com/meetrec/android/MainActivity.kt index 2f793b5..2f410cf 100644 --- a/android/app/src/main/java/com/meetrec/android/MainActivity.kt +++ b/android/app/src/main/java/com/meetrec/android/MainActivity.kt @@ -80,6 +80,12 @@ fun MeetRecScreen() { ?: "http://100.103.83.12:8085", ) } + var storageUrl by remember { + mutableStateOf( + prefs.getString("storage_url", "http://100.103.83.12:8090") + ?: "http://100.103.83.12:8090", + ) + } LaunchedEffect(transcribeOn) { prefs.edit().putString("transcribe_on", transcribeOn).apply() @@ -87,6 +93,9 @@ fun MeetRecScreen() { LaunchedEffect(serverUrl) { prefs.edit().putString("server_url", serverUrl).apply() } + LaunchedEffect(storageUrl) { + prefs.edit().putString("storage_url", storageUrl).apply() + } var model by remember { mutableStateOf("tiny") } var modelMenu by remember { mutableStateOf(false) } @@ -106,6 +115,16 @@ fun MeetRecScreen() { val recState by RecorderService.state.collectAsState() val live by RecorderService.liveLines.collectAsState() + val upload by RecorderService.uploadState.collectAsState() + + LaunchedEffect(upload) { + when (val u = upload) { + is UploadState.Uploading -> status = "Uploading recording to library…" + is UploadState.Done -> status = "Uploaded to library (${u.id})" + is UploadState.Error -> status = "Upload failed: ${u.message}" + UploadState.None -> {} + } + } fun runBusy(block: suspend () -> T) { if (busy) return @@ -197,6 +216,7 @@ fun MeetRecScreen() { } else { null }, + storageUrl = storageUrl.trim().ifBlank { null }, ) if (transcribeOn == "server") { status = "Recording — live and final pass on ${serverUrl.trim()}" @@ -282,6 +302,15 @@ fun MeetRecScreen() { ) } + // recording library (leave empty to disable upload) + OutlinedTextField( + value = storageUrl, + onValueChange = { storageUrl = it }, + label = { Text("Library URL") }, + placeholder = { Text("http://100.103.83.12:8090") }, + modifier = Modifier.fillMaxWidth(), + ) + // model + language Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { ExposedDropdownMenuBox( diff --git a/android/app/src/main/java/com/meetrec/android/RecorderService.kt b/android/app/src/main/java/com/meetrec/android/RecorderService.kt index 5ee0b2f..7a181d1 100644 --- a/android/app/src/main/java/com/meetrec/android/RecorderService.kt +++ b/android/app/src/main/java/com/meetrec/android/RecorderService.kt @@ -37,11 +37,23 @@ import kotlinx.coroutines.withContext /** One line of the live rolling transcript, with its absolute start time. */ data class LiveLine(val startMs: Long, val text: String) +/** Upload progress for the recording library (meetrec-server). */ +sealed interface UploadState { + data object None : UploadState + + data class Uploading(val file: File) : UploadState + + data class Done(val id: String) : UploadState + + data class Error(val message: String) : UploadState +} + /** * Engine/session for the next recording; set by the UI right before * starting the service. When [serverUrl] is set, BOTH passes transcribe * remotely (model lives on the server); otherwise [finalEngine] and the * local [liveModel] are used. All engines may be null — record only. + * When [storageUrl] is set, finished recordings upload to the library. */ data class SessionConfig( val finalEngine: WhisperEngine?, @@ -49,6 +61,7 @@ data class SessionConfig( val language: String?, val liveModel: String?, val liveIntervalSec: Int = 8, + val storageUrl: String? = null, ) /** Recording state published to the UI (collect RecorderService.state). */ @@ -107,6 +120,10 @@ class RecorderService : Service() { val liveLines: StateFlow> = MutableStateFlow>(emptyList()) private val _liveLines = liveLines as MutableStateFlow> + + /** Recording library upload (meetrec-server). */ + val uploadState: StateFlow = MutableStateFlow(UploadState.None) + private val _uploadState = uploadState as MutableStateFlow } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -261,6 +278,7 @@ class RecorderService : Service() { cfg?.serverUrl?.let { RemoteWhisperEngine(it) } ?: cfg?.finalEngine if (transcriber == null) { _state.value = RecordingState.Finished(file, durationMs, sampleRate) + maybeUpload(file, emptyList(), durationMs, cfg) cleanup() return } @@ -282,6 +300,7 @@ class RecorderService : Service() { "${SystemClock.elapsedRealtime() - t0} ms") val outputs = TranscriptFiles.writeAll(file, durationMs, segments) _state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs) + maybeUpload(file, outputs, durationMs, cfg) } catch (e: Exception) { _state.value = RecordingState.Finished( file, durationMs, sampleRate, error = e.message, @@ -290,6 +309,41 @@ class RecorderService : Service() { cleanup() } + /** Uploads the bundle to the recording library in the background. */ + private fun maybeUpload( + file: File, + outputs: List, + durationMs: Long, + cfg: SessionConfig?, + ) { + val url = cfg?.storageUrl?.trim() + if (url.isNullOrBlank()) return + scope.launch { + _uploadState.value = UploadState.Uploading(file) + try { + val id = withContext(Dispatchers.IO) { + StorageClient.upload( + baseUrl = url, + wav = file, + txt = outputs.firstOrNull { it.name.endsWith(".txt") }, + srt = outputs.firstOrNull { it.name.endsWith(".srt") }, + json = outputs.firstOrNull { it.name.endsWith(".json") }, + startedAt = file.nameWithoutExtension.removePrefix("meeting-"), + durationMs = durationMs, + language = cfg.language ?: "", + device = "Android", + agenda = emptyList(), + ) + } + Log.i(TAG, "upload done: $id") + _uploadState.value = UploadState.Done(id) + } catch (e: Exception) { + Log.w(TAG, "upload failed: ${e.message}", e) + _uploadState.value = UploadState.Error(e.message ?: "upload failed") + } + } + } + private fun cleanup() { session = null stopForeground(STOP_FOREGROUND_REMOVE) diff --git a/android/app/src/main/java/com/meetrec/android/StorageClient.kt b/android/app/src/main/java/com/meetrec/android/StorageClient.kt new file mode 100644 index 0000000..ff2c084 --- /dev/null +++ b/android/app/src/main/java/com/meetrec/android/StorageClient.kt @@ -0,0 +1,113 @@ +package com.meetrec.android + +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import org.json.JSONObject + +/** Client for the meetrec-server storage API (see server/meetrec-server/). */ +object StorageClient { + + /** Uploads a recording bundle; returns the new recording id. */ + fun upload( + baseUrl: String, + wav: File, + txt: File?, + srt: File?, + json: File?, + startedAt: String, + durationMs: Long, + language: String?, + device: String, + agenda: List, + timeoutMs: Int = 300_000, + ): String { + val base = baseUrl.trim().trimEnd('/') + val body = multipart( + mapOf( + "started_at" to startedAt, + "duration_ms" to durationMs.toString(), + "language" to (language ?: ""), + "device" to device, + "agenda" to toJsonArray(agenda), + ), + listOfNotNull( + wav.takeIf { it.isFile }?.let { "wav" to it }, + txt?.takeIf { it.isFile }?.let { "txt" to it }, + srt?.takeIf { it.isFile }?.let { "srt" to it }, + json?.takeIf { it.isFile }?.let { "json" to it }, + ), + ) + + val conn = (URL("$base/api/recordings").openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 15_000 + readTimeout = timeoutMs + setFixedLengthStreamingMode(body.size) + setRequestProperty( + "Content-Type", + "multipart/form-data; boundary=$BOUNDARY", + ) + } + try { + conn.outputStream.use { it.write(body) } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val resp = stream?.use { it.readBytes() } ?: ByteArray(0) + if (code !in 200..299) { + throw IOException( + "server returned $code: " + String(resp, 0, minOf(resp.size, 200)), + ) + } + return JSONObject(String(resp)).getString("id") + } finally { + conn.disconnect() + } + } + + private fun multipart(fields: Map, files: List>): ByteArray { + val parts = ArrayList() + for ((name, value) in fields) { + parts.add( + ("--$BOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"$name\"\r\n\r\n" + + "$value\r\n").toByteArray(), + ) + } + for ((name, file) in files) { + parts.add( + ("--$BOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"$name\"; " + + "filename=\"meeting.$name\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n").toByteArray(), + ) + parts.add(file.readBytes()) + parts.add("\r\n".toByteArray()) + } + parts.add("--$BOUNDARY--\r\n".toByteArray()) + return parts.reduce { acc, b -> acc + b } + } + + private fun toJsonArray(items: List): String = + items.joinToString(",", "[", "]") { "\"" + escape(it) + "\"" } + + private fun escape(s: String): String { + val sb = StringBuilder(s.length + 8) + for (c in s) { + when { + c == '"' -> sb.append("\\\"") + c == '\\' -> sb.append("\\\\") + c == '\n' -> sb.append("\\n") + c == '\r' -> sb.append("\\r") + c == '\t' -> sb.append("\\t") + c < ' ' -> sb.append("\\u%04x".format(c.code)) + else -> sb.append(c) + } + } + return sb.toString() + } + + private const val BOUNDARY = "meetrec-android-3b6a9c2f" +} \ No newline at end of file diff --git a/server/meetrec-server/.dockerignore b/server/meetrec-server/.dockerignore new file mode 100644 index 0000000..06c57ed --- /dev/null +++ b/server/meetrec-server/.dockerignore @@ -0,0 +1,4 @@ +data/ +.git +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/server/meetrec-server/Dockerfile b/server/meetrec-server/Dockerfile new file mode 100644 index 0000000..ab51f45 --- /dev/null +++ b/server/meetrec-server/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +ENV MEETREC_DATA=/data +EXPOSE 8090 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"] \ No newline at end of file diff --git a/server/meetrec-server/docker-compose.yml b/server/meetrec-server/docker-compose.yml new file mode 100644 index 0000000..ddd9d6d --- /dev/null +++ b/server/meetrec-server/docker-compose.yml @@ -0,0 +1,34 @@ +# meetrec-server: storage + orcheststration for meetrec recordings. +# +# docker compose up -d --build +# +# Stores recording bundles uploaded by the meetrec apps (wav/txt/srt/json + +# metadata) and — from M3c on — generates German meeting summaries and +# agenda coverage via Ollama. Single-user service for a private tailnet; +# bound only to the Tailscale interface, like whisper-server. + +services: + meetrec-server: + build: . + image: meetrec-server:latest + container_name: meetrec-server + restart: unless-stopped + + environment: + # Ollama (used from M3c on for summaries/agenda coverage) + OLLAMA_URL: http://100.103.83.12:11435 + OLLAMA_MODEL: gemma4:12b + SUMMARY_LANG: de + + volumes: + - ./data:/data + + ports: + - "100.103.83.12:8090:8090" # tailscale-only; see whisper-server notes + + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8090/api/health')\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s \ No newline at end of file diff --git a/server/meetrec-server/main.py b/server/meetrec-server/main.py new file mode 100644 index 0000000..7d0a79f --- /dev/null +++ b/server/meetrec-server/main.py @@ -0,0 +1,164 @@ +"""meetrec-server: storage + orchestration API for meetrec recordings. + +Single-user service for a private tailnet: stores the recording bundles +(wav/txt/srt/json + metadata) uploaded by the meetrec apps and, later in +M3c, generates meeting summaries and agenda coverage via Ollama. + +Storage layout (no database): + data/ + index.json # list of recording metadata + recordings// + meeting.wav / .txt / .srt / .json + summary.md # (M3c) + agenda.json # (M3c) +""" + +import json +import re +import shutil +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +DATA_DIR = Path("/data") +REC_DIR = DATA_DIR / "recordings" +INDEX_PATH = DATA_DIR / "index.json" + +ALLOWED_FILES = ("wav", "txt", "srt", "json") +RID_RE = re.compile(r"^[A-Za-z0-9-]+$") + +app = FastAPI(title="meetrec-server", version="0.1.0") +_lock = threading.Lock() + + +def _load_index() -> list: + if not INDEX_PATH.exists(): + return [] + with INDEX_PATH.open() as f: + return json.load(f) + + +def _save_index(items: list) -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + tmp = INDEX_PATH.with_suffix(".json.tmp") + with tmp.open("w") as f: + json.dump(items, f, ensure_ascii=False, indent=2) + tmp.replace(INDEX_PATH) + + +def _find(items: list, rid: str) -> Optional[dict]: + return next((it for it in items if it["id"] == rid), None) + + +@app.get("/api/health") +def health() -> dict: + with _lock: + return {"ok": True, "recordings": len(_load_index())} + + +@app.get("/api/recordings") +def list_recordings() -> list: + with _lock: + items = _load_index() + return list(reversed(items)) # newest first + + +@app.post("/api/recordings", status_code=201) +async def upload( + wav: UploadFile = File(...), + txt: Optional[UploadFile] = File(None), + srt: Optional[UploadFile] = File(None), + json_file: Optional[UploadFile] = File(None, alias="json"), + started_at: str = Form(""), + duration_ms: int = Form(0), + language: str = Form(""), + device: str = Form(""), + agenda: str = Form("[]"), +) -> dict: + uploads = [] + for name, f in (("wav", wav), ("txt", txt), ("srt", srt), ("json", json_file)): + if f is not None and f.filename: + uploads.append((name, f)) + if not any(name == "wav" for name, _ in uploads): + raise HTTPException(400, "a 'wav' file part is required") + + try: + agenda_items = json.loads(agenda or "[]") + except json.JSONDecodeError: + raise HTTPException(400, "agenda must be a JSON list of strings") + if not isinstance(agenda_items, list) or not all(isinstance(a, str) for a in agenda_items): + raise HTTPException(400, "agenda must be a JSON list of strings") + + rid = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] + rec_dir = REC_DIR / rid + rec_dir.mkdir(parents=True, exist_ok=True) + + stored = [] + for name, f in uploads: + dest = rec_dir / f"meeting.{name}" + with dest.open("wb") as out: + while chunk := await f.read(1 << 20): + out.write(chunk) + stored.append(dest.name) + + item = { + "id": rid, + "started_at": started_at or datetime.now(timezone.utc).isoformat(timespec="seconds"), + "duration_ms": duration_ms, + "language": language, + "device": device, + "agenda": agenda_items, + "files": sorted(stored), + "summary": None, # set in M3c + "agenda_results": None, + "uploaded_at": time.time(), + } + with _lock: + items = _load_index() + items.append(item) + _save_index(items) + return {"id": rid, "files": sorted(stored)} + + +@app.get("/api/recordings/{rid}") +def get_recording(rid: str) -> dict: + with _lock: + item = _find(_load_index(), rid) + if item is None: + raise HTTPException(404, "unknown recording id") + return item + + +@app.get("/api/recordings/{rid}/files/{name}") +def get_file(rid: str, name: str) -> FileResponse: + if not RID_RE.match(rid) or "/" in name or ".." in name: + raise HTTPException(400, "invalid id or file name") + with _lock: + item = _find(_load_index(), rid) + if item is None: + raise HTTPException(404, "unknown recording id") + if name not in item["files"] and name not in ("summary.md", "agenda.json"): + raise HTTPException(404, f"no such file: {name}") + path = REC_DIR / rid / name + if not path.is_file(): + raise HTTPException(404, f"no such file: {name}") + return FileResponse(path) + + +@app.delete("/api/recordings/{rid}", status_code=204) +def delete_recording(rid: str) -> None: + if not RID_RE.match(rid): + raise HTTPException(400, "invalid id") + with _lock: + items = _load_index() + item = _find(items, rid) + if item is None: + raise HTTPException(404, "unknown recording id") + _save_index([it for it in items if it["id"] != rid]) + shutil.rmtree(REC_DIR / rid, ignore_errors=True) \ No newline at end of file diff --git a/server/meetrec-server/requirements.txt b/server/meetrec-server/requirements.txt new file mode 100644 index 0000000..6e3c026 --- /dev/null +++ b/server/meetrec-server/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115 +uvicorn>=0.30 +python-multipart>=0.0.9 \ No newline at end of file diff --git a/server/whisper-server/README.md b/server/whisper-server/README.md index 5fb371d..5e5c194 100644 --- a/server/whisper-server/README.md +++ b/server/whisper-server/README.md @@ -65,7 +65,8 @@ Notes: ## Client status -- Desktop `meetrec`: a `whisper-server` engine is planned - (`--engine whisper-server --server-url http://100.103.83.12:8085`). -- Android app: a remote engine option is planned (phone records, server - transcribes; local JNI stays as the offline fallback). \ No newline at end of file +- Desktop `meetrec`: `--engine whisper-server --server-url http://100.103.83.12:8085`. +- Android app: **Transcribe on: server** (phone records, server transcribes; + local JNI stays as the offline fallback). +- Recording library: [meetrec-server](../meetrec-server/) stores recordings + + transcripts; Android auto-uploads after the final pass. \ No newline at end of file