Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
14 KiB
LinkedStorm — Home Audio Assistant Design
Date: 2026-05-27
Status: Approved
Context
The goal is a whole-home audio assistant that passively records household conversations, identifies who is speaking, produces searchable transcripts, and maintains a daily activity log. The motivation is to have a reliable, private, locally-hosted record of decisions and conversations that can be queried naturally ("what did we decide about the vacation?") without relying on cloud services.
The system reuses as much existing homelab infrastructure as possible: Whisper STT on the GPU machine, LiveKit for WebRTC, Ollama for LLM queries, and SpeechBrain for audio ML — all already deployed. Speaker labeling mirrors the proven frigate-notify person-identification pattern (CLIP embeddings → voice embeddings, cosine similarity, three-tier matching, review-and-tag UI).
Architecture
Hybrid approach: Lightweight WebSocket audio capture from browser tabs → server-side processing pipeline → LiveKit for live monitoring and replay.
Container Breakdown
Every deployable component is its own container, following the same pattern as VoiceCoach (7 containers) and the rest of the homelab. LinkedStorm is 3 containers:
| Container | Purpose | Port | Host |
|---|---|---|---|
linkedstorm-pipeline |
WebSocket ingestion, Silero VAD, Whisper calls, resemblyzer speaker embedding, cosine similarity matching, SQLite writes, LiveKit publish | 8300 | hf-pdocker-01 |
linkedstorm-api |
FastAPI web dashboard + REST API, static UI, FTS5 search, RAG queries, speaker labeling | 8310 | hf-pdocker-01 |
linkedstorm-cron |
Nightly Ollama summary, unknown speaker clustering, WAV clip purge | — (no port) | hf-pdocker-01 |
All three share a single Docker volume for the SQLite database and clips directory.
External Services (Reused Without Modification)
| Service | What | Where |
|---|---|---|
| Speaches/Whisper | POST /v1/audio/transcriptions |
GPU machine :8000 |
| LiveKit | Per-room replay rooms (linkedstorm-{room}) |
GPU machine :7880 |
| Ollama | RAG queries + daily summaries (qwen2.5:14b) |
GPU machine :11434 |
Speaker voice embedding uses resemblyzer (ECAPA-TDNN, ~50MB) running in-process inside linkedstorm-pipeline — no separate microservice, no coupling to VoiceCoach.
Data Flow
Browser tab (capture.html)
└─ MediaRecorder → WebSocket → linkedstorm-pipeline:8300
└─ Silero VAD detects speech segment
└─ [Whisper STT via Speaches ‖ resemblyzer embed] concurrent
└─ cosine similarity → speaker match
└─ SQLite write + WAV clip save
└─ LiveKit publish → dashboard replay
linkedstorm-api:8310 ← reads SQLite → serves dashboard + API
linkedstorm-cron ← runs nightly at midnight via entrypoint sleep loop
Components
1. Browser Capture Page (linkedstorm-capture)
Single HTML page opened on any device to make it a room microphone.
MediaRecorderat 16kHz mono, 250ms chunks streamed over WebSocket- Room name set via URL param (
?room=Kitchen) or prompt on first load - Persistent room name stored in
localStorage - Mute toggle: stops
MediaRecorder, sends{"type":"mute"}control message, server discards buffered audio for that room - Visual indicator: green pulsing dot (streaming) / red dot (muted) / gray (disconnected)
- Auto-reconnect with exponential backoff on WebSocket drop
2. Audio Ingestion Service (linkedstorm-ingestion)
Python asyncio service on hf-pdocker-01. Single process, per-room coroutines.
- WebSocket endpoint:
ws://linkedstorm:8300/ws/{room_id} - Per-room
asyncio.Queueof audio chunks - Silero VAD (reused from VoiceCoach): processes each 250ms chunk; accumulates on speech-detected, emits segment on ≥600ms silence; discards segments <0.5s
- On segment emit: calls
process_utterance(room_id, audio_bytes, start_time, end_time) - Room registry: upsert
roomstable on first connection, updatelast_seenon each chunk, setis_active=Falseon disconnect
3. Processing Pipeline
Runs as an async task per emitted speech segment.
Step 1 — Concurrent inference (asyncio gather):
POST http://192.168.86.150:8000/v1/audio/transcriptionswith audio WAV → transcript textresemblyzer.preprocess_wav()+encoder.embed_utterance()in-process → voice embedding (256-dim float32 array)
Step 2 — Speaker matching:
- Load all
voice_embeddingsrows from SQLite - Compute cosine similarity between utterance embedding and each reference
- Group by
speaker_id, take MAX per speaker - Thresholds: ≥0.85 →
known, 0.60–0.84 →ambiguous, <0.60 →unknown
Step 3 — Persist:
- Save audio clip to
/data/linkedstorm/clips/{date}/{room_id}/{utterance_id}.wav - Insert
utterancesrow: transcript, embedding, room_id, speaker_id, match_status, match_confidence, start_time, end_time, audio_clip_path
Step 4 — LiveKit publish:
- Publish audio segment to LiveKit room
linkedstorm-{room_id}for live monitoring/replay
4. Web UI Service (linkedstorm-ui)
FastAPI on hf-pdocker-01 port 8310. Static HTML/JS frontend, JSON API backend. Same pattern as frigate-notify.
API endpoints:
GET /api/utterances # list with filters: date, room_id, speaker_id, match_status
GET /api/utterances/{id}/clip # stream audio clip WAV
POST /api/utterances/{id}/tag # label utterance: {speaker_id} or {name, create_new: true}
POST /api/utterances/{id}/dismiss # mark as noise/unknown, no review needed
GET /api/speakers # list all known speakers with utterance counts
POST /api/speakers # create new speaker
GET /api/rooms # list rooms with is_active, last_seen
POST /api/rooms/{id}/mute # send mute command to room's WebSocket connection
GET /api/search?q=... # full-text search across utterances.transcript (SQLite FTS5)
POST /api/ask # RAG query: builds context from utterances, calls Ollama
GET /api/summary/{date} # fetch or trigger daily summary for a date
UI screens (5 tabs, single-page app):
- Daily Log — timeline grouped by time blocks, color-coded by speaker, inline Play/Tag/Dismiss on unknowns
- Search — full-text with keyword highlighting, date-range filter
- Speakers — known speaker cards + review-queue banner for unreviewed utterances
- Ask AI — RAG chat backed by Ollama, responses cite room + timestamp sources
- Rooms — live capture device status, per-room mute toggle, link to open capture page
5. Nightly Cron Jobs
Run at midnight via cron on hf-pdocker-01.
Daily summary: Fetch all utterances for the day → batch into prompt → send to Ollama (qwen2.5:14b) → store in daily_summaries. Prompt instructs the model to summarize key decisions, activities, and topics — not to transcribe verbatim.
Unknown speaker clustering: Load all unknown and ambiguous utterance embeddings from the past 7 days → cluster by cosine similarity (threshold 0.70) → group consistent voices as Unknown #1, Unknown #2, etc. in the review queue rather than scattered individuals.
Data Model
SQLite at /data/linkedstorm/linkedstorm.db. WAL mode, foreign keys enabled. Thread-local connections (same pattern as frigate-notify).
CREATE TABLE rooms (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
device_label TEXT,
is_active BOOLEAN DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE speakers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE voice_embeddings (
id INTEGER PRIMARY KEY,
speaker_id INTEGER NOT NULL REFERENCES speakers(id),
embedding BLOB NOT NULL, -- packed float32
audio_sample BLOB, -- short reference WAV
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE utterances (
id INTEGER PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
speaker_id INTEGER REFERENCES speakers(id), -- nullable
transcript TEXT NOT NULL,
embedding BLOB, -- packed float32
match_status TEXT DEFAULT 'unreviewed', -- known/unknown/ambiguous/unreviewed
match_confidence REAL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
audio_clip_path TEXT,
livekit_room TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE utterances_fts USING fts5(
transcript, content=utterances, content_rowid=id
);
CREATE TABLE daily_summaries (
id INTEGER PRIMARY KEY,
date DATE NOT NULL UNIQUE,
summary_text TEXT NOT NULL,
generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
Speaker Identification
Mirrors frigate-notify's CLIP embedding pattern, adapted for voice:
| Similarity | Status | Action |
|---|---|---|
| ≥ 0.85 | known |
Auto-label speaker. Store utterance embedding as additional reference. |
| 0.60–0.84 | ambiguous |
Store utterance. Appear in review queue with Play + Tag buttons. |
| < 0.60 | unknown |
Store utterance. Cluster with other unknowns nightly. |
| No embed | unreviewed |
SpeechBrain unavailable. Transcript stored, embed missing. |
Labeling flow (same as frigate-notify tag workflow):
POST /api/utterances/{id}/tag with {speaker_id} or {name, create_new: true} →
sets match_status='known', match_confidence=1.0, stores utterance's embedding in voice_embeddings for future matching.
Deployment
3 containers on hf-pdocker-01
Compose file: compose/hf-pdocker-01/linkedstorm/docker-compose.yml
Ports:
8300—linkedstorm-pipelineWebSocket ingestion (capture devices connect here)8310—linkedstorm-apiFastAPI web UI and API
Volumes:
/data/linkedstorm/→/mnt/docker-data/configs/linkedstorm/(NAS)linkedstorm.db— SQLite databaseclips/— audio clip WAV files
Clip retention: A VAD-triggered household produces roughly 50–200MB of WAV clips per day. The nightly cron should delete clips older than 30 days (configurable via CLIP_RETENTION_DAYS env var) while keeping the transcript and embedding rows permanently. The audio_clip_path column is set to NULL for purged clips; the Play button in the UI shows "clip expired" for those utterances.
Environment:
SPEACHES_URL=http://192.168.86.150:8000
CLIP_RETENTION_DAYS=30
OLLAMA_URL=http://192.168.86.150:11434
LIVEKIT_URL=ws://192.168.86.150:7880
LIVEKIT_API_KEY=...
LIVEKIT_API_SECRET=...
Traefik: linkedstorm.hobbs.farm → port 8310 (dashboard). Capture page accessed at linkedstorm.hobbs.farm/capture?room=Kitchen.
Cron entries on hf-pdocker-01:
0 0 * * * docker exec linkedstorm python -m cron.daily_summary
5 0 * * * docker exec linkedstorm python -m cron.cluster_unknowns
10 0 * * * docker exec linkedstorm python -m cron.purge_clips
File Structure
compose/hf-pdocker-01/linkedstorm/
├── docker-compose.yml
├── .env.example
├── pipeline/
│ └── Dockerfile # linkedstorm-pipeline image
├── api/
│ └── Dockerfile # linkedstorm-api image
└── cron/
└── Dockerfile # linkedstorm-cron image
# Shared app code (mounted into pipeline + api containers, or baked into images)
app/
├── config.py # Settings from env vars (shared)
├── database.py # SQLite thread-local connections, schema init (shared)
├── models.py # Dataclasses for rooms, speakers, utterances (shared)
│
├── pipeline/ # linkedstorm-pipeline
│ ├── main.py # asyncio entrypoint: WebSocket server + per-room VAD loops
│ ├── ingestion.py # WebSocket handler, per-room audio buffer + Silero VAD
│ ├── processor.py # process_utterance: Whisper + resemblyzer + matching + persist
│ ├── recognition.py # resemblyzer embed + cosine similarity, speaker matching
│ └── livekit.py # Publish audio segments to LiveKit rooms
│
├── api/ # linkedstorm-api
│ ├── main.py # FastAPI entrypoint, mounts routes, serves static files
│ ├── routes/
│ │ ├── utterances.py # /api/utterances CRUD + tag + dismiss
│ │ ├── speakers.py # /api/speakers CRUD
│ │ ├── rooms.py # /api/rooms status + mute relay to pipeline
│ │ ├── search.py # /api/search (SQLite FTS5)
│ │ └── rag.py # /api/ask, /api/summary
│ └── static/
│ ├── index.html # Dashboard SPA (5-tab layout)
│ ├── capture.html # Minimal capture page (one per room device)
│ └── app.js / style.css
│
└── cron/ # linkedstorm-cron
├── main.py # Sleep-loop entrypoint: runs all jobs at midnight
├── daily_summary.py # Ollama daily summary generation
├── cluster_unknowns.py # Nightly unknown speaker clustering
└── purge_clips.py # Delete WAV files older than CLIP_RETENTION_DAYS
Verification
- Capture → ingestion: Open
capture.html?room=Kitchenin a browser, speak — confirm WebSocket connects, room appears active in the Rooms tab of the dashboard - VAD: Speak briefly then stay silent — confirm a single utterance appears in the log (not multiple fragments), and background noise is not logged
- Transcription: Read a known sentence — confirm transcript text in the utterance matches
- Speaker labeling: Tag an unknown utterance as "Jeremy" → speak again → confirm next utterances auto-resolve to Jeremy with high confidence
- Mute: Click mute on the capture page — confirm no utterances logged while muted, muted status shown in Rooms tab
- Search: Search for a word spoken earlier — confirm it appears highlighted in results
- Ask AI: Ask "What did we talk about today?" — confirm Ollama returns a coherent summary with room/time citations
- LiveKit replay: From the dashboard, join a room's LiveKit channel — confirm processed audio plays back
- Nightly cron: Trigger
daily_summarymanually — confirmdaily_summariesrow appears and is shown in the log view - Prometheus probe: Add
http://192.168.86.192:8310/healthto blackbox targets and confirm it's green