Remove redundant cursor styling
This commit is contained in:
parent
080b4bdaf1
commit
b24dd72ae0
93 changed files with 9058 additions and 1 deletions
4
another_todo.txt
Normal file
4
another_todo.txt
Normal 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
10
clio-ai/real_time/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
clio-ai/real_time/.python-version
Normal file
1
clio-ai/real_time/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
0
clio-ai/real_time/README.md
Normal file
0
clio-ai/real_time/README.md
Normal file
154
clio-ai/real_time/collect_samples.sh
Executable file
154
clio-ai/real_time/collect_samples.sh
Executable 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
61
clio-ai/real_time/flake.lock
generated
Normal 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
|
||||
}
|
||||
60
clio-ai/real_time/flake.nix
Normal file
60
clio-ai/real_time/flake.nix
Normal 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
562
clio-ai/real_time/listener.py
Executable 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()
|
||||
6
clio-ai/real_time/main.py
Normal file
6
clio-ai/real_time/main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from real-time!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
clio-ai/real_time/pyproject.toml
Normal file
16
clio-ai/real_time/pyproject.toml
Normal 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",
|
||||
]
|
||||
3
clio-ai/real_time/run_listener.sh
Executable file
3
clio-ai/real_time/run_listener.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
cd "$(dirname "$0")"
|
||||
exec nix develop --command uv run python listener.py "$@"
|
||||
71
clio-ai/real_time/test_corsair_mic.sh
Executable file
71
clio-ai/real_time/test_corsair_mic.sh
Executable 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
|
||||
191
clio-ai/real_time/train_classifier.py
Normal file
191
clio-ai/real_time/train_classifier.py
Normal 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
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
10
clio-ai/transcription/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
clio-ai/transcription/.python-version
Normal file
1
clio-ai/transcription/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
0
clio-ai/transcription/README.md
Normal file
0
clio-ai/transcription/README.md
Normal file
61
clio-ai/transcription/flake.lock
generated
Normal file
61
clio-ai/transcription/flake.lock
generated
Normal 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
|
||||
}
|
||||
46
clio-ai/transcription/flake.nix
Normal file
46
clio-ai/transcription/flake.nix
Normal 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"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
6
clio-ai/transcription/main.py
Normal file
6
clio-ai/transcription/main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from view-recordings!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
clio-ai/transcription/pyproject.toml
Normal file
10
clio-ai/transcription/pyproject.toml
Normal 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",
|
||||
]
|
||||
187
clio-ai/transcription/transcribe.py
Normal file
187
clio-ai/transcription/transcribe.py
Normal 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
1146
clio-ai/transcription/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
87
clio-android/.claude/CLAUDE.md
Normal file
87
clio-android/.claude/CLAUDE.md
Normal 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
1
clio-android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
android_studio_install_path
|
||||
61
clio-android/app/build.gradle.kts
Normal file
61
clio-android/app/build.gradle.kts
Normal 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
7
clio-android/app/proguard-rules.pro
vendored
Normal 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 { *; }
|
||||
74
clio-android/app/src/main/AndroidManifest.xml
Normal file
74
clio-android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal file
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal file
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
9
clio-android/app/src/main/res/values/colors.xml
Normal file
9
clio-android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
58
clio-android/app/src/main/res/values/strings.xml
Normal file
58
clio-android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
11
clio-android/app/src/main/res/values/themes.xml
Normal file
11
clio-android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path
|
||||
name="recordings"
|
||||
path="recordings/" />
|
||||
</paths>
|
||||
4
clio-android/build.gradle.kts
Normal file
4
clio-android/build.gradle.kts
Normal 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
136
clio-android/description.md
Normal 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
61
clio-android/flake.lock
generated
Normal 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
52
clio-android/flake.nix
Normal 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"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
4
clio-android/gradle.properties
Normal file
4
clio-android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
clio-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
clio-android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
clio-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
clio-android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
clio-android/gradlew
vendored
Executable 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
94
clio-android/gradlew.bat
vendored
Normal 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
|
||||
8
clio-android/local.properties
Normal file
8
clio-android/local.properties
Normal 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
14
clio-android/notes.txt
Normal 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?
|
||||
18
clio-android/settings.gradle.kts
Normal file
18
clio-android/settings.gradle.kts
Normal 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
117
clio-android/todo.md
Normal 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
10
clio-api/.gitignore
vendored
Normal 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
1
clio-api/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
0
clio-api/README.md
Normal file
0
clio-api/README.md
Normal file
74
clio-api/deploy.sh
Executable file
74
clio-api/deploy.sh
Executable 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
46
clio-api/nixos/Caddyfile
Normal 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
|
||||
# }
|
||||
# }
|
||||
182
clio-api/nixos/phone-recorder.nix
Normal file
182
clio-api/nixos/phone-recorder.nix
Normal 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 =
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
33
clio-api/nixos/phone-recorder.service
Normal file
33
clio-api/nixos/phone-recorder.service
Normal 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
|
||||
8
clio-api/requirements.txt
Normal file
8
clio-api/requirements.txt
Normal 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
126
clio-infra/ARCHITECTURE.md
Normal 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
153
clio-infra/TODO.md
Normal 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 |
|
||||
46
clio-infra/nixos/Caddyfile
Normal file
46
clio-infra/nixos/Caddyfile
Normal 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
|
||||
# }
|
||||
# }
|
||||
182
clio-infra/nixos/phone-recorder.nix
Normal file
182
clio-infra/nixos/phone-recorder.nix
Normal 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 =
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
33
clio-infra/nixos/phone-recorder.service
Normal file
33
clio-infra/nixos/phone-recorder.service
Normal 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
|
||||
74
clio-infra/scripts/deploy-api-server.sh
Executable file
74
clio-infra/scripts/deploy-api-server.sh
Executable 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 ==="
|
||||
15
clio-infra/scripts/deploy-api.sh
Executable file
15
clio-infra/scripts/deploy-api.sh
Executable 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
78
clio-infra/scripts/deploy.sh
Executable 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
102
clio-pi/README.md
Normal 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`.
|
||||
352
clio-pi/bin/gen_recording_status.py
Executable file
352
clio-pi/bin/gen_recording_status.py
Executable 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="Don’t 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
31
clio-pi/bin/record.sh
Executable 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
238
clio-pi/setup.sh
Executable 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 ""
|
||||
11
clio-pi/systemd/record.service
Normal file
11
clio-pi/systemd/record.service
Normal 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
|
||||
|
|
@ -510,7 +510,7 @@ function RecordingDetail({ recording: r, data, onBack, allHighlights, setAllHigh
|
|||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '3px 10px',
|
||||
background: 'var(--bg4)', border: '1px solid var(--border)', borderRadius: 4,
|
||||
color: 'var(--amber)', cursor: 'pointer',
|
||||
color: 'var(--amber)',
|
||||
}}
|
||||
>
|
||||
↓ Go to {formatTime(currentTime)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue