Remove redundant cursor styling
Some checks failed
Deploy API / deploy (push) Failing after 1s
Deploy UI / deploy (push) Successful in 1s

This commit is contained in:
Your Name 2026-05-19 19:48:13 -04:00
parent 080b4bdaf1
commit b24dd72ae0
93 changed files with 9058 additions and 1 deletions

4
another_todo.txt Normal file
View file

@ -0,0 +1,4 @@
add echo service for testing
add a food diary
google drive plugin

10
clio-ai/real_time/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

View file

@ -0,0 +1 @@
3.13

View file

View file

@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Training sample collection script for voice command classification
# Creates organized recordings for wake word and category phrases
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RECORDINGS_DIR="$SCRIPT_DIR/recordings"
SAMPLE_RATE=16000
DURATION=4
# Categories
declare -A CATEGORIES=(
["wake_word"]="ok, note this"
["pressure_below_15"]="eye pressure is less than 15"
["pressure_15_to_25"]="eye pressure is 15 to 25"
["pressure_25_to_35"]="eye pressure is 25 to 35"
["pressure_above_35"]="eye pressure is greater than 35"
["drop_l"]="L drop"
["drop_p"]="P drop"
["drop_d"]="D drop"
["both_eyes"]="both eyes"
)
# Get Corsair mic source
get_mic_source() {
wpctl status | grep -i "corsair" | grep -i "mono" | grep -oE '[0-9]+\.' | head -1 | tr -d '.'
}
# Create directory structure
setup_dirs() {
for category in "${!CATEGORIES[@]}"; do
mkdir -p "$RECORDINGS_DIR/$category"
done
echo "Created recording directories in: $RECORDINGS_DIR"
}
# Count existing samples for a category
count_samples() {
local category=$1
find "$RECORDINGS_DIR/$category" -name "*.wav" 2>/dev/null | wc -l
}
# Record a single sample
record_sample() {
local category=$1
local voice_name=$2
local source_id=$3
local count=$(count_samples "$category")
local filename="${voice_name}_$(printf "%03d" $((count + 1))).wav"
local filepath="$RECORDINGS_DIR/$category/$filename"
echo ""
echo "Recording: $category"
echo "Say: \"${CATEGORIES[$category]}\""
echo ""
echo "Press ENTER to start recording (${DURATION}s)..."
read
echo ">>> RECORDING NOW - SPEAK! <<<"
pw-record --target "$source_id" --rate "$SAMPLE_RATE" --channels 1 --format s16 "$filepath" &
local pid=$!
sleep "$DURATION"
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
# Check audio level
local max_vol=$(ffmpeg -i "$filepath" -af "volumedetect" -f null /dev/null 2>&1 | grep "max_volume" | sed 's/.*max_volume: \([-0-9.]*\).*/\1/')
echo "Saved: $filename (max volume: ${max_vol} dB)"
if [ "${max_vol%.*}" -lt -50 ] 2>/dev/null; then
echo "WARNING: Recording seems quiet. Keep? [Y/n]"
read -r keep
if [[ "$keep" =~ ^[Nn] ]]; then
rm "$filepath"
echo "Deleted. Try again."
return 1
fi
fi
return 0
}
# Interactive recording session
record_session() {
local source_id=$(get_mic_source)
if [ -z "$source_id" ]; then
echo "ERROR: Corsair mic not found"
exit 1
fi
echo "=== Voice Command Sample Collection ==="
echo "Mic source: $source_id"
echo ""
echo "Enter your name/voice identifier (e.g., 'john', 'sarah'):"
read -r voice_name
voice_name=${voice_name:-default}
while true; do
echo ""
echo "=== Current sample counts ==="
for category in "${!CATEGORIES[@]}"; do
local count=$(count_samples "$category")
echo " $category: $count samples"
done
echo ""
echo "Select category to record:"
echo " 1) wake_word - \"ok, note this\""
echo " 2) pressure_below_15 - \"eye pressure is less than 15\""
echo " 3) pressure_15_to_25 - \"eye pressure is 15 to 25\""
echo " 4) pressure_25_to_35 - \"eye pressure is 25 to 35\""
echo " 5) pressure_above_35 - \"eye pressure is greater than 35\""
echo " 6) drop_l - \"L drop\""
echo " 7) drop_p - \"P drop\""
echo " 8) drop_d - \"D drop\""
echo " 9) both_eyes - \"both eyes\""
echo " a) Record ALL categories (one each)"
echo " q) Quit"
echo ""
read -r choice
case $choice in
1) record_sample "wake_word" "$voice_name" "$source_id" ;;
2) record_sample "pressure_below_15" "$voice_name" "$source_id" ;;
3) record_sample "pressure_15_to_25" "$voice_name" "$source_id" ;;
4) record_sample "pressure_25_to_35" "$voice_name" "$source_id" ;;
5) record_sample "pressure_above_35" "$voice_name" "$source_id" ;;
6) record_sample "drop_l" "$voice_name" "$source_id" ;;
7) record_sample "drop_p" "$voice_name" "$source_id" ;;
8) record_sample "drop_d" "$voice_name" "$source_id" ;;
9) record_sample "both_eyes" "$voice_name" "$source_id" ;;
a|A)
for category in wake_word pressure_below_15 pressure_15_to_25 pressure_25_to_35 pressure_above_35 drop_l drop_p drop_d both_eyes; do
record_sample "$category" "$voice_name" "$source_id"
done
;;
q|Q)
echo "Done. Total samples collected:"
for category in "${!CATEGORIES[@]}"; do
echo " $category: $(count_samples "$category")"
done
exit 0
;;
*) echo "Invalid choice" ;;
esac
done
}
# Main
setup_dirs
record_session

61
clio-ai/real_time/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,60 @@
{
description = "Voice-activated eye pressure classifier";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
config.cudaSupport = true;
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Python and uv
python313
uv
# Audio
portaudio
pipewire
alsa-lib
ffmpeg
# CUDA (for faster-whisper)
cudaPackages.cudatoolkit
cudaPackages.cudnn
# SSL certificates
cacert
# Text-to-speech
espeak-ng
];
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [
pkgs.portaudio
pkgs.pipewire
pkgs.alsa-lib
pkgs.cudaPackages.cudatoolkit
pkgs.cudaPackages.cudnn
]}:/run/opengl-driver/lib:$LD_LIBRARY_PATH"
export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
echo "Voice classifier dev environment"
echo "Run: uv run python listener.py"
'';
};
}
);
}

562
clio-ai/real_time/listener.py Executable file
View file

@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""
Voice-activated eye pressure classifier.
Listens for wake word "ok, note this" then classifies the following speech
into eye pressure categories.
Uses two classification methods:
1. Transcription + regex pattern matching (fallback)
2. Audio embeddings + trained classifier (primary, if available)
Usage:
uv run python listener.py [--model large-v3] [--device cuda]
"""
import argparse
import collections
import pickle
import queue
import re
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
import sounddevice as sd
import torch
import webrtcvad
# Lazy imports for faster startup feedback
whisper_model = None
embedding_model = None
classifier_data = None
SCRIPT_DIR = Path(__file__).parent
CLASSIFIER_PATH = SCRIPT_DIR / "classifier.pkl"
# Categories with regex patterns for matching
CATEGORIES = {
"pressure_below_15": [
r"(less\s+than|under|below)\s*15",
r"<\s*15",
r"15.*less",
],
"pressure_15_to_25": [
r"15\s*(to|through|-)\s*25",
r"between\s*15\s*(and|to)\s*25",
],
"pressure_25_to_35": [
r"25\s*(to|through|-)\s*35",
r"between\s*25\s*(and|to)\s*35",
],
"pressure_above_35": [
r"(greater\s+than|over|above|more\s+than)\s*35",
r">\s*35",
r"35.*more",
],
"drop_l": [
r"\bl\s*drop",
r"drop\s*l\b",
r"\bell\s*drop",
],
"drop_p": [
r"\bp\s*drop",
r"drop\s*p\b",
r"\bpee\s*drop",
],
"drop_d": [
r"\bd\s*drop",
r"drop\s*d\b",
r"\bdee\s*drop",
],
"both_eyes": [
r"both\s*eyes",
r"both\s*i",
],
}
WAKE_PHRASES = [
r"ok[,.]?\s*note\s+this",
r"okay[,.]?\s*note\s+this",
r"o\.?k\.?\s*note\s+this",
# Common mis-transcriptions
r"ok[,.]?\s*notice",
r"okay[,.]?\s*notice",
r"ok[,.]?\s*noted",
r"okay[,.]?\s*noted",
r"ok[,.]?\s*no\s+this",
r"okay[,.]?\s*no\s+this",
r"ok[,.]?\s*not\s+this",
r"okay[,.]?\s*not\s+this",
]
CONFIRM_YES = [
r"\byes\b", r"\byeah\b", r"\byep\b", r"\byup\b",
r"\bcorrect\b", r"\bright\b", r"\bconfirm\b",
r"\bsave\b", r"\bok\b", r"\bokay\b",
]
CONFIRM_NO = [
r"\bno\b", r"\bnope\b", r"\bnah\b",
r"\bwrong\b", r"\bcancel\b", r"\bdelete\b",
r"\bdiscard\b", r"\bredo\b",
]
# Spoken responses for each category
CATEGORY_RESPONSES = {
"pressure_below_15": "Eye pressure below 15",
"pressure_15_to_25": "Eye pressure 15 to 25",
"pressure_25_to_35": "Eye pressure 25 to 35",
"pressure_above_35": "Eye pressure above 35",
"drop_l": "L drop",
"drop_p": "P drop",
"drop_d": "D drop",
"both_eyes": "Both eyes",
}
SAMPLE_RATE = 16000
FRAME_DURATION_MS = 30 # webrtcvad frame size
FRAME_SIZE = int(SAMPLE_RATE * FRAME_DURATION_MS / 1000)
SPEECH_PADDING_MS = 300 # padding around speech
NUM_PADDING_FRAMES = SPEECH_PADDING_MS // FRAME_DURATION_MS
def speak(text: str):
"""Speak text using available TTS."""
# Try espeak-ng, then espeak, then piper
for cmd in [
["espeak-ng", "-v", "en-us", "-s", "150", text],
["espeak", "-v", "en-us", "-s", "150", text],
]:
try:
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
return
except FileNotFoundError:
continue
except Exception as e:
print(f" TTS error: {e}")
return
# Fallback: use ffmpeg to generate speech-like notification
print(f" (TTS unavailable - install espeak-ng)")
def beep():
"""Play a short beep tone."""
try:
# Generate a 100ms 800Hz beep
subprocess.run(
["ffplay", "-nodisp", "-autoexit", "-f", "lavfi", "-i",
"sine=frequency=800:duration=0.1", "-loglevel", "quiet"],
capture_output=True,
timeout=2
)
except Exception:
pass # Silent fail
def get_corsair_device():
"""Find Corsair microphone device index."""
devices = sd.query_devices()
for i, dev in enumerate(devices):
if "corsair" in dev["name"].lower() and dev["max_input_channels"] > 0:
return i
return None
def load_whisper(model_name: str, device: str):
"""Load faster-whisper model."""
global whisper_model
if whisper_model is None:
from faster_whisper import WhisperModel
print(f"Loading Whisper model '{model_name}' on {device}...")
compute_type = "float16" if device == "cuda" else "int8"
whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
print(" Whisper loaded.")
return whisper_model
def load_embedding_model(device: str):
"""Load wav2vec2 model for embeddings."""
global embedding_model
if embedding_model is None:
from torchaudio.pipelines import WAV2VEC2_BASE
print(f"Loading wav2vec2 on {device}...")
bundle = WAV2VEC2_BASE
embedding_model = bundle.get_model().to(device)
embedding_model.eval()
print(" wav2vec2 loaded.")
return embedding_model
def load_classifier():
"""Load trained classifier if available."""
global classifier_data
if classifier_data is None and CLASSIFIER_PATH.exists():
print(f"Loading classifier from {CLASSIFIER_PATH}...")
with open(CLASSIFIER_PATH, 'rb') as f:
classifier_data = pickle.load(f)
print(f" Classifier loaded. Categories: {list(classifier_data['label_encoder'].classes_)}")
return classifier_data
def transcribe(audio: np.ndarray, model) -> str:
"""Transcribe audio to text."""
segments, _ = model.transcribe(audio, language="en", beam_size=5)
return " ".join(seg.text for seg in segments).strip().lower()
def extract_embedding(audio: np.ndarray, model, device: str) -> np.ndarray:
"""Extract embedding from audio using wav2vec2."""
with torch.no_grad():
# Convert to torch tensor
waveform = torch.from_numpy(audio).unsqueeze(0).to(device)
features, _ = model.extract_features(waveform)
# Use the last layer's features, averaged over time
embedding = features[-1].mean(dim=1).cpu().numpy()
return embedding.flatten()
def classify_with_embedding(audio: np.ndarray, emb_model, classifier, device: str) -> tuple[str, float]:
"""Classify audio using embedding + trained classifier."""
embedding = extract_embedding(audio, emb_model, device)
embedding = embedding.reshape(1, -1)
probs = classifier['classifier'].predict_proba(embedding)[0]
pred_idx = probs.argmax()
confidence = probs[pred_idx]
category = classifier['label_encoder'].inverse_transform([pred_idx])[0]
return category, confidence
def check_wake_word(text: str) -> bool:
"""Check if text contains wake phrase."""
for pattern in WAKE_PHRASES:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
def check_confirmation(text: str) -> str | None:
"""Check if text is a yes/no confirmation. Returns 'yes', 'no', or None."""
for pattern in CONFIRM_YES:
if re.search(pattern, text, re.IGNORECASE):
return "yes"
for pattern in CONFIRM_NO:
if re.search(pattern, text, re.IGNORECASE):
return "no"
return None
def log_recording(log_file: str, category: str, timestamp: str):
"""Append confirmed recording to log file."""
import csv
import os
file_exists = os.path.exists(log_file)
with open(log_file, 'a', newline='') as f:
writer = csv.writer(f)
if not file_exists:
writer.writerow(['timestamp', 'category'])
writer.writerow([timestamp, category])
def classify_category(text: str) -> str | None:
"""Classify text into a category. Returns None if no match."""
for category, patterns in CATEGORIES.items():
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return category
return None
def classify_text(text: str) -> tuple[str | None, str | None]:
"""
Check for wake word and classify category from transcription.
Returns (category, text) or (None, None) if no wake word.
"""
if not check_wake_word(text):
return None, None
category = classify_category(text)
return category if category else "unknown", text
class VoiceActivityDetector:
"""VAD-based speech segmentation."""
def __init__(self, aggressiveness: int = 2):
self.vad = webrtcvad.Vad(aggressiveness)
self.ring_buffer = collections.deque(maxlen=NUM_PADDING_FRAMES)
self.triggered = False
self.voiced_frames = []
def process_frame(self, frame: bytes) -> np.ndarray | None:
"""
Process a frame of audio.
Returns complete utterance when speech ends, None otherwise.
"""
is_speech = self.vad.is_speech(frame, SAMPLE_RATE)
if not self.triggered:
self.ring_buffer.append((frame, is_speech))
num_voiced = sum(1 for _, speech in self.ring_buffer if speech)
if num_voiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = True
self.voiced_frames = [f for f, _ in self.ring_buffer]
self.ring_buffer.clear()
else:
self.voiced_frames.append(frame)
self.ring_buffer.append((frame, is_speech))
num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)
if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = False
audio_bytes = b"".join(self.voiced_frames)
self.voiced_frames = []
self.ring_buffer.clear()
# Convert to numpy array
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
return audio
return None
def audio_callback(indata, frames, time_info, status, audio_queue):
"""Callback for audio stream."""
if status:
print(f"Audio status: {status}", file=sys.stderr)
audio_queue.put(bytes(indata))
def main():
parser = argparse.ArgumentParser(description="Voice-activated eye pressure classifier")
parser.add_argument("--model", default="base.en", help="Whisper model name")
parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"], help="Compute device")
parser.add_argument("--list-devices", action="store_true", help="List audio devices and exit")
parser.add_argument("--no-classifier", action="store_true", help="Disable embedding classifier")
parser.add_argument("--confidence-threshold", type=float, default=0.6,
help="Minimum confidence for embedding classifier (default: 0.6)")
parser.add_argument("--wake-timeout", type=float, default=5.0,
help="Seconds to wait for command after wake word (default: 5.0)")
parser.add_argument("--confirm", action="store_true",
help="Require yes/no confirmation before saving")
parser.add_argument("--log-file", type=str, default="recordings_log.csv",
help="File to log confirmed recordings (default: recordings_log.csv)")
args = parser.parse_args()
if args.list_devices:
print(sd.query_devices())
return
# Find microphone
mic_device = get_corsair_device()
if mic_device is None:
print("Corsair microphone not found. Available devices:")
print(sd.query_devices())
sys.exit(1)
print(f"Using microphone: {sd.query_devices(mic_device)['name']}")
print()
# Load models
whisper = load_whisper(args.model, args.device)
# Load embedding classifier if available
classifier = None
emb_model = None
if not args.no_classifier:
classifier = load_classifier()
if classifier:
emb_model = load_embedding_model(args.device)
else:
print(" No classifier found. Run train_classifier.py after collecting samples.")
print()
# Setup VAD and audio queue
vad = VoiceActivityDetector(aggressiveness=2)
audio_queue = queue.Queue()
print("=" * 60)
print("Listening for: \"Ok, note this\"")
print("Then say your command (can pause between wake word and command)")
print("Commands: pressure <15 / 15-25 / 25-35 / >35, L/P/D drop, both eyes")
if classifier:
print(f"Using embedding classifier (confidence threshold: {args.confidence_threshold})")
else:
print("Using transcription-only mode")
if args.confirm:
print("Confirmation mode: say 'yes' to save, 'no' to discard")
print("Press Ctrl+C to stop")
print("=" * 60)
print()
# State machine: idle -> awaiting_command -> (awaiting_confirmation) -> idle
state = "idle"
state_time = 0
pending_category = None
def do_classification(utterance, text, use_embedding=True):
"""Classify utterance. Returns (category, method) or (None, None)."""
final_category = None
method_used = None
# Try embedding classifier first
if use_embedding and classifier and emb_model:
start = time.time()
emb_category, confidence = classify_with_embedding(
utterance, emb_model, classifier, args.device
)
emb_time = time.time() - start
print(f" Embedding: {emb_category} ({confidence:.1%}) [{emb_time:.2f}s]")
if confidence >= args.confidence_threshold:
final_category = emb_category
method_used = f"embedding ({confidence:.1%})"
else:
print(f" Low confidence, trying transcription")
# Fall back to transcription
if final_category is None:
text_category = classify_category(text)
if text_category:
final_category = text_category
method_used = "transcription"
return final_category, method_used
def announce_classification(category, method, ask_confirm=False):
"""Announce the classification result."""
response = CATEGORY_RESPONSES.get(category, category)
print()
print(f" >>> {response} <<< (via {method})")
print()
if ask_confirm:
speak(f"{response}, correct?")
else:
speak(response)
return response
def save_recording(category):
"""Save the confirmed recording."""
from datetime import datetime
timestamp = datetime.now().isoformat()
log_recording(args.log_file, category, timestamp)
print(f" Saved to {args.log_file}")
beep()
try:
with sd.RawInputStream(
samplerate=SAMPLE_RATE,
blocksize=FRAME_SIZE,
device=mic_device,
dtype="int16",
channels=1,
callback=lambda *a: audio_callback(*a, audio_queue),
):
while True:
frame = audio_queue.get()
# Check for timeouts
if state != "idle" and (time.time() - state_time) > args.wake_timeout:
print(" (timeout)")
print()
state = "idle"
pending_category = None
utterance = vad.process_frame(frame)
if utterance is not None and len(utterance) > SAMPLE_RATE * 0.5: # At least 0.5s
print("Processing speech...", end=" ", flush=True)
# Transcribe
start = time.time()
text = transcribe(utterance, whisper)
transcribe_time = time.time() - start
print(f"({transcribe_time:.2f}s)")
print(f" Heard: \"{text}\"")
# State: awaiting confirmation
if state == "awaiting_confirmation":
confirmation = check_confirmation(text)
if confirmation == "yes":
print(" Confirmed!")
save_recording(pending_category)
elif confirmation == "no":
print(" Discarded.")
beep()
else:
print(" Say 'yes' to save or 'no' to discard")
state_time = time.time() # Reset timeout
print()
continue
state = "idle"
pending_category = None
print()
continue
# State: awaiting command
if state == "awaiting_command":
category, method = do_classification(utterance, text)
if category:
announce_classification(category, method, ask_confirm=args.confirm)
if args.confirm:
print(" Say 'yes' to save, 'no' to discard")
state = "awaiting_confirmation"
state_time = time.time()
pending_category = category
else:
save_recording(category)
state = "idle"
else:
print(" Could not classify. Try again.")
state_time = time.time() # Reset timeout
print()
continue
# State: idle - check for wake word
has_wake = check_wake_word(text)
if not has_wake:
print(" (no wake word detected)")
print()
continue
print(" Wake word detected!")
# Try to classify in same utterance
category, method = do_classification(utterance, text)
if category:
announce_classification(category, method, ask_confirm=args.confirm)
if args.confirm:
print(" Say 'yes' to save, 'no' to discard")
state = "awaiting_confirmation"
state_time = time.time()
pending_category = category
else:
save_recording(category)
print()
continue
# No category found - wait for next utterance
print(" Listening for command...")
beep()
state = "awaiting_command"
state_time = time.time()
print()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,6 @@
def main():
print("Hello from real-time!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,16 @@
[project]
name = "real-time"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"faster-whisper>=1.2.1",
"numpy>=2.4.4",
"scikit-learn>=1.8.0",
"scipy>=1.17.1",
"sounddevice>=0.5.5",
"torch>=2.12.0",
"torchaudio>=2.11.0",
"webrtcvad>=2.0.10",
]

View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
exec nix develop --command uv run python listener.py "$@"

View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Test script for Corsair VOID ELITE Wireless microphone
# Uses PipeWire for audio capture
OUTPUT_FILE="test_recording.wav"
DURATION=5
echo "=== Corsair VOID ELITE Microphone Test ==="
echo ""
# Get the Corsair source ID
CORSAIR_SOURCE=$(wpctl status | grep -i "corsair" | grep -i "mono" | grep -oE '[0-9]+\.' | head -1 | tr -d '.')
if [ -z "$CORSAIR_SOURCE" ]; then
echo "ERROR: Corsair microphone not found in PipeWire sources"
wpctl status | grep -A5 "Sources:"
exit 1
fi
echo "Found Corsair mic: Source ID $CORSAIR_SOURCE"
echo "Duration: ${DURATION} seconds"
echo ""
echo "Speak into your Corsair headset microphone..."
echo ""
# Record using pw-record
pw-record --target "$CORSAIR_SOURCE" --rate 48000 --channels 1 --format s16 "$OUTPUT_FILE" &
PW_PID=$!
sleep "$DURATION"
kill "$PW_PID" 2>/dev/null
wait "$PW_PID" 2>/dev/null
if [ -f "$OUTPUT_FILE" ]; then
echo ""
echo "=== Recording complete ==="
echo "Saved to: $OUTPUT_FILE"
echo ""
# Show file info
echo "File info:"
ffprobe -hide_banner "$OUTPUT_FILE" 2>&1 | grep -E "Duration|Stream"
# Check if there's actual audio (not silence)
echo ""
echo "Audio levels:"
ffmpeg -i "$OUTPUT_FILE" -af "volumedetect" -f null /dev/null 2>&1 | grep -E "max_volume|mean_volume"
MAX_VOL=$(ffmpeg -i "$OUTPUT_FILE" -af "volumedetect" -f null /dev/null 2>&1 | grep "max_volume" | sed 's/.*max_volume: \([-0-9.]*\).*/\1/')
echo ""
# Check if max volume is greater than -50 dB (audible)
if [ "$(echo "$MAX_VOL" | cut -d. -f1)" -gt -50 ] 2>/dev/null; then
echo "SUCCESS: Audio detected! Max volume: ${MAX_VOL} dB"
else
echo "WARNING: Very quiet or silent recording (${MAX_VOL} dB)"
echo ""
echo "Possible causes:"
echo " - Mic mute button on headset is ON (flip the switch!)"
echo " - Headset is not being worn / mic boom is retracted"
echo " - Volume too low in system settings"
fi
echo ""
echo "To play back: ffplay -nodisp -autoexit $OUTPUT_FILE"
else
echo ""
echo "=== Recording FAILED ==="
exit 1
fi

View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
Train an audio embedding classifier on recorded samples.
Uses wav2vec2 to extract embeddings, then trains a simple classifier.
Usage:
uv run python train_classifier.py
"""
import pickle
from pathlib import Path
import numpy as np
import torch
from scipy.io import wavfile
from torchaudio.pipelines import WAV2VEC2_BASE
RECORDINGS_DIR = Path(__file__).parent / "recordings"
MODEL_PATH = Path(__file__).parent / "classifier.pkl"
SAMPLE_RATE = 16000
# Categories (excluding wake_word which is handled separately)
CATEGORIES = [
"pressure_below_15",
"pressure_15_to_25",
"pressure_25_to_35",
"pressure_above_35",
"drop_l",
"drop_p",
"drop_d",
"both_eyes",
]
def load_audio(path: Path) -> torch.Tensor:
"""Load and preprocess audio file."""
sr, data = wavfile.read(path)
# Convert to float32
if data.dtype == np.int16:
data = data.astype(np.float32) / 32768.0
elif data.dtype == np.int32:
data = data.astype(np.float32) / 2147483648.0
elif data.dtype != np.float32:
data = data.astype(np.float32)
# Convert to mono if stereo
if len(data.shape) > 1:
data = data.mean(axis=1)
# Resample if needed
if sr != SAMPLE_RATE:
from scipy import signal
num_samples = int(len(data) * SAMPLE_RATE / sr)
data = signal.resample(data, num_samples)
# Convert to torch tensor with batch dimension
waveform = torch.from_numpy(data).unsqueeze(0)
return waveform
def extract_embedding(waveform: torch.Tensor, model, device: str) -> np.ndarray:
"""Extract embedding from audio using wav2vec2."""
with torch.no_grad():
waveform = waveform.to(device)
features, _ = model.extract_features(waveform)
# Use the last layer's features, averaged over time
embedding = features[-1].mean(dim=1).cpu().numpy()
return embedding.flatten()
def load_samples():
"""Load all training samples."""
samples = []
labels = []
for category in CATEGORIES:
category_dir = RECORDINGS_DIR / category
if not category_dir.exists():
print(f"Warning: {category_dir} does not exist")
continue
wav_files = list(category_dir.glob("*.wav"))
print(f" {category}: {len(wav_files)} samples")
for wav_file in wav_files:
samples.append(wav_file)
labels.append(category)
return samples, labels
def main():
print("=== Training Audio Classifier ===\n")
# Check for samples
if not RECORDINGS_DIR.exists():
print(f"Error: {RECORDINGS_DIR} does not exist")
print("Run ./collect_samples.sh first to record training samples")
return
print("Loading samples...")
sample_paths, labels = load_samples()
if len(sample_paths) == 0:
print("\nNo samples found. Run ./collect_samples.sh first.")
return
if len(set(labels)) < 2:
print(f"\nNeed samples from at least 2 categories. Found: {set(labels)}")
return
print(f"\nTotal: {len(sample_paths)} samples across {len(set(labels))} categories")
# Check minimum samples per category
from collections import Counter
counts = Counter(labels)
min_samples = min(counts.values())
if min_samples < 3:
print(f"\nWarning: Some categories have < 3 samples. More samples = better accuracy.")
# Load model
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"\nLoading wav2vec2 on {device}...")
bundle = WAV2VEC2_BASE
model = bundle.get_model().to(device)
model.eval()
# Extract embeddings
print("Extracting embeddings...")
embeddings = []
valid_labels = []
for i, (path, label) in enumerate(zip(sample_paths, labels)):
try:
waveform = load_audio(path)
embedding = extract_embedding(waveform, model, device)
embeddings.append(embedding)
valid_labels.append(label)
print(f" [{i+1}/{len(sample_paths)}] {path.name}")
except Exception as e:
print(f" [{i+1}/{len(sample_paths)}] {path.name} - ERROR: {e}")
if len(embeddings) < 2:
print("\nNot enough valid samples to train.")
return
X = np.array(embeddings)
y = np.array(valid_labels)
print(f"\nEmbedding shape: {X.shape}")
# Train classifier
print("\nTraining classifier...")
from sklearn.preprocessing import LabelEncoder
from sklearn.svm import SVC
from sklearn.model_selection import cross_val_score
label_encoder = LabelEncoder()
y_encoded = label_encoder.fit_transform(y)
# Use SVM with probability estimates
classifier = SVC(kernel='rbf', probability=True, C=1.0)
# Cross-validation if enough samples
if len(X) >= 4:
n_splits = min(5, len(X))
scores = cross_val_score(classifier, X, y_encoded, cv=n_splits)
print(f"Cross-validation accuracy: {scores.mean():.2%} (+/- {scores.std()*2:.2%})")
# Train on all data
classifier.fit(X, y_encoded)
# Save model
model_data = {
'classifier': classifier,
'label_encoder': label_encoder,
'categories': CATEGORIES,
}
with open(MODEL_PATH, 'wb') as f:
pickle.dump(model_data, f)
print(f"\nClassifier saved to: {MODEL_PATH}")
print(f"Categories: {list(label_encoder.classes_)}")
print("\nDone! The listener will now use this classifier.")
if __name__ == "__main__":
main()

1104
clio-ai/real_time/uv.lock generated Normal file

File diff suppressed because it is too large Load diff

10
clio-ai/transcription/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

View file

@ -0,0 +1 @@
3.13

View file

61
clio-ai/transcription/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -0,0 +1,46 @@
{
description = "Audio transcription with faster-whisper (local models only)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
pythonEnv = pkgs.python313.withPackages (ps: with ps; [
pip
]);
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
pythonEnv
uv
ffmpeg
zlib
stdenv.cc.cc.lib
];
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [
pkgs.zlib
pkgs.stdenv.cc.cc.lib
pkgs.ffmpeg
]}:$LD_LIBRARY_PATH"
# Ensure venv exists
if [ ! -d .venv ]; then
uv venv
fi
echo "Transcription environment ready."
echo "Run: uv run python transcribe.py /path/to/recordings"
'';
};
}
);
}

View file

@ -0,0 +1,6 @@
def main():
print("Hello from view-recordings!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,10 @@
[project]
name = "view-recordings"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"faster-whisper>=1.2.1",
"openai-whisper>=20250625",
]

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Transcribe audio recordings using Whisper models via faster-whisper.
For Chromebook (CPU-only): Uses 'tiny' or 'base' models which run reasonably fast.
For GPU (3090): Can use 'large-v3' for best quality.
"""
import argparse
import json
import os
import sys
from datetime import timedelta
from pathlib import Path
def format_timestamp(seconds: float) -> str:
"""Convert seconds to HH:MM:SS.mmm format."""
td = timedelta(seconds=seconds)
hours, remainder = divmod(td.seconds, 3600)
minutes, secs = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{int(td.microseconds/1000):03d}"
def transcribe_file(model, audio_path: Path, output_dir: Path, verbose: bool = False):
"""Transcribe a single audio file and save results."""
print(f"\nTranscribing: {audio_path.name}")
segments, info = model.transcribe(
str(audio_path),
beam_size=5,
vad_filter=True, # Filter out non-speech sections
vad_parameters=dict(
min_silence_duration_ms=500,
speech_pad_ms=200,
),
)
print(f" Detected language: {info.language} (probability: {info.language_probability:.2f})")
print(f" Duration: {info.duration:.1f}s")
# Collect all segments
results = []
full_text = []
for segment in segments:
results.append({
"start": segment.start,
"end": segment.end,
"text": segment.text.strip(),
})
full_text.append(segment.text.strip())
if verbose:
print(f" [{format_timestamp(segment.start)} -> {format_timestamp(segment.end)}] {segment.text.strip()}")
# Save outputs
stem = audio_path.stem
# Save JSON with timestamps
json_path = output_dir / f"{stem}.json"
with open(json_path, "w") as f:
json.dump({
"source_file": str(audio_path),
"language": info.language,
"language_probability": info.language_probability,
"duration": info.duration,
"segments": results,
}, f, indent=2)
# Save plain text
txt_path = output_dir / f"{stem}.txt"
with open(txt_path, "w") as f:
f.write("\n".join(full_text))
print(f" Saved: {json_path.name}, {txt_path.name}")
print(f" Found {len(results)} speech segments")
return results
def main():
parser = argparse.ArgumentParser(description="Transcribe audio files using Whisper")
parser.add_argument(
"input_dir",
type=Path,
nargs="?",
default=None,
help="Directory containing audio files",
)
parser.add_argument(
"-o", "--output-dir",
type=Path,
default=None,
help="Output directory for transcriptions (default: input_dir/transcriptions)",
)
parser.add_argument(
"-m", "--model",
default=os.getenv("WHISPER_MODEL", "large-v3"),
choices=["tiny", "base", "small", "medium", "large-v3"],
help="Whisper model size (default: large-v3, or WHISPER_MODEL env var)",
)
parser.add_argument(
"--device",
default="auto",
choices=["auto", "cpu", "cuda"],
help="Device to use (auto detects GPU)",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Print each segment as it's transcribed",
)
parser.add_argument(
"--file",
type=Path,
default=None,
help="Transcribe a single file instead of directory",
)
args = parser.parse_args()
# Import here to show helpful error if not installed
try:
from faster_whisper import WhisperModel
except ImportError:
print("Error: faster-whisper not installed. Install with:")
print(" uv add faster-whisper")
print(" # or: pip install faster-whisper")
sys.exit(1)
# Determine device
if args.device == "auto":
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
compute_type = "float16" if device == "cuda" else "int8"
elif args.device == "cuda":
device = "cuda"
compute_type = "float16"
else:
device = "cpu"
compute_type = "int8"
print(f"Using device: {device}, compute type: {compute_type}")
print(f"Loading model: {args.model}")
model = WhisperModel(args.model, device=device, compute_type=compute_type)
# Handle single file or directory
if args.file:
audio_files = [args.file]
output_dir = args.output_dir or args.file.parent / "transcriptions"
elif args.input_dir:
input_dir = args.input_dir
if not input_dir.exists():
print(f"Error: Input directory does not exist: {input_dir}")
sys.exit(1)
audio_files = sorted(
p for p in input_dir.iterdir()
if p.suffix.lower() in {".ogg", ".mp3", ".wav", ".m4a", ".flac", ".webm"}
)
output_dir = args.output_dir or input_dir / "transcriptions"
else:
print("Error: Must specify either input_dir or --file")
sys.exit(1)
if not audio_files:
print("No audio files found")
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Found {len(audio_files)} audio file(s)")
print(f"Output directory: {output_dir}")
for audio_path in audio_files:
try:
transcribe_file(model, audio_path, output_dir, verbose=args.verbose)
except Exception as e:
print(f" Error transcribing {audio_path.name}: {e}")
print("\nDone!")
if __name__ == "__main__":
main()

1146
clio-ai/transcription/uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
# Phone Recorder Server
Encrypted audio recording server with transcript viewing capabilities.
## Quick Start
```bash
cd server
nix develop # or activate venv
uv run python main.py
```
Server runs on port 8000 by default.
## Project Structure
```
.
├── app/ # Android app (Kotlin, Android Studio)
├── server/ # FastAPI server (Python)
│ ├── main.py # API endpoints
│ ├── crypto.py # PyNaCl decryption, keypair management
│ ├── database.py # SQLite for recordings metadata
│ ├── importer.py # Import transcriptions from batch processing
│ ├── cli.py # CLI tools
│ ├── config.py # Paths, ports
│ ├── keys/ # Server keypair (auto-generated)
│ └── recordings/ # Decrypted audio files
└── test_recordings/ # Test data
```
## Key Commands
```bash
# Run server
cd server && uv run python main.py
# Run tests
cd server && python test_crypto.py
cd server && python test_integration.py
# CLI tools
cd server && uv run python cli.py --help
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/api/v1/public-key` | GET | Get server public key (Base64) |
| `/api/v1/recordings/upload` | POST | Upload encrypted file from phone |
| `/api/v1/recordings` | GET | List recordings with transcripts |
| `/api/v1/recordings/{id}` | GET | Get recording details |
| `/api/v1/recordings/{id}/audio` | GET | Stream audio file |
| `/api/v1/import/transcription` | POST | Import transcription JSON |
| `/api/v1/highlights` | GET/POST | Manage highlights |
## Security Model
- Phone encrypts with server's public key (sealed box / X25519 + XSalsa20-Poly1305)
- Server decrypts with private key on upload
- Phone never stores unencrypted audio
- Files use SREC format (chunked encryption for large files)
## Frontend
The frontend is a separate static React app in `/work/projects/view_recordings/designer_export/`:
```bash
cd /work/projects/view_recordings/designer_export
python -m http.server 3000
# Open http://localhost:3000/RecordingsViewer.html
```
## Dependencies
- Python 3.x with uv
- PyNaCl (libsodium bindings)
- FastAPI + uvicorn
- SQLite (via database.py)
## Important Files
- `description.md` - Detailed architecture documentation
- `todo.md` - Future features and known limitations
- `server/keys/` - Contains server keypair (do not commit private key)

1
clio-android/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
android_studio_install_path

View file

@ -0,0 +1,61 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.phonerecorder"
compileSdk = 34
defaultConfig {
applicationId = "com.example.phonerecorder"
minSdk = 29
targetSdk = 33
versionCode = 2
versionName = "1.1.0"
// Build timestamp for version display
buildConfigField("long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L")
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// Encryption
implementation("com.goterl:lazysodium-android:5.1.0@aar")
implementation("net.java.dev.jna:jna:5.14.0@aar")
// Background sync
implementation("androidx.work:work-runtime-ktx:2.9.0")
// HTTP client
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

7
clio-android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,7 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /sdk/tools/proguard/proguard-android.txt
# Keep service and receiver classes
-keep class com.example.phonerecorder.RecordingService { *; }
-keep class com.example.phonerecorder.BootReceiver { *; }

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Audio recording -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Auto-start on boot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Keep CPU awake during recording -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Request to ignore battery optimizations -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Network for sync -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PhoneRecorder"
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".RecordingService"
android:exported="false"
android:foregroundServiceType="microphone" />
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,33 @@
package com.example.phonerecorder
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootReceiver"
private const val PREFS_NAME = "phone_recorder_prefs"
private const val PREF_AUTO_START = "auto_start_enabled"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed received")
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val autoStartEnabled = prefs.getBoolean(PREF_AUTO_START, false)
if (autoStartEnabled) {
Log.i(TAG, "Auto-start enabled, starting recording service")
val serviceIntent = Intent(context, RecordingService::class.java)
ContextCompat.startForegroundService(context, serviceIntent)
} else {
Log.i(TAG, "Auto-start disabled, not starting service")
}
}
}
}

View file

@ -0,0 +1,540 @@
package com.example.phonerecorder
import android.Manifest
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.BatteryManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.PowerManager
import android.provider.Settings
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.example.phonerecorder.crypto.KeyManager
import com.example.phonerecorder.sync.NetworkHelper
import com.example.phonerecorder.sync.SyncManager
import com.example.phonerecorder.sync.SyncMode
import com.example.phonerecorder.sync.SyncSettings
import com.example.phonerecorder.sync.SyncStatus
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import java.util.Locale
class MainActivity : AppCompatActivity() {
companion object {
private const val PREFS_NAME = "phone_recorder_prefs"
private const val PREF_AUTO_START = "auto_start_enabled"
}
private lateinit var statusIndicator: android.view.View
private lateinit var statusText: TextView
private lateinit var elapsedTimeText: TextView
private lateinit var currentFileText: TextView
private lateinit var storageLocationText: TextView
private lateinit var storageInfoText: TextView
private lateinit var batteryInfoText: TextView
private lateinit var toggleButton: MaterialButton
private lateinit var autoStartSwitch: SwitchMaterial
private lateinit var batteryOptimizationHint: TextView
private lateinit var syncModeSpinner: Spinner
private lateinit var keyStatusText: TextView
private lateinit var importKeyButton: MaterialButton
private lateinit var syncStatusText: TextView
private lateinit var networkInfoText: TextView
private lateinit var configureLanButton: MaterialButton
private lateinit var versionText: TextView
private lateinit var storageHelper: StorageHelper
private lateinit var prefs: SharedPreferences
private lateinit var keyManager: KeyManager
private lateinit var syncManager: SyncManager
private lateinit var networkHelper: NetworkHelper
private lateinit var syncSettings: SyncSettings
private var recordingService: RecordingService? = null
private var isBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as RecordingService.RecordingBinder
recordingService = binder.getService()
isBound = true
updateUI()
}
override fun onServiceDisconnected(name: ComponentName?) {
recordingService = null
isBound = false
}
}
private val recordingReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
RecordingService.ACTION_RECORDING_STARTED,
RecordingService.ACTION_RECORDING_STOPPED,
RecordingService.ACTION_RECORDING_UPDATE -> updateUI()
RecordingService.ACTION_LOW_STORAGE -> {
Toast.makeText(
this@MainActivity,
R.string.storage_warning,
Toast.LENGTH_LONG
).show()
updateUI()
updateSyncStatus()
}
}
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.all { it.value }
if (allGranted) {
startRecordingService()
} else {
Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
storageHelper = StorageHelper(this)
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyManager = KeyManager(this)
syncManager = SyncManager.getInstance(this)
networkHelper = NetworkHelper(this)
syncSettings = SyncSettings(this)
initViews()
setupListeners()
setupSyncUI()
checkBatteryOptimization()
// Start periodic sync based on settings
syncManager.startPeriodicSync()
}
override fun onStart() {
super.onStart()
bindRecordingService()
registerReceivers()
updateUI()
updateNetworkInfo()
updateSyncStatus()
}
override fun onStop() {
super.onStop()
unregisterReceivers()
unbindRecordingService()
}
private fun initViews() {
statusIndicator = findViewById(R.id.statusIndicator)
statusText = findViewById(R.id.statusText)
elapsedTimeText = findViewById(R.id.elapsedTimeText)
currentFileText = findViewById(R.id.currentFileText)
storageLocationText = findViewById(R.id.storageLocationText)
storageInfoText = findViewById(R.id.storageInfoText)
batteryInfoText = findViewById(R.id.batteryInfoText)
toggleButton = findViewById(R.id.toggleButton)
autoStartSwitch = findViewById(R.id.autoStartSwitch)
batteryOptimizationHint = findViewById(R.id.batteryOptimizationHint)
// Initialize auto-start switch state
autoStartSwitch.isChecked = prefs.getBoolean(PREF_AUTO_START, false)
updateAutoStartText()
// Sync UI elements
syncModeSpinner = findViewById(R.id.syncModeSpinner)
keyStatusText = findViewById(R.id.keyStatusText)
importKeyButton = findViewById(R.id.importKeyButton)
syncStatusText = findViewById(R.id.syncStatusText)
networkInfoText = findViewById(R.id.networkInfoText)
configureLanButton = findViewById(R.id.configureLanButton)
versionText = findViewById(R.id.versionText)
// Set version info
val buildDate = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US)
.format(java.util.Date(BuildConfig.BUILD_TIMESTAMP))
versionText.text = "v${BuildConfig.VERSION_NAME} (build $buildDate)"
}
private fun setupListeners() {
toggleButton.setOnClickListener {
if (recordingService?.isRecording() == true) {
stopRecordingService()
} else {
checkPermissionsAndStart()
}
}
autoStartSwitch.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(PREF_AUTO_START, isChecked).apply()
updateAutoStartText()
}
batteryOptimizationHint.setOnClickListener {
openBatteryOptimizationSettings()
}
importKeyButton.setOnClickListener {
showImportKeyDialog()
}
configureLanButton.setOnClickListener {
showLanConfigDialog()
}
}
private fun setupSyncUI() {
// Setup sync mode spinner
val syncModes = arrayOf(
getString(R.string.sync_mode_never),
getString(R.string.sync_mode_lan_only),
getString(R.string.sync_mode_wifi_only),
getString(R.string.sync_mode_any_network)
)
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, syncModes)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
syncModeSpinner.adapter = adapter
// Set current selection
syncModeSpinner.setSelection(syncManager.getSyncMode().ordinal)
// Handle selection changes
syncModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val mode = SyncMode.entries[position]
syncManager.setSyncMode(mode)
updateSyncStatus()
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
updateKeyStatus()
updateSyncStatus()
updateNetworkInfo()
}
private fun updateNetworkInfo() {
val ipAddress = networkHelper.getCurrentIpAddress()
networkInfoText.text = when {
ipAddress != null && networkHelper.isOnWifi() ->
getString(R.string.network_wifi, ipAddress)
networkHelper.isNetworkAvailable() ->
getString(R.string.network_mobile)
else ->
getString(R.string.network_not_connected)
}
}
private fun showLanConfigDialog() {
val currentIp = networkHelper.getCurrentIpAddress() ?: "Not connected"
val currentServerUrl = syncSettings.lanServerUrl
val currentPrefix = syncSettings.lanSubnetPrefix
val currentApiKey = syncSettings.apiKey
// Create a layout with fields
val layout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(48, 24, 48, 0)
}
val serverLabel = TextView(this).apply {
text = "LAN Server URL:"
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
}
layout.addView(serverLabel)
val serverEdit = EditText(this).apply {
hint = "http://10.0.0.45:8000"
setText(currentServerUrl)
isSingleLine = true
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
}
layout.addView(serverEdit)
val prefixLabel = TextView(this).apply {
text = "\nLAN Subnet Prefix:"
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
}
layout.addView(prefixLabel)
val prefixEdit = EditText(this).apply {
hint = getString(R.string.lan_subnet_hint)
setText(currentPrefix)
isSingleLine = true
}
layout.addView(prefixEdit)
val apiKeyLabel = TextView(this).apply {
text = "\nAPI Key (for upload auth):"
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
}
layout.addView(apiKeyLabel)
val apiKeyEdit = EditText(this).apply {
hint = "Leave empty if not required"
setText(currentApiKey)
isSingleLine = true
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
layout.addView(apiKeyEdit)
val infoText = TextView(this).apply {
text = "\nYour IP: $currentIp"
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
textSize = 12f
}
layout.addView(infoText)
AlertDialog.Builder(this)
.setTitle(R.string.lan_config_title)
.setView(layout)
.setPositiveButton(android.R.string.ok) { _, _ ->
// Save server URL
val serverUrl = serverEdit.text.toString().trim()
if (serverUrl.isNotEmpty()) {
syncSettings.lanServerUrl = serverUrl
}
// Save subnet prefix
var prefix = prefixEdit.text.toString().trim()
if (prefix.isNotEmpty() && !prefix.endsWith(".")) {
prefix = "$prefix."
}
if (prefix.isNotEmpty()) {
syncSettings.lanSubnetPrefix = prefix
}
// Save API key
syncSettings.apiKey = apiKeyEdit.text.toString().trim()
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
updateNetworkInfo()
updateSyncStatus()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showImportKeyDialog() {
val editText = EditText(this).apply {
hint = getString(R.string.import_key_hint)
isSingleLine = false
minLines = 2
maxLines = 4
}
AlertDialog.Builder(this)
.setTitle(R.string.import_key_title)
.setView(editText)
.setPositiveButton(android.R.string.ok) { _, _ ->
val key = editText.text.toString().trim()
if (keyManager.setPublicKey(key)) {
Toast.makeText(this, R.string.import_key_success, Toast.LENGTH_SHORT).show()
updateKeyStatus()
updateSyncStatus()
} else {
Toast.makeText(this, R.string.import_key_error, Toast.LENGTH_LONG).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun updateKeyStatus() {
keyStatusText.text = if (keyManager.hasPublicKey()) {
getString(R.string.key_status_configured)
} else {
getString(R.string.key_status_not_configured)
}
}
private fun updateSyncStatus() {
val status = syncManager.getSyncStatus(keyManager.hasPublicKey())
syncStatusText.text = when (status) {
is SyncStatus.Disabled -> getString(R.string.sync_status_disabled)
is SyncStatus.NoKey -> getString(R.string.sync_status_no_key)
is SyncStatus.NotOnWifi -> getString(R.string.sync_status_not_on_wifi)
is SyncStatus.NotOnLan -> getString(R.string.sync_status_not_on_lan)
is SyncStatus.NoNetwork -> getString(R.string.sync_status_no_network)
is SyncStatus.Ready -> getString(R.string.sync_status_pending, status.pending)
is SyncStatus.Synced -> getString(R.string.sync_status_synced)
}
}
private fun updateAutoStartText() {
autoStartSwitch.text = if (autoStartSwitch.isChecked) {
getString(R.string.auto_start_enabled)
} else {
getString(R.string.auto_start_disabled)
}
}
private fun checkPermissionsAndStart() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val notGranted = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isEmpty()) {
startRecordingService()
} else {
permissionLauncher.launch(notGranted.toTypedArray())
}
}
private fun startRecordingService() {
val intent = Intent(this, RecordingService::class.java)
ContextCompat.startForegroundService(this, intent)
bindRecordingService()
}
private fun stopRecordingService() {
recordingService?.stopRecording()
stopService(Intent(this, RecordingService::class.java))
}
private fun bindRecordingService() {
val intent = Intent(this, RecordingService::class.java)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun unbindRecordingService() {
if (isBound) {
unbindService(serviceConnection)
isBound = false
}
}
private fun registerReceivers() {
val filter = IntentFilter().apply {
addAction(RecordingService.ACTION_RECORDING_STARTED)
addAction(RecordingService.ACTION_RECORDING_STOPPED)
addAction(RecordingService.ACTION_RECORDING_UPDATE)
addAction(RecordingService.ACTION_LOW_STORAGE)
}
LocalBroadcastManager.getInstance(this).registerReceiver(recordingReceiver, filter)
}
private fun unregisterReceivers() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(recordingReceiver)
}
private fun updateUI() {
val isRecording = recordingService?.isRecording() == true
// Status indicator
statusIndicator.setBackgroundResource(
if (isRecording) R.drawable.status_circle_active else R.drawable.status_circle
)
statusText.text = getString(
if (isRecording) R.string.status_recording else R.string.status_stopped
)
// Elapsed time
val elapsed = recordingService?.getElapsedTime() ?: 0L
elapsedTimeText.text = formatElapsedTime(elapsed)
// Current file
val filename = recordingService?.getCurrentFilename()
currentFileText.text = if (filename != null) {
getString(R.string.current_file, filename)
} else {
getString(R.string.current_file, getString(R.string.no_file))
}
// Storage info
val storageInfo = storageHelper.getStorageInfo()
storageLocationText.text = getString(
if (storageInfo.isSDCard) R.string.storage_location_sd else R.string.storage_location_internal
)
storageInfoText.text = getString(
R.string.storage_info,
storageHelper.formatBytes(storageInfo.usedBytes),
storageHelper.formatBytes(storageInfo.availableBytes)
)
// Battery info
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
batteryInfoText.text = getString(R.string.battery_info, batteryLevel)
// Toggle button
toggleButton.text = getString(
if (isRecording) R.string.stop_recording else R.string.start_recording
)
}
private fun formatElapsedTime(millis: Long): String {
val seconds = (millis / 1000) % 60
val minutes = (millis / (1000 * 60)) % 60
val hours = millis / (1000 * 60 * 60)
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
}
private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
showBatteryOptimizationDialog()
} else {
batteryOptimizationHint.visibility = android.view.View.GONE
}
}
private fun showBatteryOptimizationDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.battery_optimization_title)
.setMessage(R.string.battery_optimization_message)
.setPositiveButton(R.string.battery_optimization_button) { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun openBatteryOptimizationSettings() {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
} catch (e: Exception) {
// Fall back to general battery settings
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent)
}
}
}

View file

@ -0,0 +1,378 @@
package com.example.phonerecorder
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.MediaRecorder
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.example.phonerecorder.crypto.CryptoHelper
import com.example.phonerecorder.crypto.KeyManager
import com.example.phonerecorder.sync.SyncManager
import java.io.File
import java.util.Locale
class RecordingService : Service() {
companion object {
private const val TAG = "RecordingService"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "recording_channel"
// Segment rotation: 2 hours in milliseconds
private const val SEGMENT_DURATION_MS = 2 * 60 * 60 * 1000L
// Storage check interval: 5 minutes
private const val STORAGE_CHECK_INTERVAL_MS = 5 * 60 * 1000L
// Notification update interval: 1 second
private const val NOTIFICATION_UPDATE_INTERVAL_MS = 1000L
// Broadcast actions
const val ACTION_RECORDING_STARTED = "com.example.phonerecorder.RECORDING_STARTED"
const val ACTION_RECORDING_STOPPED = "com.example.phonerecorder.RECORDING_STOPPED"
const val ACTION_RECORDING_UPDATE = "com.example.phonerecorder.RECORDING_UPDATE"
const val ACTION_LOW_STORAGE = "com.example.phonerecorder.LOW_STORAGE"
// Intent extras
const val EXTRA_FILENAME = "filename"
const val EXTRA_ELAPSED_TIME = "elapsed_time"
}
private val binder = RecordingBinder()
private var mediaRecorder: MediaRecorder? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var storageHelper: StorageHelper
private lateinit var notificationManager: NotificationManager
private lateinit var keyManager: KeyManager
private lateinit var cryptoHelper: CryptoHelper
private var syncManager: SyncManager? = null
private var isRecording = false
private var currentTempFile: File? = null
private var currentFile: File? = null
private var segmentStartTime: Long = 0
private var totalElapsedTime: Long = 0
private val handler = Handler(Looper.getMainLooper())
private val segmentRotationRunnable = Runnable { rotateSegment() }
private val storageCheckRunnable = object : Runnable {
override fun run() {
checkStorage()
if (isRecording) {
handler.postDelayed(this, STORAGE_CHECK_INTERVAL_MS)
}
}
}
private val notificationUpdateRunnable = object : Runnable {
override fun run() {
updateNotification()
broadcastUpdate()
if (isRecording) {
handler.postDelayed(this, NOTIFICATION_UPDATE_INTERVAL_MS)
}
}
}
inner class RecordingBinder : Binder() {
fun getService(): RecordingService = this@RecordingService
}
override fun onCreate() {
super.onCreate()
storageHelper = StorageHelper(this)
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
keyManager = KeyManager(this)
cryptoHelper = CryptoHelper(keyManager)
syncManager = SyncManager.getInstance(this)
createNotificationChannel()
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isRecording) {
startRecording()
}
return START_STICKY
}
override fun onDestroy() {
stopRecording()
super.onDestroy()
}
fun isRecording(): Boolean = isRecording
fun getCurrentFilename(): String? = currentFile?.name
fun getElapsedTime(): Long {
return if (isRecording) {
totalElapsedTime + (System.currentTimeMillis() - segmentStartTime)
} else {
totalElapsedTime
}
}
fun startRecording() {
if (isRecording) return
if (storageHelper.isStorageLow()) {
broadcastLowStorage()
return
}
try {
acquireWakeLock()
startNewSegment()
isRecording = true
totalElapsedTime = 0
// Schedule segment rotation
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
// Start storage checks
handler.postDelayed(storageCheckRunnable, STORAGE_CHECK_INTERVAL_MS)
// Start notification updates
handler.post(notificationUpdateRunnable)
// Start foreground service
startForeground(NOTIFICATION_ID, createNotification())
broadcastRecordingStarted()
Log.i(TAG, "Recording started: ${currentFile?.name}")
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording", e)
stopRecording()
}
}
fun stopRecording() {
if (!isRecording) return
isRecording = false
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
handler.removeCallbacks(segmentRotationRunnable)
handler.removeCallbacks(storageCheckRunnable)
handler.removeCallbacks(notificationUpdateRunnable)
releaseMediaRecorder()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
broadcastRecordingStopped()
Log.i(TAG, "Recording stopped")
}
private fun startNewSegment() {
releaseMediaRecorder()
// Record to temp file first (will be encrypted after recording stops)
currentTempFile = storageHelper.getNewTempRecordingFile()
segmentStartTime = System.currentTimeMillis()
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
mediaRecorder?.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.OGG)
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
setAudioEncodingBitRate(64000)
setAudioSamplingRate(48000)
setAudioChannels(1)
setOutputFile(currentTempFile?.absolutePath)
prepare()
start()
}
// Update currentFile to show the expected encrypted filename
currentFile = currentTempFile?.let { storageHelper.getEncryptedFileForTemp(it) }
Log.i(TAG, "New segment started: ${currentTempFile?.name}")
}
private fun rotateSegment() {
if (!isRecording) return
Log.i(TAG, "Rotating segment...")
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
try {
startNewSegment()
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
} catch (e: Exception) {
Log.e(TAG, "Failed to rotate segment", e)
stopRecording()
}
}
private fun checkStorage() {
if (storageHelper.isStorageLow()) {
Log.w(TAG, "Low storage detected, stopping recording")
stopRecording()
broadcastLowStorage()
}
}
private fun releaseMediaRecorder() {
val tempFile = currentTempFile
try {
mediaRecorder?.apply {
stop()
release()
}
} catch (e: Exception) {
Log.e(TAG, "Error releasing MediaRecorder", e)
}
mediaRecorder = null
// Encrypt the completed recording
if (tempFile != null && tempFile.exists()) {
encryptCompletedRecording(tempFile)
}
currentTempFile = null
}
/**
* Encrypts a completed recording and deletes the temp file.
* If no public key is available, the recording is NOT stored (security requirement).
*/
private fun encryptCompletedRecording(tempFile: File) {
if (!cryptoHelper.hasPublicKey()) {
Log.w(TAG, "No public key configured - deleting unencrypted recording for security")
tempFile.delete()
return
}
val encryptedFile = storageHelper.getEncryptedFileForTemp(tempFile)
try {
val success = cryptoHelper.encryptFile(tempFile, encryptedFile)
if (success) {
// Delete temp file after successful encryption
tempFile.delete()
currentFile = encryptedFile
Log.i(TAG, "Recording encrypted: ${encryptedFile.name}")
// Trigger immediate sync
syncManager?.scheduleImmediateSync()
} else {
Log.e(TAG, "Failed to encrypt recording - deleting temp file for security")
tempFile.delete()
encryptedFile.delete() // Clean up partial encrypted file
}
} catch (e: Exception) {
Log.e(TAG, "Error during encryption - deleting files for security", e)
tempFile.delete()
encryptedFile.delete()
}
}
private fun acquireWakeLock() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"PhoneRecorder::RecordingWakeLock"
).apply {
acquire()
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_description)
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
}
private fun createNotification(): Notification {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val elapsedFormatted = formatElapsedTime(getElapsedTime())
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text, elapsedFormatted))
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
.build()
}
private fun updateNotification() {
if (isRecording) {
notificationManager.notify(NOTIFICATION_ID, createNotification())
}
}
private fun formatElapsedTime(millis: Long): String {
val seconds = (millis / 1000) % 60
val minutes = (millis / (1000 * 60)) % 60
val hours = millis / (1000 * 60 * 60)
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
}
private fun broadcastRecordingStarted() {
val intent = Intent(ACTION_RECORDING_STARTED).apply {
putExtra(EXTRA_FILENAME, currentFile?.name)
}
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
private fun broadcastRecordingStopped() {
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_RECORDING_STOPPED))
}
private fun broadcastUpdate() {
val intent = Intent(ACTION_RECORDING_UPDATE).apply {
putExtra(EXTRA_FILENAME, currentFile?.name)
putExtra(EXTRA_ELAPSED_TIME, getElapsedTime())
}
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
private fun broadcastLowStorage() {
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_LOW_STORAGE))
}
}

View file

@ -0,0 +1,222 @@
package com.example.phonerecorder
import android.content.Context
import android.os.StatFs
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class StorageHelper(private val context: Context) {
companion object {
private const val RECORDINGS_FOLDER = "recordings"
private const val TEMP_FOLDER = "temp"
private const val LOW_STORAGE_THRESHOLD_MB = 500L
private const val BYTES_PER_MB = 1024L * 1024L
private const val BYTES_PER_GB = 1024L * 1024L * 1024L
}
data class StorageInfo(
val usedBytes: Long,
val availableBytes: Long,
// this should be a storage type, not an isSDCard
val isSDCard: Boolean,
val recordingsDir: File
)
/**
* Gets the best available storage location.
* Prefers SD card, falls back to internal storage.
*/
fun getRecordingsDirectory(): File {
val externalDirs = context.getExternalFilesDirs(null)
// Find SD card (second external storage, if available)
val sdCardDir = externalDirs.getOrNull(1)
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
return ensureRecordingsFolder(sdCardDir)
}
// Fall back to primary external storage
val primaryDir = externalDirs.getOrNull(0)
if (primaryDir != null && isStorageAvailable(primaryDir)) {
return ensureRecordingsFolder(primaryDir)
}
// Last resort: internal files directory
return ensureRecordingsFolder(context.filesDir)
}
/**
* Checks if we're using SD card storage.
*/
fun isUsingSDCard(): Boolean {
val externalDirs = context.getExternalFilesDirs(null)
val sdCardDir = externalDirs.getOrNull(1)
return sdCardDir != null && isStorageAvailable(sdCardDir)
}
/**
* Gets storage information for the recordings directory.
*/
fun getStorageInfo(): StorageInfo {
val recordingsDir = getRecordingsDirectory()
val parentDir = recordingsDir.parentFile ?: recordingsDir
val statFs = StatFs(parentDir.absolutePath)
val availableBytes = statFs.availableBytes
val usedBytes = calculateUsedStorage(recordingsDir)
return StorageInfo(
usedBytes = usedBytes,
availableBytes = availableBytes,
isSDCard = isUsingSDCard(),
recordingsDir = recordingsDir
)
}
/**
* Checks if storage is below the low threshold.
*/
fun isStorageLow(): Boolean {
val info = getStorageInfo()
return info.availableBytes < LOW_STORAGE_THRESHOLD_MB * BYTES_PER_MB
}
/**
* Gets the temp directory for unencrypted recordings during recording.
*/
fun getTempDirectory(): File {
val baseDir = getBaseStorageDirectory()
return ensureFolder(baseDir, TEMP_FOLDER)
}
/**
* Gets the base storage directory (parent of recordings and temp).
*/
private fun getBaseStorageDirectory(): File {
val externalDirs = context.getExternalFilesDirs(null)
// Find SD card (second external storage, if available)
val sdCardDir = externalDirs.getOrNull(1)
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
return sdCardDir
}
// Fall back to primary external storage
val primaryDir = externalDirs.getOrNull(0)
if (primaryDir != null && isStorageAvailable(primaryDir)) {
return primaryDir
}
// Last resort: internal files directory
return context.filesDir
}
/**
* Generates a new recording filename with current timestamp (for encrypted files).
*/
fun generateFilename(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
val timestamp = dateFormat.format(Date())
return "recording_$timestamp.ogg.enc"
}
/**
* Generates a temp filename (unencrypted, used during recording).
*/
fun generateTempFilename(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
val timestamp = dateFormat.format(Date())
return "recording_$timestamp.ogg"
}
/**
* Gets the full path for a new temp recording file (unencrypted during recording).
*/
fun getNewTempRecordingFile(): File {
val dir = getTempDirectory()
return File(dir, generateTempFilename())
}
/**
* Gets the full path for a new encrypted recording file.
*/
fun getNewRecordingFile(): File {
val dir = getRecordingsDirectory()
return File(dir, generateFilename())
}
/**
* Gets the encrypted file path corresponding to a temp file.
*/
fun getEncryptedFileForTemp(tempFile: File): File {
val dir = getRecordingsDirectory()
val encryptedName = tempFile.name + ".enc"
return File(dir, encryptedName)
}
/**
* Lists all unsynced encrypted recording files.
*/
fun listUnsyncedRecordings(syncedFiles: Set<String>): List<File> {
val dir = getRecordingsDirectory()
return dir.listFiles { file -> file.name.endsWith(".ogg.enc") }
?.filter { !syncedFiles.contains(it.name) }
?.sortedBy { it.lastModified() }
?: emptyList()
}
/**
* Formats bytes as human-readable string (e.g., "2.5 GB").
*/
fun formatBytes(bytes: Long): String {
return when {
bytes >= BYTES_PER_GB -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / BYTES_PER_GB)
bytes >= BYTES_PER_MB -> String.format(Locale.US, "%.1f MB", bytes.toDouble() / BYTES_PER_MB)
else -> String.format(Locale.US, "%d KB", bytes / 1024)
}
}
/**
* Lists all recording files sorted by date (newest first).
* Includes both encrypted (.ogg.enc) and unencrypted (.ogg) files.
*/
fun listRecordings(): List<File> {
val dir = getRecordingsDirectory()
return dir.listFiles { file ->
file.name.endsWith(".ogg") || file.name.endsWith(".ogg.enc")
}
?.sortedByDescending { it.lastModified() }
?: emptyList()
}
private fun isStorageAvailable(dir: File): Boolean {
return try {
if (!dir.exists()) {
dir.mkdirs()
}
dir.canWrite()
} catch (e: Exception) {
false
}
}
private fun ensureRecordingsFolder(baseDir: File): File {
return ensureFolder(baseDir, RECORDINGS_FOLDER)
}
private fun ensureFolder(baseDir: File, folderName: String): File {
val dir = File(baseDir, folderName)
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
private fun calculateUsedStorage(dir: File): Long {
if (!dir.exists()) return 0L
return dir.listFiles()?.sumOf { it.length() } ?: 0L
}
}

View file

@ -0,0 +1,229 @@
package com.example.phonerecorder.api
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
class RecordingsApi {
companion object {
private const val TAG = "RecordingsApi"
private const val CONNECT_TIMEOUT_SECONDS = 30L
private const val READ_TIMEOUT_SECONDS = 120L
private const val WRITE_TIMEOUT_SECONDS = 120L
}
private val client = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
/**
* Checks if the server is healthy and reachable.
*
* @param serverUrl The base server URL
* @return true if server responds with status "ok"
*/
fun checkHealth(serverUrl: String): Boolean {
val request = Request.Builder()
.url("$serverUrl/health")
.get()
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
val json = JSONObject(body ?: "{}")
json.optString("status") == "ok"
} else {
Log.w(TAG, "Health check failed: ${response.code}")
false
}
}
} catch (e: IOException) {
Log.e(TAG, "Health check error", e)
false
}
}
/**
* Fetches the server's public key for encryption.
*
* @param serverUrl The base server URL
* @return Base64-encoded public key, or null on failure
*/
fun getPublicKey(serverUrl: String): String? {
val request = Request.Builder()
.url("$serverUrl/api/v1/public-key")
.get()
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
val json = JSONObject(body ?: "{}")
json.optString("public_key", null)
} else {
Log.w(TAG, "Get public key failed: ${response.code}")
null
}
}
} catch (e: IOException) {
Log.e(TAG, "Get public key error", e)
null
}
}
/**
* Uploads an encrypted recording file to the server.
* Verifies the server received the correct data by comparing SHA-256 hashes.
*
* @param serverUrl The base server URL
* @param file The encrypted file to upload
* @param apiKey Optional API key for authentication
* @return UploadResult indicating success or failure with message
*/
fun uploadRecording(serverUrl: String, file: File, apiKey: String? = null): UploadResult {
// Compute hash of file before upload for verification
val localHash = computeFileHash(file)
if (localHash == null) {
Log.e(TAG, "Failed to compute hash for: ${file.name}")
return UploadResult(false, "Failed to compute file hash")
}
val mediaType = "application/octet-stream".toMediaType()
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
file.name,
file.asRequestBody(mediaType)
)
.build()
val requestBuilder = Request.Builder()
.url("$serverUrl/api/v1/recordings/upload")
.post(requestBody)
if (!apiKey.isNullOrEmpty()) {
requestBuilder.addHeader("X-API-Key", apiKey)
}
val request = requestBuilder.build()
return try {
client.newCall(request).execute().use { response ->
val body = response.body?.string()
if (response.isSuccessful) {
// Verify server received correct data
val json = JSONObject(body ?: "{}")
val serverHash = json.optString("received_hash", "")
val verified = json.optBoolean("verified", false)
if (serverHash.isNotEmpty() && serverHash == localHash && verified) {
Log.i(TAG, "Upload verified: ${file.name} (hash: ${localHash.take(8)}...)")
UploadResult(true, "Upload verified", verified = true)
} else if (serverHash.isEmpty()) {
// Legacy server without hash verification
Log.w(TAG, "Upload successful but unverified (no hash): ${file.name}")
UploadResult(true, "Upload successful (unverified)", verified = false)
} else {
Log.e(TAG, "Hash mismatch! Local: $localHash, Server: $serverHash")
UploadResult(false, "Data corruption detected - hash mismatch")
}
} else {
val errorMessage = try {
val json = JSONObject(body ?: "{}")
json.optString("detail", "Upload failed: ${response.code}")
} catch (e: Exception) {
"Upload failed: ${response.code}"
}
Log.w(TAG, "Upload failed: $errorMessage")
UploadResult(false, errorMessage)
}
}
} catch (e: IOException) {
Log.e(TAG, "Upload error", e)
UploadResult(false, "Network error: ${e.message}")
}
}
/**
* Computes SHA-256 hash of a file.
*/
private fun computeFileHash(file: File): String? {
return try {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { fis ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (fis.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
digest.digest().joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e(TAG, "Hash computation failed", e)
null
}
}
/**
* Lists recordings on the server, optionally filtered by date.
*
* @param serverUrl The base server URL
* @param date Optional date filter in YYYY-MM-DD format
* @return List of recording filenames, or null on failure
*/
fun listRecordings(serverUrl: String, date: String? = null): List<String>? {
val url = if (date != null) {
"$serverUrl/api/v1/recordings?date=$date"
} else {
"$serverUrl/api/v1/recordings"
}
val request = Request.Builder()
.url(url)
.get()
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
val json = JSONObject(body ?: "{}")
val recordings = json.optJSONArray("recordings")
if (recordings != null) {
(0 until recordings.length()).map { recordings.getString(it) }
} else {
emptyList()
}
} else {
Log.w(TAG, "List recordings failed: ${response.code}")
null
}
}
} catch (e: IOException) {
Log.e(TAG, "List recordings error", e)
null
}
}
data class UploadResult(
val success: Boolean,
val message: String,
val verified: Boolean = false
)
}

View file

@ -0,0 +1,124 @@
package com.example.phonerecorder.crypto
import android.util.Log
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.Box
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
class CryptoHelper(private val keyManager: KeyManager) {
companion object {
private const val TAG = "CryptoHelper"
// File format constants
private val MAGIC_BYTES = byteArrayOf('S'.code.toByte(), 'R'.code.toByte(), 'E'.code.toByte(), 'C'.code.toByte())
private const val VERSION: Byte = 1
private const val CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming encryption
}
private val lazySodium: LazySodiumAndroid = LazySodiumAndroid(SodiumAndroid())
/**
* Encrypts a file using libsodium sealed box encryption.
*
* File format:
* [4 bytes: "SREC" magic]
* [1 byte: version (1)]
* [4 bytes: chunk count (big-endian)]
* For each chunk:
* [4 bytes: encrypted length (big-endian)]
* [N bytes: sealed box encrypted data]
*
* @param inputFile The unencrypted source file
* @param outputFile The destination for the encrypted file
* @return true if encryption succeeded, false otherwise
*/
fun encryptFile(inputFile: File, outputFile: File): Boolean {
val publicKey = keyManager.getPublicKey()
if (publicKey == null) {
Log.e(TAG, "No public key available for encryption")
return false
}
try {
FileInputStream(inputFile).use { input ->
FileOutputStream(outputFile).use { output ->
// Calculate number of chunks
val fileSize = inputFile.length()
val chunkCount = ((fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
// Write header
output.write(MAGIC_BYTES)
output.write(VERSION.toInt())
output.write(intToBytes(chunkCount))
// Encrypt and write each chunk
val buffer = ByteArray(CHUNK_SIZE)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
val chunk = if (bytesRead < CHUNK_SIZE) {
buffer.copyOf(bytesRead)
} else {
buffer
}
val encrypted = encryptChunk(chunk, publicKey)
if (encrypted == null) {
Log.e(TAG, "Failed to encrypt chunk")
return false
}
// Write encrypted chunk length and data
output.write(intToBytes(encrypted.size))
output.write(encrypted)
}
}
}
Log.i(TAG, "Successfully encrypted file: ${inputFile.name} -> ${outputFile.name}")
return true
} catch (e: IOException) {
Log.e(TAG, "IO error during encryption", e)
return false
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during encryption", e)
return false
}
}
/**
* Encrypts a single chunk using sealed box encryption.
* Sealed box provides anonymous sender encryption using X25519 + XSalsa20-Poly1305.
*/
private fun encryptChunk(data: ByteArray, publicKey: ByteArray): ByteArray? {
return try {
// Sealed box overhead: crypto_box_SEALBYTES (48 bytes)
val ciphertext = ByteArray(data.size + Box.SEALBYTES)
val success = lazySodium.cryptoBoxSeal(ciphertext, data, data.size.toLong(), publicKey)
if (success) ciphertext else null
} catch (e: Exception) {
Log.e(TAG, "Encryption failed", e)
null
}
}
private fun intToBytes(value: Int): ByteArray {
return ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(value)
.array()
}
/**
* Checks if a public key is available for encryption.
*/
fun hasPublicKey(): Boolean = keyManager.getPublicKey() != null
}

View file

@ -0,0 +1,77 @@
package com.example.phonerecorder.crypto
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import com.goterl.lazysodium.interfaces.Box
class KeyManager(context: Context) {
companion object {
private const val TAG = "KeyManager"
private const val PREFS_NAME = "crypto_prefs"
private const val KEY_PUBLIC_KEY = "server_public_key"
private const val PUBLIC_KEY_LENGTH = Box.PUBLICKEYBYTES // 32 bytes for X25519
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* Stores the server's public key from a Base64-encoded string.
*
* @param base64Key The Base64-encoded public key
* @return true if the key was valid and stored, false otherwise
*/
fun setPublicKey(base64Key: String): Boolean {
return try {
val keyBytes = Base64.decode(base64Key.trim(), Base64.DEFAULT)
if (keyBytes.size != PUBLIC_KEY_LENGTH) {
Log.e(TAG, "Invalid public key length: ${keyBytes.size}, expected $PUBLIC_KEY_LENGTH")
return false
}
prefs.edit().putString(KEY_PUBLIC_KEY, base64Key.trim()).apply()
Log.i(TAG, "Server public key stored successfully")
true
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid Base64 encoding for public key", e)
false
}
}
/**
* Retrieves the stored server public key as raw bytes.
*
* @return The public key bytes, or null if not set
*/
fun getPublicKey(): ByteArray? {
val base64Key = prefs.getString(KEY_PUBLIC_KEY, null) ?: return null
return try {
val keyBytes = Base64.decode(base64Key, Base64.DEFAULT)
if (keyBytes.size == PUBLIC_KEY_LENGTH) keyBytes else null
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Failed to decode stored public key", e)
null
}
}
/**
* Returns the stored public key as a Base64 string for display.
*/
fun getPublicKeyBase64(): String? {
return prefs.getString(KEY_PUBLIC_KEY, null)
}
/**
* Checks if a public key has been configured.
*/
fun hasPublicKey(): Boolean = getPublicKey() != null
/**
* Clears the stored public key.
*/
fun clearPublicKey() {
prefs.edit().remove(KEY_PUBLIC_KEY).apply()
Log.i(TAG, "Server public key cleared")
}
}

View file

@ -0,0 +1,99 @@
package com.example.phonerecorder.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.util.Log
import java.net.Inet4Address
import java.net.NetworkInterface
class NetworkHelper(private val context: Context) {
companion object {
private const val TAG = "NetworkHelper"
}
private val connectivityManager: ConnectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val syncSettings = SyncSettings(context)
/**
* Checks if any network is available.
*/
fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
/**
* Checks if currently connected to WiFi.
*/
fun isOnWifi(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
/**
* Checks if currently on the configured LAN subnet.
* The subnet prefix is configurable in SyncSettings (default: 10.0.0.)
*/
fun isOnLan(): Boolean {
if (!isOnWifi()) return false
try {
val ipAddress = getLocalIpAddress()
val lanPrefix = syncSettings.lanSubnetPrefix
val isLan = ipAddress?.startsWith(lanPrefix) == true
Log.d(TAG, "Local IP: $ipAddress, LAN prefix: $lanPrefix, isOnLan: $isLan")
return isLan
} catch (e: Exception) {
Log.e(TAG, "Error checking LAN status", e)
return false
}
}
/**
* Returns the current local IP address (for display/debugging).
*/
fun getCurrentIpAddress(): String? = getLocalIpAddress()
/**
* Gets the local IPv4 address.
*/
private fun getLocalIpAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (networkInterface.isLoopback || !networkInterface.isUp) continue
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (address is Inet4Address && !address.isLoopbackAddress) {
return address.hostAddress
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting local IP address", e)
}
return null
}
/**
* Checks if sync should proceed based on current network state and sync mode.
*/
fun shouldSync(syncMode: SyncMode): Boolean {
return when (syncMode) {
SyncMode.NEVER -> false
SyncMode.LAN_ONLY -> isOnLan()
SyncMode.WIFI_ONLY -> isOnWifi()
SyncMode.ANY_NETWORK -> isNetworkAvailable()
}
}
}

View file

@ -0,0 +1,189 @@
package com.example.phonerecorder.sync
import android.content.Context
import android.util.Log
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.example.phonerecorder.StorageHelper
import java.util.concurrent.TimeUnit
class SyncManager private constructor(private val context: Context) {
companion object {
private const val TAG = "SyncManager"
private const val PERIODIC_SYNC_INTERVAL_MINUTES = 15L
@Volatile
private var instance: SyncManager? = null
fun getInstance(context: Context): SyncManager {
return instance ?: synchronized(this) {
instance ?: SyncManager(context.applicationContext).also { instance = it }
}
}
}
private val workManager = WorkManager.getInstance(context)
private val syncSettings = SyncSettings(context)
private val syncTracker = SyncTracker(context)
private val storageHelper = StorageHelper(context)
private val networkHelper = NetworkHelper(context)
/**
* Starts or updates the periodic sync schedule based on current settings.
*/
fun startPeriodicSync() {
val syncMode = syncSettings.syncMode
if (syncMode == SyncMode.NEVER) {
Log.i(TAG, "Sync disabled, cancelling periodic work")
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
return
}
val constraints = buildConstraints(syncMode)
val periodicWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
PERIODIC_SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
SyncWorker.WORK_NAME_PERIODIC,
ExistingPeriodicWorkPolicy.UPDATE,
periodicWorkRequest
)
Log.i(TAG, "Periodic sync scheduled for mode: $syncMode")
}
/**
* Stops the periodic sync.
*/
fun stopPeriodicSync() {
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
Log.i(TAG, "Periodic sync stopped")
}
/**
* Triggers an immediate sync attempt.
*/
fun scheduleImmediateSync() {
val syncMode = syncSettings.syncMode
if (syncMode == SyncMode.NEVER) {
Log.i(TAG, "Sync disabled, skipping immediate sync")
return
}
val constraints = buildConstraints(syncMode)
val immediateWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.build()
workManager.enqueueUniqueWork(
SyncWorker.WORK_NAME_IMMEDIATE,
ExistingWorkPolicy.REPLACE,
immediateWorkRequest
)
Log.i(TAG, "Immediate sync scheduled")
}
/**
* Returns the count of files pending sync.
*/
fun getPendingSyncCount(): Int {
val syncedFiles = syncTracker.getSyncedFiles()
val allFiles = storageHelper.listUnsyncedRecordings(emptySet())
return allFiles.count { !syncedFiles.contains(it.name) }
}
/**
* Returns the detailed sync status based on current conditions.
* @param hasKey Whether the server public key is configured
*/
fun getSyncStatus(hasKey: Boolean): SyncStatus {
val syncMode = syncSettings.syncMode
// Check sync mode first
if (syncMode == SyncMode.NEVER) {
return SyncStatus.Disabled
}
// Check key
if (!hasKey) {
return SyncStatus.NoKey
}
// Check network conditions based on mode
when (syncMode) {
SyncMode.LAN_ONLY -> {
if (!networkHelper.isOnWifi()) {
return SyncStatus.NotOnWifi
}
if (!networkHelper.isOnLan()) {
return SyncStatus.NotOnLan
}
}
SyncMode.WIFI_ONLY -> {
if (!networkHelper.isOnWifi()) {
return SyncStatus.NotOnWifi
}
}
SyncMode.ANY_NETWORK -> {
if (!networkHelper.isNetworkAvailable()) {
return SyncStatus.NoNetwork
}
}
SyncMode.NEVER -> { /* Already handled above */ }
}
// Network conditions are met, check pending count
val pending = getPendingSyncCount()
return if (pending > 0) {
SyncStatus.Ready(pending)
} else {
SyncStatus.Synced
}
}
/**
* Returns the current sync mode.
*/
fun getSyncMode(): SyncMode = syncSettings.syncMode
/**
* Updates the sync mode and restarts periodic sync if needed.
*/
fun setSyncMode(mode: SyncMode) {
syncSettings.syncMode = mode
startPeriodicSync()
}
private fun buildConstraints(syncMode: SyncMode): Constraints {
val builder = Constraints.Builder()
when (syncMode) {
SyncMode.NEVER -> {
// Should not reach here, but set impossible constraint
builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
}
SyncMode.LAN_ONLY, SyncMode.WIFI_ONLY -> {
// Require unmetered (WiFi) connection
builder.setRequiredNetworkType(NetworkType.UNMETERED)
}
SyncMode.ANY_NETWORK -> {
// Any connected network
builder.setRequiredNetworkType(NetworkType.CONNECTED)
}
}
return builder.build()
}
}

View file

@ -0,0 +1,86 @@
package com.example.phonerecorder.sync
import android.content.Context
import android.content.SharedPreferences
enum class SyncMode {
NEVER, // Never sync
LAN_ONLY, // Only sync when on LAN (10.0.0.x subnet)
WIFI_ONLY, // Only sync when on WiFi (any network)
ANY_NETWORK // Sync on any network (including mobile data)
}
sealed class SyncStatus {
object Disabled : SyncStatus()
object NoKey : SyncStatus()
object NotOnWifi : SyncStatus()
object NotOnLan : SyncStatus()
object NoNetwork : SyncStatus()
data class Ready(val pending: Int) : SyncStatus()
object Synced : SyncStatus()
}
class SyncSettings(context: Context) {
companion object {
private const val PREFS_NAME = "sync_settings"
private const val KEY_SYNC_MODE = "sync_mode"
private const val KEY_LAN_SERVER_URL = "lan_server_url"
private const val KEY_REMOTE_SERVER_URL = "remote_server_url"
private const val KEY_LAN_SUBNET_PREFIX = "lan_subnet_prefix"
private const val KEY_API_KEY = "api_key"
const val DEFAULT_LAN_SERVER_URL = "http://10.0.0.45:8000"
const val DEFAULT_REMOTE_SERVER_URL = "https://recordings.hallocks.xyz"
const val DEFAULT_LAN_SUBNET_PREFIX = "10.0.0." // Change this to match your network
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var syncMode: SyncMode
get() {
val ordinal = prefs.getInt(KEY_SYNC_MODE, SyncMode.NEVER.ordinal)
return SyncMode.entries.getOrElse(ordinal) { SyncMode.NEVER }
}
set(value) {
prefs.edit().putInt(KEY_SYNC_MODE, value.ordinal).apply()
}
var lanServerUrl: String
get() = prefs.getString(KEY_LAN_SERVER_URL, DEFAULT_LAN_SERVER_URL) ?: DEFAULT_LAN_SERVER_URL
set(value) {
prefs.edit().putString(KEY_LAN_SERVER_URL, value).apply()
}
var remoteServerUrl: String
get() = prefs.getString(KEY_REMOTE_SERVER_URL, DEFAULT_REMOTE_SERVER_URL) ?: DEFAULT_REMOTE_SERVER_URL
set(value) {
prefs.edit().putString(KEY_REMOTE_SERVER_URL, value).apply()
}
/**
* The subnet prefix used to detect LAN (e.g., "192.168.1." or "10.0.0.").
* Your phone must be on this subnet for LAN mode to work.
*/
var lanSubnetPrefix: String
get() = prefs.getString(KEY_LAN_SUBNET_PREFIX, DEFAULT_LAN_SUBNET_PREFIX) ?: DEFAULT_LAN_SUBNET_PREFIX
set(value) {
prefs.edit().putString(KEY_LAN_SUBNET_PREFIX, value).apply()
}
/**
* API key for upload authentication.
*/
var apiKey: String
get() = prefs.getString(KEY_API_KEY, "") ?: ""
set(value) {
prefs.edit().putString(KEY_API_KEY, value).apply()
}
/**
* Returns the appropriate server URL based on whether we're on LAN.
*/
fun getServerUrl(isOnLan: Boolean): String {
return if (isOnLan) lanServerUrl else remoteServerUrl
}
}

View file

@ -0,0 +1,82 @@
package com.example.phonerecorder.sync
import android.content.Context
import android.content.SharedPreferences
class SyncTracker(context: Context) {
companion object {
private const val PREFS_NAME = "sync_tracker"
private const val KEY_SYNCED_FILES = "synced_files"
private const val SEPARATOR = "|"
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* Returns the set of filenames that have been successfully synced.
*/
fun getSyncedFiles(): Set<String> {
val stored = prefs.getString(KEY_SYNCED_FILES, "") ?: ""
return if (stored.isEmpty()) {
emptySet()
} else {
stored.split(SEPARATOR).toSet()
}
}
/**
* Marks a file as successfully synced.
*/
fun markSynced(filename: String) {
val current = getSyncedFiles().toMutableSet()
current.add(filename)
saveSyncedFiles(current)
}
/**
* Marks multiple files as successfully synced.
*/
fun markSynced(filenames: Collection<String>) {
val current = getSyncedFiles().toMutableSet()
current.addAll(filenames)
saveSyncedFiles(current)
}
/**
* Checks if a specific file has been synced.
*/
fun isSynced(filename: String): Boolean {
return getSyncedFiles().contains(filename)
}
/**
* Returns the count of files pending sync.
*/
fun getPendingCount(allFiles: List<String>): Int {
val synced = getSyncedFiles()
return allFiles.count { !synced.contains(it) }
}
/**
* Clears the sync history for a specific file.
*/
fun clearSyncStatus(filename: String) {
val current = getSyncedFiles().toMutableSet()
current.remove(filename)
saveSyncedFiles(current)
}
/**
* Clears all sync history.
*/
fun clearAll() {
prefs.edit().remove(KEY_SYNCED_FILES).apply()
}
private fun saveSyncedFiles(files: Set<String>) {
prefs.edit()
.putString(KEY_SYNCED_FILES, files.joinToString(SEPARATOR))
.apply()
}
}

View file

@ -0,0 +1,104 @@
package com.example.phonerecorder.sync
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.phonerecorder.StorageHelper
import com.example.phonerecorder.api.RecordingsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "SyncWorker"
const val WORK_NAME_PERIODIC = "sync_worker_periodic"
const val WORK_NAME_IMMEDIATE = "sync_worker_immediate"
}
private val syncSettings = SyncSettings(applicationContext)
private val networkHelper = NetworkHelper(applicationContext)
private val syncTracker = SyncTracker(applicationContext)
private val storageHelper = StorageHelper(applicationContext)
private val recordingsApi = RecordingsApi()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.i(TAG, "Starting sync work")
// Check if sync is enabled and conditions are met
val syncMode = syncSettings.syncMode
if (syncMode == SyncMode.NEVER) {
Log.i(TAG, "Sync disabled")
return@withContext Result.success()
}
if (!networkHelper.shouldSync(syncMode)) {
Log.i(TAG, "Network conditions not met for sync mode: $syncMode")
return@withContext Result.retry()
}
// Determine server URL based on sync mode
// LAN_ONLY mode uses LAN URL, other modes use remote URL
val serverUrl = if (syncMode == SyncMode.LAN_ONLY) {
syncSettings.lanServerUrl
} else {
syncSettings.remoteServerUrl
}
Log.i(TAG, "Using server: $serverUrl (mode: $syncMode)")
// Check server health
if (!recordingsApi.checkHealth(serverUrl)) {
Log.w(TAG, "Server health check failed")
return@withContext Result.retry()
}
// Get unsynced files
val syncedFiles = syncTracker.getSyncedFiles()
val unsyncedFiles = storageHelper.listUnsyncedRecordings(syncedFiles)
if (unsyncedFiles.isEmpty()) {
Log.i(TAG, "No files to sync")
return@withContext Result.success()
}
Log.i(TAG, "Found ${unsyncedFiles.size} files to sync")
var successCount = 0
var failCount = 0
for (file in unsyncedFiles) {
// Re-check network conditions before each upload
if (!networkHelper.shouldSync(syncMode)) {
Log.w(TAG, "Network conditions changed during sync")
break
}
val result = recordingsApi.uploadRecording(serverUrl, file, syncSettings.apiKey)
if (result.success && result.verified) {
// Only mark as synced if server verified the hash
syncTracker.markSynced(file.name)
successCount++
Log.i(TAG, "Synced and verified: ${file.name}")
} else if (result.success && !result.verified) {
// Upload succeeded but no verification - don't mark as synced, retry later
Log.w(TAG, "Upload succeeded but unverified, will retry: ${file.name}")
failCount++
} else {
failCount++
Log.w(TAG, "Failed to sync ${file.name}: ${result.message}")
}
}
Log.i(TAG, "Sync completed: $successCount succeeded, $failCount failed")
if (failCount > 0 && successCount == 0) {
return@withContext Result.retry()
}
return@withContext Result.success()
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Microphone icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,28c-5.5,0 -10,4.5 -10,10v20c0,5.5 4.5,10 10,10s10,-4.5 10,-10v-20c0,-5.5 -4.5,-10 -10,-10z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M74,58v-8c0,-11 -9,-20 -20,-20s-20,9 -20,20v8h6v-8c0,-7.7 6.3,-14 14,-14s14,6.3 14,14v8h6z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M51,72v8h6v-8c9.4,-1.2 16.7,-9.2 16.7,-18.7h-6c0,7.5 -6.1,13.7 -13.7,13.7s-13.7,-6.1 -13.7,-13.7h-6c0,9.5 7.3,17.5 16.7,18.7z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/recording_stopped" />
</shape>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/recording_active" />
</shape>

View file

@ -0,0 +1,232 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp"
tools:context=".MainActivity">
<!-- Status Indicator -->
<View
android:id="@+id/statusIndicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/status_circle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/status_stopped"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/statusIndicator"
app:layout_constraintStart_toEndOf="@id/statusIndicator"
app:layout_constraintTop_toTopOf="@id/statusIndicator" />
<!-- Elapsed Time -->
<TextView
android:id="@+id/elapsedTimeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="00:00:00"
android:textSize="48sp"
android:textStyle="bold"
android:fontFamily="monospace"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
<!-- Current File -->
<TextView
android:id="@+id/currentFileText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:ellipsize="middle"
android:singleLine="true"
android:textSize="14sp"
tools:text="Current file: recording_2024-01-15_09-30-00.ogg"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/elapsedTimeText" />
<!-- Storage Location -->
<TextView
android:id="@+id/storageLocationText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Storage: SD Card"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/currentFileText" />
<!-- Storage Info -->
<TextView
android:id="@+id/storageInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp"
tools:text="Used: 2.5 GB / Available: 28.3 GB"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/storageLocationText" />
<!-- Battery Info -->
<TextView
android:id="@+id/batteryInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
tools:text="Battery: 85%"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/storageInfoText" />
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/toggleButton"
android:layout_width="200dp"
android:layout_height="64dp"
android:text="@string/start_recording"
android:textSize="18sp"
app:cornerRadius="32dp"
app:layout_constraintBottom_toTopOf="@id/autoStartSwitch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/batteryInfoText"
app:layout_constraintVertical_bias="0.5" />
<!-- Auto-start Switch -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/auto_start_disabled"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toggleButton" />
<!-- Sync Settings Section -->
<TextView
android:id="@+id/syncSettingsLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/sync_settings_label"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/autoStartSwitch" />
<Spinner
android:id="@+id/syncModeSpinner"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncSettingsLabel" />
<!-- Network Info -->
<TextView
android:id="@+id/networkInfoText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
tools:text="Network: WiFi (192.168.1.42)"
app:layout_constraintEnd_toStartOf="@id/configureLanButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner"
app:layout_constraintBottom_toBottomOf="@id/configureLanButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/configureLanButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/configure_lan"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner" />
<!-- Key Status -->
<TextView
android:id="@+id/keyStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
tools:text="Key: Not configured"
app:layout_constraintEnd_toStartOf="@id/importKeyButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/configureLanButton"
app:layout_constraintBottom_toBottomOf="@id/importKeyButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/importKeyButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/import_key_button"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/configureLanButton" />
<!-- Sync Status -->
<TextView
android:id="@+id/syncStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp"
tools:text="Sync: 3 pending"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/importKeyButton" />
<!-- Battery Optimization Hint -->
<TextView
android:id="@+id/batteryOptimizationHint"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/battery_optimization_hint"
android:textColor="@color/warning"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncStatusText" />
<!-- Version Info -->
<TextView
android:id="@+id/versionText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:textColor="@android:color/darker_gray"
android:textSize="11sp"
tools:text="v1.1.0 (build 2026-05-11)"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/batteryOptimizationHint"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#D32F2F</color>
<color name="primary_dark">#B71C1C</color>
<color name="accent">#FF5252</color>
<color name="recording_active">#4CAF50</color>
<color name="recording_stopped">#9E9E9E</color>
<color name="warning">#FF9800</color>
</resources>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Phone Recorder</string>
<string name="status_recording">Recording</string>
<string name="status_stopped">Stopped</string>
<string name="start_recording">Start Recording</string>
<string name="stop_recording">Stop Recording</string>
<string name="storage_info">Storage: %1$s used / %2$s available</string>
<string name="battery_info">Battery: %1$d%%</string>
<string name="current_file">Current file: %1$s</string>
<string name="no_file">No file</string>
<string name="notification_channel_name">Recording</string>
<string name="notification_channel_description">Shows when recording is active</string>
<string name="notification_title">Recording in progress</string>
<string name="notification_text">Elapsed: %1$s</string>
<string name="storage_warning">Low storage! Recording stopped.</string>
<string name="storage_location_sd">Storage: SD Card</string>
<string name="storage_location_internal">Storage: Internal (SD card not available)</string>
<string name="auto_start_enabled">Auto-start on boot: Enabled</string>
<string name="auto_start_disabled">Auto-start on boot: Disabled</string>
<string name="battery_optimization_title">Battery Optimization</string>
<string name="battery_optimization_message">For reliable background recording, disable battery optimization for this app.</string>
<string name="battery_optimization_button">Open Settings</string>
<string name="battery_optimization_hint">Tip: Disable battery optimization for best reliability</string>
<string name="permission_required">Microphone permission is required for recording</string>
<!-- Sync settings -->
<string name="sync_settings_label">Sync Mode</string>
<string name="sync_mode_never">Never</string>
<string name="sync_mode_lan_only">LAN Only</string>
<string name="sync_mode_wifi_only">WiFi Only</string>
<string name="sync_mode_any_network">Any Network</string>
<string name="sync_status_pending">Sync: %1$d pending</string>
<string name="sync_status_disabled">Sync: Disabled</string>
<string name="sync_status_no_key">Sync: No server key configured</string>
<string name="sync_status_not_on_wifi">Sync: Not on WiFi</string>
<string name="sync_status_not_on_lan">Sync: Not on configured LAN</string>
<string name="sync_status_no_network">Sync: No network connection</string>
<string name="sync_status_synced">Sync: All synced</string>
<string name="import_key_button">Import Server Key</string>
<string name="import_key_title">Import Server Public Key</string>
<string name="import_key_hint">Paste Base64 public key</string>
<string name="import_key_success">Server key imported successfully</string>
<string name="import_key_error">Invalid key format</string>
<string name="key_status_configured">Key: Configured</string>
<string name="key_status_not_configured">Key: Not configured</string>
<!-- Network info -->
<string name="network_not_connected">Network: Not connected</string>
<string name="network_wifi">Network: WiFi (%1$s)</string>
<string name="network_mobile">Network: Mobile data</string>
<string name="lan_subnet_label">LAN Subnet</string>
<string name="lan_subnet_hint">e.g., 192.168.1. or 10.0.0.</string>
<string name="configure_lan">Configure</string>
<string name="lan_config_title">LAN Subnet Configuration</string>
<string name="lan_config_message">Enter the IP prefix for your home network.\n\nYour current IP: %1$s\n\nCommon examples:\n• 192.168.1.\n• 192.168.0.\n• 10.0.0.</string>
<string name="lan_config_saved">LAN subnet saved: %1$s</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PhoneRecorder" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<item name="colorSecondary">@color/accent</item>
<item name="colorSecondaryVariant">@color/accent</item>
<item name="colorOnSecondary">@android:color/white</item>
</style>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="recordings"
path="recordings/" />
</paths>

View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}

136
clio-android/description.md Normal file
View file

@ -0,0 +1,136 @@
# Phone Recorder - Architecture Overview
## Purpose
Android app that records audio continuously, encrypts recordings immediately (never stored unencrypted), and syncs them to a server over LAN or internet.
## Security Model
```
Phone (has: server public key) Server (has: private key)
───────────────────────────── ────────────────────────
Record audio → temp.ogg
Encrypt with public key →
recording_*.ogg.enc
└──── HTTP POST ────────→ Receive encrypted file
Decrypt with private key
Store as .ogg in recordings/
```
- **Sealed box encryption**: X25519 key exchange + XSalsa20-Poly1305
- **Forward secrecy**: Each encryption uses ephemeral keys
- **Phone never stores unencrypted**: Temp file deleted immediately after encryption
- **If no public key configured**: Recordings are deleted (security-first design)
## File Format (SREC)
Encrypted files use a chunked format for streaming large files:
```
[4 bytes: "SREC" magic]
[1 byte: version (1)]
[4 bytes: chunk count, big-endian]
For each chunk (64KB max):
[4 bytes: encrypted length, big-endian]
[N bytes: sealed box ciphertext]
```
## Android App Structure
### Main Files
| File | Purpose |
|------|---------|
| `MainActivity.kt` | UI, settings, sync controls |
| `RecordingService.kt` | Foreground service for recording, encryption on stop |
| `StorageHelper.kt` | File paths, temp vs recordings directories |
### Crypto Package (`crypto/`)
| File | Purpose |
|------|---------|
| `CryptoHelper.kt` | SREC format encryption using lazysodium |
| `KeyManager.kt` | Store/retrieve server public key (SharedPreferences) |
### Sync Package (`sync/`)
| File | Purpose |
|------|---------|
| `SyncSettings.kt` | Sync mode enum, server URLs, LAN subnet prefix |
| `NetworkHelper.kt` | Detect WiFi, LAN (by IP prefix), network availability |
| `SyncTracker.kt` | Track which files synced successfully (SharedPreferences) |
| `SyncWorker.kt` | WorkManager background job for uploads |
| `SyncManager.kt` | Schedule periodic (15min) and immediate syncs |
### API Package (`api/`)
| File | Purpose |
|------|---------|
| `RecordingsApi.kt` | OkHttp client, upload with SHA-256 verification |
## Sync Modes
| Mode | Behavior |
|------|----------|
| Never | No sync, files stay on phone |
| LAN Only | Sync only when IP matches configured subnet (e.g., `10.0.0.`) |
| WiFi Only | Sync on any WiFi network |
| Any Network | Sync on WiFi or mobile data |
## Server Structure (Python/FastAPI)
| File | Purpose |
|------|---------|
| `main.py` | FastAPI endpoints |
| `crypto.py` | PyNaCl decryption, keypair management |
| `config.py` | Paths, ports |
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Returns `{"status": "ok"}` |
| `/api/v1/public-key` | GET | Returns Base64 public key |
| `/api/v1/recordings/upload` | POST | Upload encrypted file, returns hash for verification |
| `/api/v1/recordings` | GET | List decrypted recordings |
## Data Flow
1. **Recording starts**: `RecordingService` writes to `temp/recording_TIMESTAMP.ogg`
2. **Recording stops**:
- `CryptoHelper.encryptFile()` reads temp, writes `recordings/recording_TIMESTAMP.ogg.enc`
- Temp file deleted
- `SyncManager.scheduleImmediateSync()` triggered
3. **Sync runs**:
- `SyncWorker` checks network conditions match `SyncMode`
- Computes SHA-256 of encrypted file
- Uploads to server
- Server decrypts, returns hash
- If hashes match, marks file as synced
4. **Server storage**: `recordings/YYYY/MM/DD/recording_TIMESTAMP.ogg`
## Key Dependencies
### Android
- `lazysodium-android` - libsodium bindings
- `androidx.work` - Background job scheduling
- `okhttp3` - HTTP client
### Server
- `fastapi` + `uvicorn` - Web framework
- `pynacl` - libsodium bindings
- `aiofiles` - Async file I/O
## Configuration
### Android (in-app settings)
- Sync mode (Never/LAN/WiFi/Any)
- LAN subnet prefix (default: `10.0.0.`)
- Server URLs (LAN and remote)
- Server public key (Base64)
### Server (config.py or env vars)
- `HOST` - Bind address (default: 0.0.0.0)
- `PORT` - Listen port (default: 8000)
- `BASE_DIR` - Base directory for keys and recordings

61
clio-android/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1776877367,
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

52
clio-android/flake.nix Normal file
View file

@ -0,0 +1,52 @@
{
description = "Phone Recorder Android app development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
androidComposition = pkgs.androidenv.composeAndroidPackages {
buildToolsVersions = [ "34.0.0" ];
platformVersions = [ "34" "33" ];
abiVersions = [ "arm64-v8a" "x86_64" ];
includeEmulator = false;
includeSystemImages = false;
includeSources = false;
includeNDK = false;
};
androidSdk = androidComposition.androidsdk;
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
android-studio
android-tools
jdk17
androidSdk
];
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
shellHook = ''
echo "Phone Recorder development environment"
echo "Run 'android-studio' to launch Android Studio"
echo "ANDROID_HOME=$ANDROID_HOME"
'';
};
}
);
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
clio-android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
clio-android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,8 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Sun Apr 26 22:24:29 GMT 2026
sdk.dir=/work/projects/phone_recorder/claude/android_studio_install_path

14
clio-android/notes.txt Normal file
View file

@ -0,0 +1,14 @@
./gradlew assembleDebug
2. Connect your phone (USB debugging enabled) and verify connection:
./android_studio_install_path/platform-tools/adb devices
3. Install the APK:
./android_studio_install_path/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
Or you can combine build + install in one step:
./gradlew installDebug
This requires your phone to be connected with USB debugging enabled in Developer Options.
can you add it to the flake?

View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PhoneRecorder"
include(":app")

117
clio-android/todo.md Normal file
View file

@ -0,0 +1,117 @@
# Phone Recorder - Future Ideas / TODO
## Questions to Address
### Key Rotation
- What happens when server keypair changes?
- Current behavior: Old encrypted files can't be decrypted with new key
- Options:
1. Keep old private key archived for decrypting old files
2. Sync all files before rotating keys
3. Store key version/ID in encrypted file header
### Manual Decryption
- Document steps to decrypt files copied via USB from phone
- Need: Python script using server's private key
- Consider: CLI tool in server/ directory
### No-Key Behavior
- Current: Deletes recordings if no public key configured (security-first)
- Alternatives to consider:
1. Store unencrypted with warning (less secure)
2. Queue recordings until key configured (could fill storage)
3. Current behavior is probably correct for security model
---
## Possible Features
### High Priority (if needed)
- [ ] QR code for key import (avoid email/typing)
- [ ] Server URL configuration in app UI (currently requires code change for non-defaults)
- [ ] Manual sync trigger button
- [ ] View sync history/status in app
- [ ] **Display last successful sync time on phone** (e.g., "Last sync: 2 hours ago")
- [ ] **Server status page** - HTML dashboard showing:
- When phone last synced
- Total recordings count
- Recent uploads
- Disk space usage
- Could be at `/status` or `/dashboard`
### Medium Priority
- [ ] Configurable recording quality (bitrate, sample rate)
- [ ] Configurable segment duration (currently 2 hours)
- [ ] Delete synced files automatically (with configurable retention)
- [ ] Server: Authentication for upload endpoint
- [ ] Server: HTTPS with Let's Encrypt
### Audio Analysis & Intelligence
- [ ] **Speech detection bar** - Visual indicator showing when speech is detected vs silence
- Display as additional bar in recording detail view alongside waveform
- Data comes from batch processing script
- [ ] **Sleeping detection** - Detect periods of sleep/silence for overnight recordings
- Could help skip to interesting parts
- Visual indicator in timeline
- [ ] **Auto voice recognition / Speaker identification**
- Identify who is talking (requires training on voice samples)
- Label segments by speaker
- Not text-based (transcripts are unreliable)
- [ ] **Auto highlight generation**
- Audio-based detection (not text search since transcripts are bad)
- Example: detect phrase patterns like "Ok, note taken" acoustically
- Auto-create highlights with tags like "note_taken"
- Use audio fingerprinting or keyword spotting models
### Viewer UX
- [ ] **Background audio playback** - Keep playing current audio while browsing other recordings
- Mini player that persists across views
- Allows reviewing recordings list while listening
- [ ] **Highlight duration** - Currently highlights mark only a point; consider adding end time/duration
- [ ] **Auto-scroll timeline to recordings** - If recordings are only at end of day, auto-scroll so user doesn't have to scroll down to see them
### Low Priority / Nice to Have
- [ ] Multiple server destinations
- [ ] Compression before encryption
- [ ] Resume interrupted uploads
- [ ] Server: Web UI to browse/play recordings
- [ ] Server: Transcription integration
### Infrastructure
- [ ] Systemd service file for server
- [ ] Docker container for server
- [ ] NixOS module for server
- [ ] Backup strategy for server private key
---
## Known Limitations
1. **No streaming encryption**: Entire temp file must complete before encryption
2. **No partial sync**: If upload fails, whole file retries
3. **Single server**: Can only sync to one destination at a time
4. **No authentication**: Anyone who can reach server can upload (relies on encryption for confidentiality)
---
## Testing Notes
### Unit Tests (server/)
```bash
python test_crypto.py
```
### Integration Tests (server/)
```bash
# Start server first
python main.py &
python test_integration.py
```
### Manual Testing Checklist
- [ ] Record short clip, verify .ogg.enc created
- [ ] Verify temp file deleted after encryption
- [ ] Check sync triggers after recording stops
- [ ] Verify hash verification (corrupt a file, see it retry)
- [ ] Test LAN detection with different subnets
- [ ] Test sync mode transitions

10
clio-api/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
clio-api/.python-version Normal file
View file

@ -0,0 +1 @@
3.13

0
clio-api/README.md Normal file
View file

74
clio-api/deploy.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# Deployment script for phone-recorder server
# Target: thallock@10.0.0.45 (SSH port 2252)
#
# Paths on server:
# Code: /work/hosting/recordings
# Data (db): /work/hosting/recordings/data
# Audio: /media/external/Hosting/Recordings
SERVER_HOST="10.0.0.45"
SERVER_USER="thallock"
SERVER_PORT="2252"
CODE_DIR="/work/hosting/recordings"
DATA_DIR="/work/hosting/recordings/data"
AUDIO_DIR="/media/external/Hosting/Recordings"
SSH_OPTS="-p ${SERVER_PORT}"
SSH_CMD="ssh ${SSH_OPTS} ${SERVER_USER}@${SERVER_HOST}"
RSYNC_SSH="ssh -p ${SERVER_PORT}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== Deploying phone-recorder server to ${SERVER_USER}@${SERVER_HOST} ==="
# Sync server code (excluding local data)
echo "Syncing server code..."
rsync -avz --delete \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude 'recordings/' \
--exclude 'recordings.db' \
--exclude 'keys/' \
--exclude 'data/' \
--exclude '.git' \
--exclude 'nixos/' \
-e "${RSYNC_SSH}" \
"${SCRIPT_DIR}/" \
"${SERVER_USER}@${SERVER_HOST}:${CODE_DIR}/"
echo "Setting up on remote server..."
${SSH_CMD} bash << REMOTE_SCRIPT
set -euo pipefail
# Create directories
mkdir -p ${DATA_DIR}/keys
mkdir -p ${AUDIO_DIR}
cd ${CODE_DIR}
# Sync dependencies
echo "Syncing Python dependencies..."
uv sync
echo ""
echo "=== Server code deployed ==="
echo "Code: ${CODE_DIR}"
echo "Data: ${DATA_DIR}"
echo "Audio: ${AUDIO_DIR}"
echo ""
echo "To install/update systemd service:"
echo " sudo cp ${CODE_DIR}/nixos/phone-recorder.service /etc/systemd/system/"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now phone-recorder"
echo ""
echo "To check status:"
echo " sudo systemctl status phone-recorder"
echo " curl http://localhost:8000/health"
REMOTE_SCRIPT
echo ""
echo "=== Deployment complete ==="

46
clio-api/nixos/Caddyfile Normal file
View file

@ -0,0 +1,46 @@
# Caddyfile snippet for phone-recorder
# Add this to your global /etc/caddy/Caddyfile
#
# Caddy will automatically obtain and renew HTTPS certificates
recordings.hallocks.xyz {
# Proxy everything to FastAPI
reverse_proxy localhost:8000
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# Logging for fail2ban
log {
output file /var/log/caddy/recordings.log {
roll_size 10mb
roll_keep 5
}
format json
}
}
# ============================================================
# Other services (for reference when setting them back up):
# ============================================================
# todo.hallocks.xyz {
# reverse_proxy localhost:3456 # Vikunja
# log {
# output file /var/log/caddy/todo.log
# format json
# }
# }
# passwords.hallocks.xyz {
# reverse_proxy localhost:8080 # Vaultwarden
# log {
# output file /var/log/caddy/passwords.log
# format json
# }
# }

View file

@ -0,0 +1,182 @@
# NixOS module for phone-recorder server
# Add to your configuration.nix imports or use as a standalone module
#
# Usage in configuration.nix:
# imports = [ ./phone-recorder.nix ];
# services.phone-recorder.enable = true;
{ config, lib, pkgs, ... }:
let
cfg = config.services.phone-recorder;
in
{
options.services.phone-recorder = {
enable = lib.mkEnableOption "Phone Recorder server";
port = lib.mkOption {
type = lib.types.port;
default = 8000;
description = "Port for the API server";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/phone-recorder";
description = "Directory for recordings and database";
};
user = lib.mkOption {
type = lib.types.str;
default = "phone-recorder";
description = "User to run the service as";
};
domain = lib.mkOption {
type = lib.types.str;
default = "recordings.hallocks.xyz";
description = "Domain for HTTPS access";
};
enableCaddy = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable Caddy reverse proxy with automatic HTTPS";
};
enableFail2ban = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable fail2ban protection";
};
sourceDir = lib.mkOption {
type = lib.types.path;
default = /home/thallock/phone-recorder;
description = "Path to the server source code";
};
};
config = lib.mkIf cfg.enable {
# Create user and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.user;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.user} = {};
# Systemd service
systemd.services.phone-recorder = {
description = "Phone Recorder API Server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HOST = "127.0.0.1"; # Only listen locally, Caddy handles external
PORT = toString cfg.port;
BASE_DIR = toString cfg.sourceDir;
RECORDINGS_DIR = "${cfg.dataDir}/recordings";
DATABASE_PATH = "${cfg.dataDir}/recordings.db";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.user;
WorkingDirectory = cfg.sourceDir;
ExecStart = "${pkgs.uv}/bin/uv run python main.py";
Restart = "always";
RestartSec = 5;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
PrivateTmp = true;
ReadWritePaths = [ cfg.dataDir ];
};
preStart = ''
mkdir -p ${cfg.dataDir}/recordings
mkdir -p ${cfg.dataDir}/keys
'';
};
# Caddy reverse proxy
services.caddy = lib.mkIf cfg.enableCaddy {
enable = true;
virtualHosts = {
"${cfg.domain}" = {
extraConfig = ''
# API endpoints
handle /api/* {
reverse_proxy localhost:${toString cfg.port}
}
handle /health {
reverse_proxy localhost:${toString cfg.port}
}
# Static frontend (if you have one)
handle {
root * /var/www/recordings-viewer
file_server
try_files {path} /index.html
}
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
# Rate limiting for uploads
@upload path /api/v1/recordings/upload
handle @upload {
rate_limit {remote.ip} 10r/m
reverse_proxy localhost:${toString cfg.port}
}
# Logging
log {
output file /var/log/caddy/recordings.log
format json
}
'';
};
};
};
# Open firewall
networking.firewall.allowedTCPPorts = [ 80 443 ]
++ lib.optional (!cfg.enableCaddy) cfg.port;
# Fail2ban
services.fail2ban = lib.mkIf cfg.enableFail2ban {
enable = true;
maxretry = 5;
bantime = "1h";
jails = {
caddy-auth = lib.mkIf cfg.enableCaddy ''
enabled = true
filter = caddy-auth
logpath = /var/log/caddy/recordings.log
maxretry = 5
bantime = 3600
'';
};
};
# Fail2ban filter for Caddy
environment.etc."fail2ban/filter.d/caddy-auth.conf" = lib.mkIf (cfg.enableFail2ban && cfg.enableCaddy) {
text = ''
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":40[13].*$
ignoreregex =
'';
};
};
}

View file

@ -0,0 +1,33 @@
# Systemd service file for phone-recorder
# Copy to /etc/systemd/system/phone-recorder.service
# Then: systemctl daemon-reload && systemctl enable --now phone-recorder
[Unit]
Description=Phone Recorder API Server
After=network.target local-fs.target
Wants=network-online.target
[Service]
Type=simple
User=thallock
Group=thallock
WorkingDirectory=/work/hosting/recordings
# Paths
Environment="HOST=0.0.0.0"
Environment="PORT=8000"
Environment="BASE_DIR=/work/hosting/recordings"
Environment="RECORDINGS_DIR=/media/external/Hosting/Recordings"
Environment="DATABASE_PATH=/work/hosting/recordings/data/recordings.db"
Environment="KEYS_DIR=/work/hosting/recordings/data/keys"
ExecStart=/home/thallock/.local/bin/uv run python main.py
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,8 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pynacl>=1.5.0
aiofiles>=23.2.1
python-multipart>=0.0.6
# For testing
requests>=2.31.0

126
clio-infra/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,126 @@
# Clio - Architecture Overview
## Purpose
Multi-device audio recording system with end-to-end encryption. Records continuously on phone and portable devices, encrypts immediately, syncs to central server, transcribes with GPU, and provides a web interface for search and highlights.
## Components
| Repository | Purpose | Deployment Target |
|------------|---------|-------------------|
| clio-api | FastAPI server, recordings DB, highlights | Server (10.0.0.45) |
| clio-android | Android recording app | Phone (manual APK) |
| clio-ui | React viewer frontend | Server (10.0.0.45) |
| clio-ai | Whisper transcription, wake word detection | Desktop (10.0.0.130) |
| clio-infra | Deployment configs, docs, NixOS modules | All |
## Security Model
```
Phone (has: server public key) Server (has: private key)
───────────────────────────── ────────────────────────
Record audio → temp.ogg
Encrypt with public key →
recording_*.ogg.enc
└──── HTTP POST ────────→ Receive encrypted file
Decrypt with private key
Store as .ogg in recordings/
```
- **Sealed box encryption**: X25519 key exchange + XSalsa20-Poly1305
- **Forward secrecy**: Each encryption uses ephemeral keys
- **Phone never stores unencrypted**: Temp file deleted immediately after encryption
- **If no public key configured**: Recordings are deleted (security-first design)
## File Format (SREC)
Encrypted files use a chunked format for streaming large files:
```
[4 bytes: "SREC" magic]
[1 byte: version (1)]
[4 bytes: chunk count, big-endian]
For each chunk (64KB max):
[4 bytes: encrypted length, big-endian]
[N bytes: sealed box ciphertext]
```
## Data Flow
```
1. Recording
Phone/Pi → temp.ogg → encrypt → recording_*.ogg.enc
2. Sync
Phone/Pi → HTTP POST → Server decrypts → recordings/YYYY/MM/DD/*.ogg
3. Transcription (async, on desktop)
Desktop pulls recordings → Whisper → JSON segments → POST to server
4. Viewing
Browser → clio-ui → clio-api → recordings + transcripts + highlights
```
## API Endpoints (clio-api)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/api/v1/public-key` | GET | Server public key (Base64) |
| `/api/v1/recordings/upload` | POST | Upload encrypted file |
| `/api/v1/recordings` | GET | List recordings with transcripts |
| `/api/v1/recordings/{id}` | GET | Recording details |
| `/api/v1/recordings/{id}/audio` | GET | Stream audio file |
| `/api/v1/import/transcription` | POST | Import transcription JSON |
| `/api/v1/highlights` | GET/POST | Manage highlights |
| `/api/v1/sources` | GET | List recording sources |
## Database Schema
**recordings**: id, source_name, audio_path, duration, language, recorded_at, transcribed_at
**segments**: id, recording_id, start_time, end_time, text, confidence
**highlights**: id, recording_id, start_seconds, end_seconds, tag, note, created_at
## Sync Modes (Android)
| Mode | Behavior |
|------|----------|
| Never | No sync, files stay on phone |
| LAN Only | Sync only when IP matches subnet (e.g., `10.0.0.`) |
| WiFi Only | Sync on any WiFi network |
| Any Network | Sync on WiFi or mobile data |
## Hosts & Paths
### Server (10.0.0.45)
- Code: `/work/hosting/recordings`
- Audio: `/media/external/Hosting/Recordings`
- Database: `/work/hosting/recordings/data/recordings.db`
- Frontend: `/var/www/recordings-viewer`
- Domain: `recordings.hallocks.xyz`
### Desktop (10.0.0.130)
- Transcription scripts
- Real-time wake word detection
- GPU: RTX 3090
## Dependencies
### clio-api (Python)
- fastapi, uvicorn
- pynacl (libsodium)
- aiofiles
- sqlite3
### clio-android (Kotlin)
- lazysodium-android
- androidx.work
- okhttp3
### clio-ai (Python)
- faster-whisper
- torch, torchaudio
- sounddevice
- webrtcvad
- scikit-learn

153
clio-infra/TODO.md Normal file
View file

@ -0,0 +1,153 @@
# Clio - Consolidated TODO
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────┐
│ TARGET ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────────────────────────────────────┐ │
│ │ Phone │───────▶│ Server (10.0.0.45) │ │
│ │ (Android)│ push │ - Storage hub (recordings) │ │
│ └──────────┘ │ - clio-api (FastAPI) │ │
│ │ - clio-ui (React frontend) │ │
│ ┌──────────┐ │ - Database (highlights, transcripts) │ │
│ │ Pi │───────▶│ - Forgejo (code.hallocks.xyz) │ │
│ │(portable)│ push └──────────────────────────────────────────────┘ │
│ └──────────┘ │ │
│ │ pulls recordings │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Desktop (10.0.0.130) │ │
│ │ - GPU: RTX 3090 │ │
│ │ - clio-ai/transcription (Whisper) │ │
│ │ - clio-ai/real_time (wake word detection) │ │
│ │ - Off at night (non-critical) │ │
│ └──────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Immediate Tasks
### Infrastructure Setup
- [x] Import Pi recorder code (systemd setup) → clio-pi/
- [ ] Migrate ~100s GB of recordings from Pi to server storage
- [ ] Pi: Add sync script to push recordings to server
- [ ] Pi: Auto-detect audio device (currently hardcoded to hw:1)
- [ ] Set up Forgejo repos for all clio-* projects
- [ ] Configure Forgejo Actions for server deployments (10.0.0.45)
- [ ] Create backup strategy for viewer, highlights, and database
### Deployment Pipeline
- [ ] clio-api: Forgejo Actions → deploy to 10.0.0.45
- [ ] clio-ui: Forgejo Actions → deploy to 10.0.0.45:/var/www/recordings-viewer
- [ ] clio-ai: Manual deploy to desktop (10.0.0.130) - not via Actions
---
## Feature Backlog
### High Priority
- [ ] QR code for key import (avoid email/typing)
- [ ] Server URL configuration in app UI
- [ ] Manual sync trigger button
- [ ] View sync history/status in app
- [ ] Display last successful sync time on phone
- [ ] Server status page/dashboard:
- When phone/pi last synced
- Total recordings count
- Recent uploads
- Disk space usage
### Wake Word / Voice Recognition (clio-ai)
- [ ] Manage wake words in UI (currently hardcoded)
- [ ] Centralize voice recognition training data
- [ ] Real-time detection improvements (currently sucks even on desktop)
- [ ] Future: Real-time detection on phone/pi (low priority)
### Audio Analysis & Intelligence
- [ ] Speech detection bar in recording detail view
- [ ] Sleeping/silence detection for overnight recordings
- [ ] Auto voice recognition / speaker identification
- [ ] Auto highlight generation (audio-based, not text search)
### Viewer UX
- [ ] Background audio playback (mini player across views)
- [ ] Highlight duration (currently point-only, add end time)
- [ ] Auto-scroll timeline to recordings
### Medium Priority
- [ ] Configurable recording quality (bitrate, sample rate)
- [ ] Configurable segment duration (currently 2 hours)
- [ ] Delete synced files automatically (with retention)
- [ ] Server authentication for upload endpoint
- [ ] Server HTTPS with Let's Encrypt (partially done via Caddy)
### Low Priority / Nice to Have
- [ ] Multiple server destinations
- [ ] Compression before encryption
- [ ] Resume interrupted uploads
- [ ] Docker container for server
---
## Questions to Resolve
### Key Rotation
- What happens when server keypair changes?
- Options:
1. Keep old private key archived
2. Sync all files before rotating
3. Store key version in encrypted file header
### Manual Decryption
- Document steps to decrypt files copied via USB
- CLI tool needed
### Desktop Worker Architecture
- How does desktop pull recordings from server?
- How does it push transcriptions back?
- Queue system needed?
---
## Known Limitations
1. **No streaming encryption**: Entire temp file must complete before encryption
2. **No partial sync**: If upload fails, whole file retries
3. **Single server**: Can only sync to one destination at a time
4. **No authentication**: Relies on encryption for confidentiality
5. **Desktop off at night**: Real-time detection unavailable
---
## Repository Structure
```
/work/projects/clio/
├── clio-api/ # FastAPI server (recordings, highlights, transcripts)
├── clio-android/ # Android recording app
├── clio-ui/ # React frontend (viewer)
├── clio-ai/ # ML/AI components
│ ├── real_time/ # Wake word detection, voice classifier
│ └── transcription/ # Whisper transcription scripts
├── clio-pi/ # Raspberry Pi recorder
│ ├── bin/ # Recording scripts
│ ├── systemd/ # Service units
│ └── setup.sh # Pi setup script
└── clio-infra/ # Deployment, configs, docs
├── nixos/ # NixOS modules
├── scripts/ # Deploy scripts
└── TODO.md # This file
```
## Hosts
| Host | IP | Role |
|------|-----|------|
| Server | 10.0.0.45 | Storage, API, UI, Forgejo |
| Desktop | 10.0.0.130 | GPU transcription, real-time detection |
| Phone | (mobile) | Recording source |
| Pi | (TBD) | Portable recording source |

View file

@ -0,0 +1,46 @@
# Caddyfile snippet for phone-recorder
# Add this to your global /etc/caddy/Caddyfile
#
# Caddy will automatically obtain and renew HTTPS certificates
recordings.hallocks.xyz {
# Proxy everything to FastAPI
reverse_proxy localhost:8000
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# Logging for fail2ban
log {
output file /var/log/caddy/recordings.log {
roll_size 10mb
roll_keep 5
}
format json
}
}
# ============================================================
# Other services (for reference when setting them back up):
# ============================================================
# todo.hallocks.xyz {
# reverse_proxy localhost:3456 # Vikunja
# log {
# output file /var/log/caddy/todo.log
# format json
# }
# }
# passwords.hallocks.xyz {
# reverse_proxy localhost:8080 # Vaultwarden
# log {
# output file /var/log/caddy/passwords.log
# format json
# }
# }

View file

@ -0,0 +1,182 @@
# NixOS module for phone-recorder server
# Add to your configuration.nix imports or use as a standalone module
#
# Usage in configuration.nix:
# imports = [ ./phone-recorder.nix ];
# services.phone-recorder.enable = true;
{ config, lib, pkgs, ... }:
let
cfg = config.services.phone-recorder;
in
{
options.services.phone-recorder = {
enable = lib.mkEnableOption "Phone Recorder server";
port = lib.mkOption {
type = lib.types.port;
default = 8000;
description = "Port for the API server";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/phone-recorder";
description = "Directory for recordings and database";
};
user = lib.mkOption {
type = lib.types.str;
default = "phone-recorder";
description = "User to run the service as";
};
domain = lib.mkOption {
type = lib.types.str;
default = "recordings.hallocks.xyz";
description = "Domain for HTTPS access";
};
enableCaddy = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable Caddy reverse proxy with automatic HTTPS";
};
enableFail2ban = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable fail2ban protection";
};
sourceDir = lib.mkOption {
type = lib.types.path;
default = /home/thallock/phone-recorder;
description = "Path to the server source code";
};
};
config = lib.mkIf cfg.enable {
# Create user and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.user;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.user} = {};
# Systemd service
systemd.services.phone-recorder = {
description = "Phone Recorder API Server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HOST = "127.0.0.1"; # Only listen locally, Caddy handles external
PORT = toString cfg.port;
BASE_DIR = toString cfg.sourceDir;
RECORDINGS_DIR = "${cfg.dataDir}/recordings";
DATABASE_PATH = "${cfg.dataDir}/recordings.db";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.user;
WorkingDirectory = cfg.sourceDir;
ExecStart = "${pkgs.uv}/bin/uv run python main.py";
Restart = "always";
RestartSec = 5;
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
PrivateTmp = true;
ReadWritePaths = [ cfg.dataDir ];
};
preStart = ''
mkdir -p ${cfg.dataDir}/recordings
mkdir -p ${cfg.dataDir}/keys
'';
};
# Caddy reverse proxy
services.caddy = lib.mkIf cfg.enableCaddy {
enable = true;
virtualHosts = {
"${cfg.domain}" = {
extraConfig = ''
# API endpoints
handle /api/* {
reverse_proxy localhost:${toString cfg.port}
}
handle /health {
reverse_proxy localhost:${toString cfg.port}
}
# Static frontend (if you have one)
handle {
root * /var/www/recordings-viewer
file_server
try_files {path} /index.html
}
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
# Rate limiting for uploads
@upload path /api/v1/recordings/upload
handle @upload {
rate_limit {remote.ip} 10r/m
reverse_proxy localhost:${toString cfg.port}
}
# Logging
log {
output file /var/log/caddy/recordings.log
format json
}
'';
};
};
};
# Open firewall
networking.firewall.allowedTCPPorts = [ 80 443 ]
++ lib.optional (!cfg.enableCaddy) cfg.port;
# Fail2ban
services.fail2ban = lib.mkIf cfg.enableFail2ban {
enable = true;
maxretry = 5;
bantime = "1h";
jails = {
caddy-auth = lib.mkIf cfg.enableCaddy ''
enabled = true
filter = caddy-auth
logpath = /var/log/caddy/recordings.log
maxretry = 5
bantime = 3600
'';
};
};
# Fail2ban filter for Caddy
environment.etc."fail2ban/filter.d/caddy-auth.conf" = lib.mkIf (cfg.enableFail2ban && cfg.enableCaddy) {
text = ''
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":40[13].*$
ignoreregex =
'';
};
};
}

View file

@ -0,0 +1,33 @@
# Systemd service file for phone-recorder
# Copy to /etc/systemd/system/phone-recorder.service
# Then: systemctl daemon-reload && systemctl enable --now phone-recorder
[Unit]
Description=Phone Recorder API Server
After=network.target local-fs.target
Wants=network-online.target
[Service]
Type=simple
User=thallock
Group=thallock
WorkingDirectory=/work/hosting/recordings
# Paths
Environment="HOST=0.0.0.0"
Environment="PORT=8000"
Environment="BASE_DIR=/work/hosting/recordings"
Environment="RECORDINGS_DIR=/media/external/Hosting/Recordings"
Environment="DATABASE_PATH=/work/hosting/recordings/data/recordings.db"
Environment="KEYS_DIR=/work/hosting/recordings/data/keys"
ExecStart=/home/thallock/.local/bin/uv run python main.py
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# Deployment script for phone-recorder server
# Target: thallock@10.0.0.45 (SSH port 2252)
#
# Paths on server:
# Code: /work/hosting/recordings
# Data (db): /work/hosting/recordings/data
# Audio: /media/external/Hosting/Recordings
SERVER_HOST="10.0.0.45"
SERVER_USER="thallock"
SERVER_PORT="2252"
CODE_DIR="/work/hosting/recordings"
DATA_DIR="/work/hosting/recordings/data"
AUDIO_DIR="/media/external/Hosting/Recordings"
SSH_OPTS="-p ${SERVER_PORT}"
SSH_CMD="ssh ${SSH_OPTS} ${SERVER_USER}@${SERVER_HOST}"
RSYNC_SSH="ssh -p ${SERVER_PORT}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== Deploying phone-recorder server to ${SERVER_USER}@${SERVER_HOST} ==="
# Sync server code (excluding local data)
echo "Syncing server code..."
rsync -avz --delete \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude 'recordings/' \
--exclude 'recordings.db' \
--exclude 'keys/' \
--exclude 'data/' \
--exclude '.git' \
--exclude 'nixos/' \
-e "${RSYNC_SSH}" \
"${SCRIPT_DIR}/" \
"${SERVER_USER}@${SERVER_HOST}:${CODE_DIR}/"
echo "Setting up on remote server..."
${SSH_CMD} bash << REMOTE_SCRIPT
set -euo pipefail
# Create directories
mkdir -p ${DATA_DIR}/keys
mkdir -p ${AUDIO_DIR}
cd ${CODE_DIR}
# Sync dependencies
echo "Syncing Python dependencies..."
uv sync
echo ""
echo "=== Server code deployed ==="
echo "Code: ${CODE_DIR}"
echo "Data: ${DATA_DIR}"
echo "Audio: ${AUDIO_DIR}"
echo ""
echo "To install/update systemd service:"
echo " sudo cp ${CODE_DIR}/nixos/phone-recorder.service /etc/systemd/system/"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now phone-recorder"
echo ""
echo "To check status:"
echo " sudo systemctl status phone-recorder"
echo " curl http://localhost:8000/health"
REMOTE_SCRIPT
echo ""
echo "=== Deployment complete ==="

View file

@ -0,0 +1,15 @@
#!/run/current-system/sw/bin/fish
rsync -avz --delete \
--exclude '.venv' --exclude '__pycache__' --exclude '*.pyc' \
--exclude 'recordings/' --exclude 'recordings.db' --exclude 'keys/' \
--exclude 'data/' --exclude '.git' \
-e 'ssh -p 2252' \
/work/projects/phone_recorder/claude/server/ \
thallock@10.0.0.45:/work/hosting/recordings/
# Deploy React frontend
rsync -avz --delete \
-e 'ssh -p 2252' \
/work/projects/view_recordings/designer_export/ \
thallock@10.0.0.45:/work/hosting/recordings-viewer/

78
clio-infra/scripts/deploy.sh Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
# Clio deployment script
# Deploys clio-api and clio-ui to server (10.0.0.45)
#
# Usage: ./deploy.sh [api|ui|all]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIO_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
SERVER_HOST="10.0.0.45"
SERVER_USER="thallock"
SERVER_PORT="2252"
# Server paths
API_CODE_DIR="/work/hosting/recordings"
UI_DIR="/work/hosting/recordings-viewer"
SSH_OPTS="-p ${SERVER_PORT}"
SSH_CMD="ssh ${SSH_OPTS} ${SERVER_USER}@${SERVER_HOST}"
RSYNC_SSH="ssh -p ${SERVER_PORT}"
deploy_api() {
echo "=== Deploying clio-api ==="
rsync -avz --delete \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude 'recordings/' \
--exclude 'recordings.db' \
--exclude 'keys/' \
--exclude 'data/' \
--exclude '.git' \
--exclude 'nixos/' \
-e "${RSYNC_SSH}" \
"${CLIO_ROOT}/clio-api/" \
"${SERVER_USER}@${SERVER_HOST}:${API_CODE_DIR}/"
echo "Running uv sync on server..."
${SSH_CMD} "cd ${API_CODE_DIR} && uv sync"
echo "clio-api deployed."
}
deploy_ui() {
echo "=== Deploying clio-ui ==="
rsync -avz --delete \
-e "${RSYNC_SSH}" \
"${CLIO_ROOT}/clio-ui/" \
"${SERVER_USER}@${SERVER_HOST}:${UI_DIR}/"
echo "clio-ui deployed."
}
case "${1:-all}" in
api)
deploy_api
;;
ui)
deploy_ui
;;
all)
deploy_api
deploy_ui
;;
*)
echo "Usage: $0 [api|ui|all]"
exit 1
;;
esac
echo ""
echo "=== Deployment complete ==="
echo ""
echo "To restart the API service:"
echo " ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} 'sudo systemctl restart phone-recorder'"
echo ""
echo "To check status:"
echo " curl https://recordings.hallocks.xyz/health"

102
clio-pi/README.md Normal file
View file

@ -0,0 +1,102 @@
# Clio Pi
Raspberry Pi recording station for the Clio audio system.
## Features
- Continuous audio recording with ffmpeg (opus codec, 2hr segments)
- Systemd service for auto-start on boot
- HTML status page showing recording coverage
- NTP time sync via chrony
- SSH key-only access (hardened)
## Quick Setup
1. Flash Raspberry Pi OS Lite to SD card using Raspberry Pi Imager
2. Configure in Imager: hostname, user/pass, WiFi, enable SSH
3. Boot and SSH into Pi
4. Copy this directory to the Pi:
```bash
rsync -avz /work/projects/clio/clio-pi/ pi@<pi-ip>:~/clio-pi/
```
5. Run setup:
```bash
ssh pi@<pi-ip>
cd ~/clio-pi
sudo ./setup.sh
```
6. Add your SSH key before rebooting:
```bash
ssh-copy-id pi@<pi-ip>
```
7. Start recording:
```bash
sudo systemctl start record.service
```
## Files
```
clio-pi/
├── setup.sh # Main setup script (run with sudo)
├── bin/
│ ├── record.sh # Recording loop (ffmpeg)
│ └── gen_recording_status.py # Status page generator
├── systemd/
│ └── record.service # Systemd service unit
└── README.md
```
## Configuration
Edit `~/bin/record.sh` environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `AUDIO_DEVICE` | `hw:1` | ALSA device (run `arecord -l` to list) |
| `SEGMENT_SECONDS` | `7200` | Segment length (2 hours) |
| `RECORDINGS_DIR` | `~/recordings` | Output directory |
| `SOURCE_NAME` | `stationary` | Filename prefix |
## Services
| Service | Description |
|---------|-------------|
| `record.service` | Recording daemon |
| `chrony` | NTP time sync |
| `nginx` | Status page web server |
## Commands
```bash
# Recording service
sudo systemctl start record.service
sudo systemctl stop record.service
sudo systemctl status record.service
journalctl -u record.service -f
# Check time sync
chronyc tracking
timedatectl
# View status page
curl http://localhost/
```
## Syncing Recordings to Server
TODO: Add rsync/sync script to push recordings to server (10.0.0.45)
## Audio Device Setup
List available devices:
```bash
arecord -l
```
Example output:
```
card 1: Device [USB Audio Device], device 0: USB Audio [USB Audio]
```
This would be `hw:1,0` or just `hw:1`.

View file

@ -0,0 +1,352 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import html
import os
import re
import shutil
import statistics
from pathlib import Path
from typing import List, Tuple, Optional
FNAME_RE = re.compile(r"^stationary-(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.opus$")
def parse_start(path: Path, tz) -> Optional[dt.datetime]:
m = FNAME_RE.match(path.name)
if not m:
return None
date_str, time_str = m.groups()
start = dt.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H-%M-%S")
return start.replace(tzinfo=tz)
def get_duration_seconds(path: Path) -> Optional[float]:
try:
from mutagen.oggopus import OggOpus # optional
audio = OggOpus(path)
return float(getattr(audio.info, "length", 0.0)) or None
except Exception:
return None
def human_delta(delta: dt.timedelta) -> str:
secs = int(abs(delta).total_seconds())
d, rem = divmod(secs, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
parts = []
if d: parts.append(f"{d}d")
if h: parts.append(f"{h}h")
if m: parts.append(f"{m}m")
if not parts: parts.append(f"{s}s")
return " ".join(parts)
def merge_intervals(intervals: List[Tuple[dt.datetime, dt.datetime]]) -> List[Tuple[dt.datetime, dt.datetime]]:
if not intervals: return []
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for s, e in intervals[1:]:
ls, le = merged[-1]
if s <= le:
merged[-1] = (ls, max(le, e))
else:
merged.append((s, e))
return merged
def day_bounds(d: dt.date, tz):
start = dt.datetime(d.year, d.month, d.day, tzinfo=tz)
return start, start + dt.timedelta(days=1)
def intersect(a: Tuple[dt.datetime, dt.datetime], b: Tuple[dt.datetime, dt.datetime]) -> Optional[Tuple[dt.datetime, dt.datetime]]:
s1, e1 = a; s2, e2 = b
s = max(s1, s2); e = min(e1, e2)
return (s, e) if s < e else None
def pct(x, total):
return 0 if total <= 0 else max(0, min(100, (x/total)*100))
def fmt_ampm(t: dt.datetime) -> str:
s = t.strftime("%I:%M%p") # 01:05PM
return s.lstrip("0")
def build(args):
tz = dt.datetime.now().astimezone().tzinfo
rec_dir = Path(args.dir).expanduser().resolve()
files = [p for p in rec_dir.glob("*.opus") if p.is_file()]
files.sort()
# Gather metadata
items = [] # (path, start_dt, size_bytes, duration_opt)
for p in files:
s = parse_start(p, tz)
if not s:
continue
dur = get_duration_seconds(p) if args.read_durations else None
try:
size = p.stat().st_size
except Exception:
size = 0
items.append((p, s, size, dur))
items.sort(key=lambda x: x[1])
starts = [s for _, s, _, _ in items]
deltas = [(b - a).total_seconds() for a, b in zip(starts, starts[1:])]
# Duration policy (defaults: NO inference, assume 2h)
if args.infer:
sane = [d for d in deltas if args.infer_min_sec <= d <= args.infer_max_sec]
inferred_default = (statistics.median(sane) if sane else args.assume_duration_sec or args.infer_default_sec)
inferred_default = max(args.infer_min_sec, min(args.infer_max_sec, inferred_default))
else:
inferred_default = args.assume_duration_sec or args.infer_default_sec
# Build intervals
intervals: List[Tuple[dt.datetime, dt.datetime]] = []
for idx, (p, s, _size, dur) in enumerate(items):
if dur is None or dur <= 0:
dur_use = float(inferred_default)
else:
dur_use = dur
if idx < len(items)-1 and args.guard_gap_sec > 0:
to_next = (items[idx+1][1] - s).total_seconds()
dur_use = min(dur_use, max(1.0, to_next - args.guard_gap_sec))
e = s + dt.timedelta(seconds=max(1.0, dur_use))
intervals.append((s, e))
gen_time = dt.datetime.now(tz)
last_start = starts[-1] if starts else None
# FS usage for recordings partition
try:
total_b, used_b, free_b = shutil.disk_usage(str(rec_dir))
except Exception:
total_b = used_b = free_b = 0
# Window
today = gen_time.date()
start_date = today - dt.timedelta(days=args.days - 1)
big_gap = dt.timedelta(minutes=args.gap_yellow_min)
green_gap = dt.timedelta(minutes=args.gap_green_min)
rows = []
window_total_bytes = 0
for i in range(args.days):
d = start_date + dt.timedelta(days=i)
d_start, d_end = day_bounds(d, tz)
# Per-day file count and bytes (count files that START this day)
day_files = [(p, s, size) for (p, s, size, _du) in [(p, s, size, du) for (p, s, size, du) in items] if d_start <= s < d_end]
day_bytes = sum(size for (_p, _s, size) in day_files)
window_total_bytes += day_bytes
# Intervals overlapping this day
day_intervals = []
for (s, e) in intervals:
inter = intersect((s, e), (d_start, d_end))
if inter:
day_intervals.append(inter)
merged = merge_intervals(day_intervals)
covered = sum((e - s).total_seconds() for s, e in merged)
day_seconds = 86400.0
coverage_pct = pct(covered, day_seconds)
# Max gap includes edges
if not merged:
max_gap = dt.timedelta(days=1)
else:
gaps = [merged[0][0] - d_start]
gaps += [b[0] - a[1] for a, b in zip(merged, merged[1:])]
gaps.append(d_end - merged[-1][1])
max_gap = max(gaps) if gaps else dt.timedelta(0)
# Status
count = len(day_files)
if count == 0:
status = "red"
elif max_gap <= green_gap and coverage_pct >= args.green_cover_pct:
status = "green"
elif max_gap >= big_gap or coverage_pct < args.yellow_cover_pct:
status = "yellow"
else:
status = "yellow"
# Mini timeline segments
segs = []
for s, e in merged:
left = pct((s - d_start).total_seconds(), day_seconds)
width = pct((e - s).total_seconds(), day_seconds)
if width > 0:
segs.append((left, width))
# Details list: show ALL spans; AM/PM times
details_txt = ", ".join(f"{fmt_ampm(s)}{fmt_ampm(e)}" for s, e in merged)
rows.append({
"date": d.isoformat(),
"count": count,
"bytes": day_bytes,
"max_gap": max_gap,
"coverage_pct": coverage_pct,
"status": status,
"segments": segs,
"details": details_txt or "",
})
# HTML
def hhmm(td: dt.timedelta) -> str:
total = int(td.total_seconds())
h = total // 3600
m = (total % 3600) // 60
return f"{h:02d}:{m:02d}"
def fmt_bytes(n: int) -> str:
units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
x = float(n)
for u in units:
if x < 1024.0 or u == units[-1]:
return f"{int(x)} {u}" if u == "B" else f"{x:.1f} {u}"
x /= 1024.0
title = args.title
last_str = ""
if last_start:
last_str = f"{last_start.strftime('%Y-%m-%d %I:%M:%S %p %Z')} ({human_delta(gen_time - last_start)} ago)"
# FS usage strings
total_str = fmt_bytes(total_b)
used_str = fmt_bytes(used_b)
free_str = fmt_bytes(free_b)
window_bytes_str = fmt_bytes(window_total_bytes)
html_out = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="300">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
:root {{
--green:#30a46c; --yellow:#f5d90a; --red:#e5484d;
--bg-green:#e9f7f0; --bg-yellow:#fff8cc; --bg-red:#fdeaea;
}}
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 1.5rem; }}
h1 {{ margin: 0 0 .4rem 0; }}
.muted {{ color: #666; font-size: .9rem; }}
table {{ border-collapse: collapse; width: 100%; max-width: 1200px; }}
th, td {{ padding: .5rem .6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: middle; }}
tr:hover td {{ background: #fafafa; }}
.right {{ text-align: right; }}
.row-green td {{ background: var(--bg-green); }}
.row-yellow td {{ background: var(--bg-yellow); }}
.row-red td {{ background: var(--bg-red); }}
.status-dot {{ display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle;margin-right:.4rem;}}
.green {{ background: var(--green); }}
.yellow {{ background: var(--yellow); }}
.red {{ background: var(--red); }}
.bar {{ position: relative; height: 12px; background: #eee; border-radius: 6px; overflow: hidden; }}
.bar .seg {{ position: absolute; top:0; bottom:0; background: var(--green); opacity:.9; }}
.cover-label {{ font-size:.85rem; white-space:nowrap; }}
.nowrap {{ white-space:nowrap; }}
.hdr {{ margin-top:.6rem; margin-bottom:.8rem; }}
.kvs span {{ margin-right: 1rem; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<div class="muted">Generated at {gen_time.strftime('%Y-%m-%d %I:%M:%S %p %Z')}</div>
<div class="hdr kvs muted">
<span><strong>Last recording started:</strong> {html.escape(last_str)}</span>
<span><strong>Window:</strong> {args.days} days</span>
<span><strong>Dir:</strong> {html.escape(str(rec_dir))}</span>
</div>
<div class="hdr kvs">
<span><strong>Data in window:</strong> {window_bytes_str}</span>
<span><strong>Filesystem usage (recordings partition):</strong> used {used_str} free {free_str} total {total_str}</span>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th class="right"># Recs</th>
<th class="right">Data</th>
<th class="right">Max gap</th>
<th>Coverage</th>
<th>Spans</th>
<th>Status</th>
</tr>
</thead>
<tbody>
"""
for r in reversed(rows):
cls = f"row-{r['status']}"
cov = int(round(r["coverage_pct"]))
seg_divs = "".join(
f'<div class="seg" style="left:{left:.3f}%;width:{width:.3f}%"></div>'
for left, width in r["segments"]
)
data_str = fmt_bytes(r["bytes"])
html_out += f""" <tr class="{cls}">
<td class="nowrap">{r['date']}</td>
<td class="right">{r['count']}</td>
<td class="right">{data_str}</td>
<td class="right">{hhmm(r['max_gap'])}</td>
<td>
<div class="bar" title="{cov}% covered">{seg_divs}</div>
<div class="cover-label">{cov}%</div>
</td>
<td><div class="muted">{html.escape(r['details'])}</div></td>
<td><span class="status-dot {r['status']}"></span>{r['status']}</td>
</tr>
"""
html_out += """ </tbody>
</table>
<p class="muted">Legend: <span class="status-dot green"></span> green = nearly continuous <span class="status-dot yellow"></span> yellow = gaps or low coverage <span class="status-dot red"></span> red = no recordings</p>
</body>
</html>
"""
out_path = Path(args.out).resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
tmp = out_path.with_suffix(out_path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(html_out)
os.replace(tmp, out_path)
def main():
p = argparse.ArgumentParser(description="Generate recordings status HTML")
p.add_argument("--dir", default=str(Path.home() / "recordings"),
help="Directory containing .opus files (default: ~/recordings)")
p.add_argument("--out", default="/var/www/html/index.html",
help="HTML output path (default: /var/www/html/index.html)")
# Defaults tuned so cron needs no flags:
p.add_argument("--days", type=int, default=365, help="How many days to show (default: 365)")
p.add_argument("--gap-yellow-min", type=int, default=120, help="Gap (minutes) that marks a day as yellow (default: 120)")
p.add_argument("--gap-green-min", type=int, default=15, help="Max gap (minutes) to still be green (default: 15)")
p.add_argument("--read-durations", action="store_true",
help="Use mutagen to read exact file durations (optional; default off).")
p.add_argument("--green-cover-pct", type=float, default=90.0,
help="Min coverage%% for green (default: 90)")
p.add_argument("--yellow-cover-pct", type=float, default=10.0,
help="Coverage%% below which day is yellow (default: 10)")
# Duration policy: default is NO inference, assume 2h
p.add_argument("--assume-duration-sec", type=int, default=7200,
help="Assumed duration (s) when real duration is unavailable. Default: 7200 (2h).")
p.add_argument("--infer", action="store_true",
help="Enable inferring duration from median start deltas (disabled by default).")
p.add_argument("--infer-min-sec", type=int, default=60,
help="Ignore deltas smaller than this when inferring (default: 60s)")
p.add_argument("--infer-max-sec", type=int, default=6*3600,
help="Ignore deltas larger than this when inferring (default: 6h)")
p.add_argument("--infer-default-sec", type=int, default=20*60,
help="Fallback segment length if nothing else is available (default: 1200s)")
p.add_argument("--guard-gap-sec", type=int, default=0,
help="Dont let a segment extend within this many seconds of next start (default: 0)")
p.add_argument("--title", default="Portable Recorder Status", help="Page title")
args = p.parse_args()
build(args)
if __name__ == "__main__":
main()

31
clio-pi/bin/record.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
# Configuration
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:1}" # ALSA device (run 'arecord -l' to list)
SEGMENT_SECONDS="${SEGMENT_SECONDS:-7200}" # 2 hours per file
RECORDINGS_DIR="${RECORDINGS_DIR:-$HOME/recordings}"
SOURCE_NAME="${SOURCE_NAME:-stationary}" # Prefix for filenames
mkdir -p "${RECORDINGS_DIR}"
echo "Starting recording..."
echo " Device: ${AUDIO_DEVICE}"
echo " Segment: ${SEGMENT_SECONDS}s"
echo " Output: ${RECORDINGS_DIR}/${SOURCE_NAME}-*.opus"
while true; do
ffmpeg -f alsa -ac 1 -i "${AUDIO_DEVICE}" \
-c:a libopus \
-b:a 32k \
-f segment \
-segment_time "${SEGMENT_SECONDS}" \
-strftime 1 \
-reset_timestamps 1 \
"${RECORDINGS_DIR}/${SOURCE_NAME}-%Y-%m-%d_%H-%M-%S.opus" \
2>&1 | while read line; do echo "[ffmpeg] $line"; done
echo "Recording stopped, restarting in 5s..."
sleep 5
done

238
clio-pi/setup.sh Executable file
View file

@ -0,0 +1,238 @@
#!/bin/bash
set -euo pipefail
# Clio Pi Setup Script
# Run this after a fresh Raspberry Pi OS install
#
# Prerequisites:
# 1. Flash Raspberry Pi OS Lite to SD card
# 2. Use Raspberry Pi Imager to pre-configure:
# - Hostname (e.g., clio-pi)
# - Username/password
# - WiFi credentials
# - Enable SSH
# 3. Boot Pi and SSH in
# 4. Copy this repo to /home/pi/clio-pi
# 5. Run: sudo ./setup.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PI_USER="${PI_USER:-pi}"
PI_HOME="/home/${PI_USER}"
echo "=== Clio Pi Setup ==="
echo "User: ${PI_USER}"
echo "Home: ${PI_HOME}"
echo ""
# ============================================================
# 1. System updates
# ============================================================
echo ">>> Updating system packages..."
apt-get update
apt-get upgrade -y
# ============================================================
# 2. Install required packages
# ============================================================
echo ">>> Installing required packages..."
apt-get install -y \
ffmpeg \
python3 \
python3-pip \
nginx-light \
chrony \
rsync
# Optional: for reading audio durations
pip3 install --break-system-packages mutagen || true
# ============================================================
# 3. Configure NTP/time sync (chrony)
# ============================================================
echo ">>> Configuring time sync (chrony)..."
cat > /etc/chrony/chrony.conf << 'EOF'
# Use default NTP pools
pool 2.debian.pool.ntp.org iburst
# Allow the system clock to be stepped in the first 3 updates
# if its offset is larger than 1 second
makestep 1.0 3
# Enable kernel synchronization of the real-time clock (RTC)
rtcsync
# Serve time to the local subnet (optional)
#allow 10.0.0.0/24
# Log tracking, measurements, statistics
logdir /var/log/chrony
EOF
systemctl enable chrony
systemctl restart chrony
# Set timezone (adjust as needed)
timedatectl set-timezone America/New_York
echo ">>> Time sync configured. Current time:"
timedatectl
# ============================================================
# 4. Configure SSH key-only access
# ============================================================
echo ">>> Configuring SSH for key-only access..."
# Ensure .ssh directory exists
mkdir -p "${PI_HOME}/.ssh"
chmod 700 "${PI_HOME}/.ssh"
touch "${PI_HOME}/.ssh/authorized_keys"
chmod 600 "${PI_HOME}/.ssh/authorized_keys"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/.ssh"
# Harden sshd_config
SSHD_CONFIG="/etc/ssh/sshd_config"
# Backup original
cp "${SSHD_CONFIG}" "${SSHD_CONFIG}.bak"
# Disable password authentication
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' "${SSHD_CONFIG}"
sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' "${SSHD_CONFIG}"
sed -i 's/^#*UsePAM.*/UsePAM no/' "${SSHD_CONFIG}"
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' "${SSHD_CONFIG}"
# Ensure pubkey auth is enabled
grep -q "^PubkeyAuthentication" "${SSHD_CONFIG}" || echo "PubkeyAuthentication yes" >> "${SSHD_CONFIG}"
echo ""
echo "!!! IMPORTANT !!!"
echo "Before rebooting, add your SSH public key to:"
echo " ${PI_HOME}/.ssh/authorized_keys"
echo ""
echo "From your local machine, run:"
echo " ssh-copy-id ${PI_USER}@<pi-ip-address>"
echo ""
echo "Or manually append your ~/.ssh/id_ed25519.pub (or id_rsa.pub)"
echo ""
# Don't restart SSH yet - user needs to add their key first
# ============================================================
# 5. Set up recording directories
# ============================================================
echo ">>> Setting up recording directories..."
mkdir -p "${PI_HOME}/recordings"
mkdir -p "${PI_HOME}/bin"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/recordings"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
# ============================================================
# 6. Install recording scripts
# ============================================================
echo ">>> Installing recording scripts..."
cp "${SCRIPT_DIR}/bin/record.sh" "${PI_HOME}/bin/"
cp "${SCRIPT_DIR}/bin/gen_recording_status.py" "${PI_HOME}/bin/"
chmod +x "${PI_HOME}/bin/record.sh"
chmod +x "${PI_HOME}/bin/gen_recording_status.py"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
# ============================================================
# 7. Install systemd service
# ============================================================
echo ">>> Installing systemd service..."
cp "${SCRIPT_DIR}/systemd/record.service" /etc/systemd/system/
sed -i "s|/home/pi|${PI_HOME}|g" /etc/systemd/system/record.service
sed -i "s|User=pi|User=${PI_USER}|g" /etc/systemd/system/record.service
systemctl daemon-reload
systemctl enable record.service
# Don't start yet - user may need to configure audio device
# ============================================================
# 8. Set up cron for status page
# ============================================================
echo ">>> Setting up cron job for status page..."
CRON_LINE="*/10 * * * * python3 ${PI_HOME}/bin/gen_recording_status.py --dir ${PI_HOME}/recordings"
# Install cron job for pi user
(crontab -u "${PI_USER}" -l 2>/dev/null | grep -v gen_recording_status; echo "${CRON_LINE}") | crontab -u "${PI_USER}" -
# ============================================================
# 9. Configure nginx for status page
# ============================================================
echo ">>> Configuring nginx for status page..."
cat > /etc/nginx/sites-available/default << 'EOF'
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html;
server_name _;
location / {
try_files $uri $uri/ =404;
}
}
EOF
# Ensure www-data can read the status file
mkdir -p /var/www/html
chown -R www-data:www-data /var/www/html
# Allow pi user to write to /var/www/html
usermod -a -G www-data "${PI_USER}" || true
chmod 775 /var/www/html
systemctl enable nginx
systemctl restart nginx
# Generate initial status page
sudo -u "${PI_USER}" python3 "${PI_HOME}/bin/gen_recording_status.py" --dir "${PI_HOME}/recordings" || true
# ============================================================
# 10. Print audio device info
# ============================================================
echo ""
echo "=== Audio Devices ==="
arecord -l || echo "(no audio devices found yet)"
echo ""
echo "The recording script uses hw:1 by default."
echo "Edit ${PI_HOME}/bin/record.sh if your device is different."
# ============================================================
# Summary
# ============================================================
echo ""
echo "============================================================"
echo "=== Setup Complete ==="
echo "============================================================"
echo ""
echo "Next steps:"
echo ""
echo "1. Add your SSH public key:"
echo " From your computer: ssh-copy-id ${PI_USER}@$(hostname -I | awk '{print $1}')"
echo ""
echo "2. Test SSH key login works, then restart SSH:"
echo " sudo systemctl restart sshd"
echo ""
echo "3. Check/configure audio device:"
echo " arecord -l"
echo " Edit ~/bin/record.sh if needed (currently uses hw:1)"
echo ""
echo "4. Start recording service:"
echo " sudo systemctl start record.service"
echo " sudo systemctl status record.service"
echo ""
echo "5. View status page:"
echo " http://$(hostname -I | awk '{print $1}')/"
echo ""
echo "Services installed:"
echo " - chrony (NTP time sync) - RUNNING"
echo " - nginx (status page) - RUNNING"
echo " - record.service - ENABLED (not started)"
echo ""
echo "Cron job installed:"
echo " - gen_recording_status.py runs every 10 minutes"
echo ""

View file

@ -0,0 +1,11 @@
[Unit]
Description=Record
[Service]
ExecStart=/home/pi/bin/record.sh
User=pi
Type=simple
Restart=on-failure
[Install]
WantedBy=multi-user.target

View file

@ -510,7 +510,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
style={{ style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px',
background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4,
color: 'var(--amber)', cursor: 'pointer', color: 'var(--amber)',
}} }}
> >
Go to {formatTime(currentTime)} Go to {formatTime(currentTime)}