feat(platform): add new services, tools, tests and crews modules

New router intelligence modules (26 files): alert_ingest/store, audit_store,
architecture_pressure, backlog_generator/store, cost_analyzer, data_governance,
dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment,
platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files),
signature_state_store, sofiia_auto_router, tool_governance

New services:
- sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static
- memory-service: integration_endpoints, integrations, voice_endpoints, static UI
- aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents)
- sofiia-supervisor: new supervisor service
- aistalk-bridge-lite: Telegram bridge lite
- calendar-service: CalDAV calendar service with reminders
- mlx-stt-service / mlx-tts-service: Apple Silicon speech services
- binance-bot-monitor: market monitor service
- node-worker: STT/TTS memory providers

New tools (9): agent_email, browser_tool, contract_tool, observability_tool,
oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault

New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus,
farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine,
session_context, style_adapter, telemetry)

Tests: 85+ test files for all new modules
Made-with: Cursor
This commit is contained in:
Apple
2026-03-03 07:14:14 -08:00
parent e9dedffa48
commit 129e4ea1fc
241 changed files with 69349 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
"""
DAARION Memory Service - Integration API Endpoints
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import logging
from .integrations import obsidian_integrator, gdrive_integrator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/integrations", tags=["integrations"])
class SetVaultRequest(BaseModel):
vault_path: str
class SyncRequest(BaseModel):
output_dir: Optional[str] = "/tmp/daarion_sync"
include_attachments: Optional[bool] = False
folder_filter: Optional[List[str]] = None
tag_filter: Optional[List[str]] = None
class GDriveSyncRequest(BaseModel):
output_dir: Optional[str] = "/tmp/daarion_sync"
folder_ids: Optional[List[str]] = None
file_extensions: Optional[List[str]] = None
@router.get("/status")
async def get_integrations_status():
"""Get status of all integrations"""
obsidian_status = obsidian_integrator.get_status()
gdrive_status = gdrive_integrator.get_status()
return {
"obsidian": obsidian_status,
"google_drive": gdrive_status
}
# =====================
# OBSIDIAN ENDPOINTS
# =====================
@router.post("/obsidian/set-vault")
async def set_obsidian_vault(request: SetVaultRequest):
"""Set Obsidian vault path"""
success = obsidian_integrator.set_vault_path(request.vault_path)
if not success:
raise HTTPException(status_code=400, detail="Invalid vault path")
return {"status": "ok", "vault_path": request.vault_path}
@router.post("/obsidian/scan")
async def scan_obsidian_vault():
"""Scan Obsidian vault for notes"""
if not obsidian_integrator.vault_path:
raise HTTPException(status_code=400, detail="Vault path not set")
stats = obsidian_integrator.scan_vault()
return {
"status": "ok",
"stats": stats
}
@router.get("/obsidian/search")
async def search_obsidian_notes(query: str, limit: int = 10):
"""Search Obsidian notes"""
if not obsidian_integrator.notes_cache:
raise HTTPException(status_code=400, detail="Vault not scanned")
results = obsidian_integrator.search_notes(query, limit=limit)
return {
"query": query,
"results": [
{
"title": r["title"],
"path": r["path"],
"tags": r["tags"],
"match_score": r.get("match_score", 0),
"preview": r["content"][:200] + "..." if len(r["content"]) > 200 else r["content"]
}
for r in results
]
}
@router.post("/obsidian/sync")
async def sync_obsidian_vault(request: SyncRequest):
"""Sync Obsidian vault to DAARION"""
if not obsidian_integrator.vault_path:
raise HTTPException(status_code=400, detail="Vault path not set")
if not obsidian_integrator.notes_cache:
obsidian_integrator.scan_vault()
from pathlib import Path
output_path = Path(request.output_dir)
output_path.mkdir(parents=True, exist_ok=True)
stats = obsidian_integrator.sync_to_daarion(
output_path,
include_attachments=request.include_attachments,
folder_filter=request.folder_filter,
tag_filter=request.tag_filter
)
return {
"status": "ok",
"stats": stats
}
@router.get("/obsidian/tags")
async def get_obsidian_tags():
"""Get all tags from Obsidian vault"""
if not obsidian_integrator.tags_index:
raise HTTPException(status_code=400, detail="Vault not scanned")
return {
"tags": [
{"name": tag, "count": len(notes)}
for tag, notes in sorted(
obsidian_integrator.tags_index.items(),
key=lambda x: len(x[1]),
reverse=True
)
]
}
@router.get("/obsidian/graph")
async def get_obsidian_graph():
"""Get note connection graph"""
if not obsidian_integrator.links_graph:
raise HTTPException(status_code=400, detail="Vault not scanned")
nodes = []
links = []
for note_title, note_data in obsidian_integrator.notes_cache.items():
nodes.append({
"id": note_title,
"title": note_title,
"tags": note_data["tags"],
"size": note_data["size"]
})
for note_title, graph_data in obsidian_integrator.links_graph.items():
for linked_note in graph_data["outbound"]:
if linked_note in obsidian_integrator.notes_cache:
links.append({
"source": note_title,
"target": linked_note
})
return {
"nodes": nodes,
"links": links
}
# =====================
# GOOGLE DRIVE ENDPOINTS
# =====================
@router.post("/google-drive/auth")
async def authenticate_google_drive():
"""Authenticate with Google Drive"""
success = gdrive_integrator.authenticate()
if not success:
raise HTTPException(
status_code=401,
detail="Authentication failed. Check client_secrets.json"
)
return {"status": "ok", "authenticated": True}
@router.get("/google-drive/files")
async def list_google_drive_files(
folder_id: Optional[str] = None,
max_results: int = 50
):
"""List files from Google Drive"""
files = gdrive_integrator.list_files(folder_id=folder_id, max_results=max_results)
return {
"files": files,
"count": len(files)
}
@router.post("/google-drive/sync")
async def sync_google_drive(request: GDriveSyncRequest):
"""Sync files from Google Drive"""
from pathlib import Path
output_path = Path(request.output_dir)
output_path.mkdir(parents=True, exist_ok=True)
stats = gdrive_integrator.sync_to_daarion(
output_path,
folder_ids=request.folder_ids,
file_extensions=request.file_extensions
)
return {
"status": "ok",
"stats": stats
}
@router.get("/google-drive/folders")
async def get_google_drive_folders():
"""Get folder structure from Google Drive"""
structure = gdrive_integrator.get_folder_structure()
return {"structure": structure}

View File

@@ -0,0 +1,482 @@
"""
DAARION Memory Service - Integrations
Obsidian та Google Drive інтеграції
"""
import os
import re
import json
import logging
import hashlib
import shutil
import io
from pathlib import Path
from typing import List, Dict, Set, Optional, Any, Tuple
from datetime import datetime
logger = logging.getLogger(__name__)
class ObsidianIntegrator:
"""Obsidian інтегратор для Memory Service"""
HOST_VAULT_MAPPINGS = {
'/vault/rd': '/Users/apple/Desktop/R&D',
'/vault/obsidian': '/Users/apple/Documents/Obsidian Vault',
}
def __init__(self, vault_path: str = None):
self.vault_path = self._resolve_vault_path(vault_path) if vault_path else None
self.notes_cache = {}
self.links_graph = {}
self.tags_index = {}
def _resolve_vault_path(self, path: str) -> Path:
"""Resolve vault path, handling Docker-to-host mappings"""
path_obj = Path(path)
if path_obj.exists():
return path_obj
if path in self.HOST_VAULT_MAPPINGS:
resolved = Path(self.HOST_VAULT_MAPPINGS[path])
if resolved.exists():
return resolved
return path_obj
def find_vault(self) -> Optional[Path]:
"""Автоматично знайти Obsidian vault"""
possible_paths = [
Path.home() / "Documents" / "Obsidian Vault",
Path.home() / "Documents" / "Notes",
Path.home() / "Desktop" / "Obsidian Vault",
Path.home() / "Obsidian",
Path.home() / "Notes",
]
documents_path = Path.home() / "Documents"
if documents_path.exists():
for item in documents_path.iterdir():
if item.is_dir() and (item / ".obsidian").exists():
possible_paths.append(item)
for path in possible_paths:
if path.exists() and (path / ".obsidian").exists():
logger.info(f"Found Obsidian vault at: {path}")
return path
return None
def set_vault_path(self, vault_path: str) -> bool:
resolved = self._resolve_vault_path(vault_path)
if not resolved.exists():
logger.error(f"Vault path does not exist: {vault_path} (resolved: {resolved})")
return False
if not (resolved / ".obsidian").exists():
logger.error(f"Not a valid Obsidian vault: {vault_path} (resolved: {resolved})")
return False
self.vault_path = resolved
logger.info(f"Vault path set to: {resolved}")
return True
def scan_vault(self) -> Dict[str, Any]:
if not self.vault_path:
return {}
stats = {
'total_notes': 0,
'total_attachments': 0,
'total_links': 0,
'total_tags': 0,
'folders': set(),
'file_types': {},
'notes': []
}
for file_path in self.vault_path.rglob('*'):
if file_path.is_file() and not file_path.name.startswith('.'):
suffix = file_path.suffix.lower()
stats['file_types'][suffix] = stats['file_types'].get(suffix, 0) + 1
relative_folder = file_path.parent.relative_to(self.vault_path)
if relative_folder != Path('.'):
stats['folders'].add(str(relative_folder))
if suffix == '.md':
note_data = self._parse_note(file_path)
if note_data:
self.notes_cache[file_path.stem] = note_data
stats['notes'].append(note_data)
stats['total_notes'] += 1
stats['total_links'] += len(note_data['links'])
stats['total_tags'] += len(note_data['tags'])
else:
stats['total_attachments'] += 1
self._build_links_graph()
self._build_tags_index()
stats['folders'] = list(stats['folders'])
return stats
def _parse_note(self, file_path: Path) -> Optional[Dict[str, Any]]:
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
note_data = {
'title': file_path.stem,
'path': str(file_path.relative_to(self.vault_path)),
'full_path': str(file_path),
'size': len(content),
'created': datetime.fromtimestamp(file_path.stat().st_ctime),
'modified': datetime.fromtimestamp(file_path.stat().st_mtime),
'content': content,
'content_hash': hashlib.md5(content.encode()).hexdigest(),
'frontmatter': {},
'headings': [],
'links': [],
'tags': [],
'backlinks': [],
'blocks': []
}
frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
if frontmatter_match:
try:
import yaml
note_data['frontmatter'] = yaml.safe_load(frontmatter_match.group(1))
except Exception:
pass
headings = re.findall(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE)
note_data['headings'] = [(len(h[0]), h[1].strip()) for h in headings]
internal_links = re.findall(r'\[\[([^\]]+)\]\]', content)
note_data['links'] = [link.split('|')[0].strip() for link in internal_links]
tags = re.findall(r'(?:^|\s)#([\w\-\/]+)', content)
note_data['tags'] = list(set(tags))
blocks = re.findall(r'\^([\w\-]+)', content)
note_data['blocks'] = blocks
return note_data
except Exception as e:
logger.error(f"Error parsing note {file_path}: {e}")
return None
def _build_links_graph(self):
self.links_graph = {}
for note_title, note_data in self.notes_cache.items():
self.links_graph[note_title] = {
'outbound': note_data['links'],
'inbound': []
}
for note_title, note_data in self.notes_cache.items():
for linked_note in note_data['links']:
if linked_note in self.links_graph:
self.links_graph[linked_note]['inbound'].append(note_title)
if linked_note in self.notes_cache:
self.notes_cache[linked_note]['backlinks'].append(note_title)
def _build_tags_index(self):
self.tags_index = {}
for note_title, note_data in self.notes_cache.items():
for tag in note_data['tags']:
if tag not in self.tags_index:
self.tags_index[tag] = []
self.tags_index[tag].append(note_title)
def search_notes(self, query: str, search_content: bool = True) -> List[Dict[str, Any]]:
results = []
query_lower = query.lower()
for note_title, note_data in self.notes_cache.items():
match_score = 0
if query_lower in note_title.lower():
match_score += 10
for tag in note_data['tags']:
if query_lower in tag.lower():
match_score += 5
if search_content and query_lower in note_data['content'].lower():
match_score += 1
if match_score > 0:
result = note_data.copy()
result['match_score'] = match_score
results.append(result)
results.sort(key=lambda x: x['match_score'], reverse=True)
return results
def get_status(self) -> Dict[str, Any]:
return {
'available': True,
'vault_configured': self.vault_path is not None,
'vault_path': str(self.vault_path) if self.vault_path else None,
'notes_count': len(self.notes_cache),
'tags_count': len(self.tags_index)
}
class GoogleDriveIntegrator:
"""Google Drive інтегратор для Memory Service"""
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
CREDENTIALS_DIR = Path.home() / '.daarion'
CREDENTIALS_FILE = CREDENTIALS_DIR / 'google_credentials.json'
TOKEN_FILE = CREDENTIALS_DIR / 'google_token.json'
SUPPORTED_MIMETYPES = {
'application/vnd.google-apps.document': 'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': None,
'application/pdf': None,
'text/plain': None,
'text/markdown': None,
'application/vnd.google-apps.spreadsheet': 'text/csv',
}
def __init__(self):
self.service = None
self.CREDENTIALS_DIR.mkdir(exist_ok=True)
def authenticate(self) -> bool:
try:
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
except ImportError:
logger.warning("Google API libraries not installed")
return False
creds = None
if self.TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(self.TOKEN_FILE), self.SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
client_secrets = self.CREDENTIALS_DIR / 'client_secrets.json'
if not client_secrets.exists():
logger.warning("Google client_secrets.json not found")
return False
flow = InstalledAppFlow.from_client_secrets_file(
str(client_secrets), self.SCOPES)
creds = flow.run_local_server(port=0)
with open(self.TOKEN_FILE, 'w') as token:
token.write(creds.to_json())
self.service = build('drive', 'v3', credentials=creds)
logger.info("Google Drive API authenticated")
return True
def list_files(self, folder_id: Optional[str] = None,
max_results: int = 100) -> List[Dict]:
if not self.service:
if not self.authenticate():
return []
search_query = []
if folder_id:
search_query.append(f"'{folder_id}' in parents")
mime_conditions = []
for mime_type in self.SUPPORTED_MIMETYPES.keys():
mime_conditions.append(f"mimeType='{mime_type}'")
if mime_conditions:
search_query.append(f"({' or '.join(mime_conditions)})")
search_query.extend([
"trashed=false",
"mimeType!='application/vnd.google-apps.folder'"
])
final_query = ' and '.join(search_query)
try:
results = self.service.files().list(
q=final_query,
pageSize=max_results,
fields="nextPageToken, files(id, name, mimeType, size, createdTime, modifiedTime, parents, webViewLink)"
).execute()
return results.get('files', [])
except Exception as e:
logger.error(f"Error listing Google Drive files: {e}")
return []
def download_file(self, file_id: str, mime_type: str) -> Optional[str]:
if not self.service:
return None
try:
from googleapiclient.http import MediaIoBaseDownload
export_mime_type = self.SUPPORTED_MIMETYPES.get(mime_type)
if export_mime_type:
request = self.service.files().export_media(
fileId=file_id,
mimeType=export_mime_type
)
else:
request = self.service.files().get_media(fileId=file_id)
file_io = io.BytesIO()
downloader = MediaIoBaseDownload(file_io, request)
done = False
while done is False:
status, done = downloader.next_chunk()
content = file_io.getvalue()
for encoding in ['utf-8', 'utf-16', 'latin-1']:
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return None
except Exception as e:
logger.error(f"Error downloading file {file_id}: {e}")
return None
def get_status(self) -> Dict[str, Any]:
available = False
authenticated = False
try:
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
available = True
if self.TOKEN_FILE.exists():
authenticated = True
except ImportError:
pass
return {
'available': available,
'authenticated': authenticated,
'credentials_configured': self.CREDENTIALS_DIR.exists()
}
def sync_to_daarion(self, output_dir: Path,
folder_ids: List[str] = None,
file_extensions: List[str] = None) -> Dict[str, Any]:
"""Sync files from Google Drive to DAARION"""
stats = {
'total_files': 0,
'downloaded': 0,
'errors': 0,
'skipped': 0,
'files': []
}
all_files = []
if folder_ids:
for folder_id in folder_ids:
files = self.list_files(folder_id=folder_id)
all_files.extend(files)
else:
all_files = self.list_files()
stats['total_files'] = len(all_files)
for file_data in all_files:
file_id = file_data['id']
file_name = file_data['name']
mime_type = file_data['mimeType']
if file_extensions:
file_ext = Path(file_name).suffix.lower()
if file_ext not in file_extensions:
stats['skipped'] += 1
continue
content = self.download_file(file_id, mime_type)
if content:
safe_filename = "".join(c for c in file_name if c.isalnum() or c in (' ', '-', '_', '.')).rstrip()
file_path = output_dir / f"gdrive_{file_id}_{safe_filename}.txt"
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(f"# Google Drive: {file_name}\n")
f.write(f"Source: {file_data.get('webViewLink', 'N/A')}\n")
f.write(f"Modified: {file_data.get('modifiedTime', 'N/A')}\n")
f.write(f"MIME Type: {mime_type}\n\n")
f.write("---\n\n")
f.write(content)
stats['downloaded'] += 1
stats['files'].append({
'original_name': file_name,
'saved_path': str(file_path),
'file_id': file_id,
'size': len(content),
'url': file_data.get('webViewLink')
})
except Exception as e:
logger.error(f"Error saving {file_name}: {e}")
stats['errors'] += 1
else:
stats['errors'] += 1
return stats
def get_folder_structure(self, folder_id: str = None, level: int = 0) -> Dict:
"""Get Google Drive folder structure"""
if not self.service:
if not self.authenticate():
return {}
try:
query = "mimeType='application/vnd.google-apps.folder' and trashed=false"
if folder_id:
query += f" and '{folder_id}' in parents"
results = self.service.files().list(
q=query,
fields="files(id, name, parents)"
).execute()
folders = results.get('files', [])
structure = {}
for folder in folders:
folder_name = folder['name']
fid = folder['id']
structure[folder_name] = {
'id': fid,
'subfolders': self.get_folder_structure(fid, level + 1) if level < 3 else {}
}
return structure
except Exception as e:
logger.error(f"Error getting folder structure: {e}")
return {}
obsidian_integrator = ObsidianIntegrator()
gdrive_integrator = GoogleDriveIntegrator()

View File

@@ -0,0 +1,680 @@
"""
DAARION Memory Service — Voice Endpoints
STT: faster-whisper (Docker/Linux) → mlx-audio (macOS) → whisper-cli
TTS: edge-tts Python API (primary, pure Python, no ffmpeg needed)
→ piper (fallback, if model present)
→ espeak-ng (offline Linux fallback)
→ macOS say (fallback, macOS-only)
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice", tags=["voice"])
MODELS_CACHE: dict = {}
# ── Prometheus metrics (optional — skip if not installed) ─────────────────────
try:
from prometheus_client import Counter, Histogram
_tts_compute_hist = Histogram(
"voice_tts_compute_ms",
"TTS synthesis compute time in ms",
["engine", "voice"],
buckets=[50, 100, 250, 500, 1000, 2000, 5000],
)
_tts_bytes_hist = Histogram(
"voice_tts_audio_bytes",
"TTS audio output size in bytes",
["engine"],
buckets=[5000, 15000, 30000, 60000, 120000],
)
_tts_errors_total = Counter(
"voice_tts_errors_total",
"TTS engine errors",
["engine", "error_type"],
)
_stt_compute_hist = Histogram(
"voice_stt_compute_ms",
"STT transcription time in ms",
["engine"],
buckets=[200, 500, 1000, 2000, 5000, 10000],
)
_PROM_OK = True
except ImportError:
_PROM_OK = False
_tts_compute_hist = None
_tts_bytes_hist = None
_tts_errors_total = None
_stt_compute_hist = None
def _prom_tts_observe(engine: str, voice: str, ms: float, audio_bytes: int) -> None:
if not _PROM_OK:
return
try:
_tts_compute_hist.labels(engine=engine, voice=voice).observe(ms)
_tts_bytes_hist.labels(engine=engine).observe(audio_bytes)
except Exception:
pass
def _prom_tts_error(engine: str, error_type: str) -> None:
if not _PROM_OK:
return
try:
_tts_errors_total.labels(engine=engine, error_type=error_type).inc()
except Exception:
pass
def _prom_stt_observe(engine: str, ms: float) -> None:
if not _PROM_OK:
return
try:
_stt_compute_hist.labels(engine=engine).observe(ms)
except Exception:
pass
# ── Voice mapping ─────────────────────────────────────────────────────────────
# Maps UI voice id → edge-tts voice name
_EDGE_VOICES: dict[str, str] = {
"default": "uk-UA-PolinaNeural",
"Polina": "uk-UA-PolinaNeural",
"uk-UA-Polina": "uk-UA-PolinaNeural",
"uk-UA-PolinaNeural": "uk-UA-PolinaNeural",
"Ostap": "uk-UA-OstapNeural",
"uk-UA-Ostap": "uk-UA-OstapNeural",
"uk-UA-OstapNeural": "uk-UA-OstapNeural",
# English voices — used for English-language segments
"en-US-GuyNeural": "en-US-GuyNeural",
"en-US-JennyNeural": "en-US-JennyNeural",
"en": "en-US-GuyNeural",
# macOS-only names: map to closest Ukrainian voice
"Milena": "uk-UA-PolinaNeural",
"Yuri": "uk-UA-OstapNeural",
"af_heart": "uk-UA-PolinaNeural",
}
def _edge_voice(name: str | None) -> str:
"""Allow any valid edge-tts voice name to pass through directly."""
n = name or "default"
# If already a valid neural voice name (contains "Neural"), pass through
if "Neural" in n or n == "en":
return _EDGE_VOICES.get(n, n)
return _EDGE_VOICES.get(n, "uk-UA-PolinaNeural")
def _ffmpeg_available() -> bool:
try:
result = subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=3)
return result.returncode == 0
except Exception:
return False
def _espeak_available() -> bool:
try:
result = subprocess.run(["espeak-ng", "--version"], capture_output=True, timeout=3)
return result.returncode == 0
except Exception:
return False
class TTSRequest(BaseModel):
text: str
voice: Optional[str] = "default"
speed: Optional[float] = 1.0
model: Optional[str] = "auto"
# ── Status & Live Health ───────────────────────────────────────────────────────
@router.get("/health")
async def voice_health():
"""Live health check — actually synthesizes a short test phrase via edge-tts.
Returns edge_tts=ok/error with details; used by preflight to detect 403/blocked.
"""
import importlib.metadata
import time
result: dict = {}
# edge-tts version
try:
ver = importlib.metadata.version("edge-tts")
except Exception:
ver = "unknown"
result["edge_tts_version"] = ver
# Live synthesis test for each required Neural voice
live_voices: list[dict] = []
test_text = "Test" # Minimal — just enough to trigger actual API call
for voice_id in ("uk-UA-PolinaNeural", "uk-UA-OstapNeural"):
t0 = time.monotonic()
try:
import edge_tts
comm = edge_tts.Communicate(test_text, voice_id)
byte_count = 0
async for chunk in comm.stream():
if chunk["type"] == "audio":
byte_count += len(chunk["data"])
elapsed_ms = int((time.monotonic() - t0) * 1000)
live_voices.append({"voice": voice_id, "status": "ok",
"bytes": byte_count, "ms": elapsed_ms})
except Exception as e:
elapsed_ms = int((time.monotonic() - t0) * 1000)
live_voices.append({"voice": voice_id, "status": "error",
"error": str(e)[:150], "ms": elapsed_ms})
all_ok = all(v["status"] == "ok" for v in live_voices)
result["edge_tts"] = "ok" if all_ok else "error"
result["voices"] = live_voices
# STT check (import only — no actual transcription in health)
try:
import faster_whisper # noqa: F401
result["faster_whisper"] = "ok"
except ImportError:
result["faster_whisper"] = "unavailable"
result["ok"] = all_ok
# ── Repro pack (incident diagnosis) ──────────────────────────────────────
import os as _os
import socket as _socket
result["repro"] = {
"node_id": _os.getenv("NODE_ID", _socket.gethostname()),
"service_name": _os.getenv("MEMORY_SERVICE_NAME", "memory-service"),
"image_digest": _os.getenv("IMAGE_DIGEST", "unknown"), # set via docker label
"memory_service_url": _os.getenv("MEMORY_SERVICE_URL", "http://localhost:8000"),
"tts_max_chars": 700,
"canary_test_text": test_text,
"canary_audio_bytes": {
v["voice"]: v.get("bytes", 0) for v in live_voices
},
}
return result
@router.get("/status")
async def voice_status():
edge_ok = False
try:
import edge_tts # noqa: F401
edge_ok = True
except ImportError:
pass
espeak_ok = _espeak_available()
fw_ok = False
try:
import faster_whisper # noqa: F401
fw_ok = True
except ImportError:
pass
mlx_ok = False
try:
import mlx_audio # noqa: F401
mlx_ok = True
except ImportError:
pass
return {
"available": True,
"tts_engine": "edge-tts" if edge_ok else ("espeak-ng" if espeak_ok else "piper/say"),
"stt_engine": ("faster-whisper" if fw_ok else "") + ("mlx-audio" if mlx_ok else ""),
"edge_tts": edge_ok,
"espeak_ng": espeak_ok,
"faster_whisper": fw_ok,
"mlx_audio": mlx_ok,
"ffmpeg": _ffmpeg_available(),
"voices": list(_EDGE_VOICES.keys()),
}
# ── TTS ───────────────────────────────────────────────────────────────────────
async def _tts_edge(text: str, voice_name: str, speed: float = 1.0) -> bytes:
"""
edge-tts pure Python API — no subprocess, no ffmpeg.
Returns MP3 bytes directly (browsers play MP3 natively).
"""
import edge_tts
rate_str = f"+{int((speed - 1.0) * 50)}%" if speed != 1.0 else "+0%"
communicate = edge_tts.Communicate(text, voice_name, rate=rate_str)
buf = io.BytesIO()
async for chunk in communicate.stream():
if chunk["type"] == "audio":
buf.write(chunk["data"])
buf.seek(0)
data = buf.read()
if not data:
raise RuntimeError("edge-tts returned empty audio")
return data
async def _tts_piper(text: str) -> bytes | None:
"""Piper TTS — returns WAV bytes or None if unavailable."""
model_path = os.path.expanduser("~/.local/share/piper-voices/uk-UA-low/uk-UA-low.onnx")
if not Path(model_path).exists():
return None
try:
import piper as piper_mod
voice = piper_mod.PiperVoice.load(model_path)
buf = io.BytesIO()
voice.synthesize(text, buf)
buf.seek(0)
data = buf.read()
return data if data else None
except Exception as e:
logger.debug("Piper TTS failed: %s", e)
return None
async def _tts_macos_say(text: str, voice: str = "Milena") -> bytes | None:
"""macOS say — only works outside Docker. Returns WAV bytes or None."""
try:
tmp_id = uuid.uuid4().hex[:8]
aiff_path = f"/tmp/tts_{tmp_id}.aiff"
wav_path = f"/tmp/tts_{tmp_id}.wav"
proc = await asyncio.create_subprocess_exec(
"say", "-v", voice, "-o", aiff_path, text,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=15)
if not Path(aiff_path).exists() or Path(aiff_path).stat().st_size == 0:
return None
# Convert to WAV only if ffmpeg available
if _ffmpeg_available():
subprocess.run(["ffmpeg", "-y", "-i", aiff_path, "-ar", "22050", "-ac", "1", wav_path],
capture_output=True, timeout=10)
Path(aiff_path).unlink(missing_ok=True)
if Path(wav_path).exists() and Path(wav_path).stat().st_size > 0:
data = Path(wav_path).read_bytes()
Path(wav_path).unlink(missing_ok=True)
return data
# Return AIFF if no ffmpeg — most browsers won't play it but at least we tried
data = Path(aiff_path).read_bytes()
Path(aiff_path).unlink(missing_ok=True)
return data if data else None
except Exception as e:
logger.debug("macOS say failed: %s", e)
return None
async def _tts_espeak(text: str, voice: str = "uk", speed: float = 1.0) -> bytes | None:
"""espeak-ng offline fallback for Linux. Returns WAV bytes or None."""
if not _espeak_available():
return None
try:
tmp_id = uuid.uuid4().hex[:8]
wav_path = f"/tmp/tts_espeak_{tmp_id}.wav"
rate = max(120, min(240, int((speed or 1.0) * 170)))
proc = await asyncio.create_subprocess_exec(
"espeak-ng", "-v", voice, "-s", str(rate), "-w", wav_path, text,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
if proc.returncode != 0:
logger.debug("espeak-ng failed rc=%s stderr=%s", proc.returncode, (stderr or b"")[:200])
return None
p = Path(wav_path)
if not p.exists() or p.stat().st_size == 0:
return None
data = p.read_bytes()
p.unlink(missing_ok=True)
return data if data else None
except Exception as e:
logger.debug("espeak-ng TTS failed: %s", e)
return None
@router.post("/tts")
async def text_to_speech(request: TTSRequest):
"""
TTS pipeline:
1. edge-tts (primary — pure Python, returns MP3, works anywhere)
2. piper (if model file present)
3. espeak-ng (offline Linux fallback)
4. macOS say (macOS-only fallback)
"""
import time as _time
text = (request.text or "").strip()[:700]
if not text:
raise HTTPException(400, "Empty text")
edge_voice = _edge_voice(request.voice)
errors: list[str] = []
# ── 1. edge-tts (MP3, no ffmpeg needed) ──────────────────────────────
_t0 = _time.monotonic()
try:
data = await asyncio.wait_for(
_tts_edge(text, edge_voice, speed=request.speed or 1.0),
timeout=20.0,
)
_compute_ms = int((_time.monotonic() - _t0) * 1000)
logger.info("TTS edge-tts OK: voice=%s len=%d ms=%d", edge_voice, len(data), _compute_ms)
_prom_tts_observe("edge-tts", edge_voice, _compute_ms, len(data))
return StreamingResponse(
io.BytesIO(data),
media_type="audio/mpeg",
headers={"Content-Disposition": "inline; filename=speech.mp3",
"X-TTS-Engine": "edge-tts",
"X-TTS-Voice": edge_voice,
"X-TTS-Compute-MS": str(_compute_ms),
"Cache-Control": "no-store"},
)
except Exception as e:
_prom_tts_error("edge-tts", type(e).__name__)
errors.append(f"edge-tts: {e}")
logger.warning("edge-tts failed: %s", e)
# ── 2. piper ──────────────────────────────────────────────────────────
_t0 = _time.monotonic()
try:
data = await asyncio.wait_for(_tts_piper(text), timeout=15.0)
if data:
_compute_ms = int((_time.monotonic() - _t0) * 1000)
logger.info("TTS piper OK len=%d ms=%d", len(data), _compute_ms)
_prom_tts_observe("piper", "uk-UA", _compute_ms, len(data))
return StreamingResponse(
io.BytesIO(data),
media_type="audio/wav",
headers={"Content-Disposition": "inline; filename=speech.wav",
"X-TTS-Engine": "piper",
"X-TTS-Compute-MS": str(_compute_ms),
"Cache-Control": "no-store"},
)
except Exception as e:
_prom_tts_error("piper", type(e).__name__)
errors.append(f"piper: {e}")
logger.debug("piper failed: %s", e)
# ── 3. espeak-ng (offline Linux) ─────────────────────────────────────
espeak_voice = "en-us" if str(request.voice or "").startswith("en") else "uk"
_t0 = _time.monotonic()
try:
data = await asyncio.wait_for(_tts_espeak(text, espeak_voice, request.speed or 1.0), timeout=12.0)
if data:
_compute_ms = int((_time.monotonic() - _t0) * 1000)
logger.info("TTS espeak-ng OK voice=%s len=%d ms=%d", espeak_voice, len(data), _compute_ms)
_prom_tts_observe("espeak-ng", espeak_voice, _compute_ms, len(data))
return StreamingResponse(
io.BytesIO(data),
media_type="audio/wav",
headers={"Content-Disposition": "inline; filename=speech.wav",
"X-TTS-Engine": "espeak-ng",
"X-TTS-Voice": espeak_voice,
"X-TTS-Compute-MS": str(_compute_ms),
"Cache-Control": "no-store"},
)
except Exception as e:
_prom_tts_error("espeak-ng", type(e).__name__)
errors.append(f"espeak-ng: {e}")
logger.debug("espeak-ng failed: %s", e)
# ── 4. macOS say ──────────────────────────────────────────────────────
say_voice = "Milena" if request.voice in (None, "default", "Polina", "Milena") else "Yuri"
_t0 = _time.monotonic()
try:
data = await asyncio.wait_for(_tts_macos_say(text, say_voice), timeout=20.0)
if data:
_compute_ms = int((_time.monotonic() - _t0) * 1000)
mime = "audio/wav" if data[:4] == b"RIFF" else "audio/aiff"
logger.info("TTS macOS say OK voice=%s len=%d ms=%d", say_voice, len(data), _compute_ms)
_prom_tts_observe("macos-say", say_voice, _compute_ms, len(data))
return StreamingResponse(
io.BytesIO(data),
media_type=mime,
headers={"Content-Disposition": f"inline; filename=speech.{'wav' if mime=='audio/wav' else 'aiff'}",
"X-TTS-Engine": "macos-say",
"X-TTS-Compute-MS": str(_compute_ms),
"Cache-Control": "no-store"},
)
except Exception as e:
_prom_tts_error("macos-say", type(e).__name__)
errors.append(f"say: {e}")
logger.debug("macOS say failed: %s", e)
logger.error("All TTS engines failed: %s", errors)
raise HTTPException(503, f"All TTS engines failed: {'; '.join(errors)}")
# ── STT ───────────────────────────────────────────────────────────────────────
async def _convert_audio_to_wav(input_path: str, output_path: str) -> bool:
"""Convert audio to WAV using ffmpeg if available."""
if not _ffmpeg_available():
return False
try:
result = subprocess.run(
["ffmpeg", "-y", "-i", input_path, "-ar", "16000", "-ac", "1", output_path],
capture_output=True, timeout=30,
)
return result.returncode == 0 and Path(output_path).exists()
except Exception:
return False
def _stt_faster_whisper_sync(wav_path: str, language: str | None) -> str:
"""faster-whisper STT — sync, runs in executor — works in Docker/Linux."""
from faster_whisper import WhisperModel
# Use 'small' for better Ukrainian accuracy (still fast on CPU)
model_size = os.getenv("WHISPER_MODEL", "small")
cache_key = f"faster_whisper_{model_size}"
if cache_key not in MODELS_CACHE:
logger.info("Loading faster-whisper model=%s (first call)...", model_size)
MODELS_CACHE[cache_key] = WhisperModel(
model_size, device="cpu", compute_type="int8",
)
model = MODELS_CACHE[cache_key]
segments, info = model.transcribe(wav_path, language=language or "uk", beam_size=5)
text = " ".join(seg.text for seg in segments).strip()
logger.info("faster-whisper OK: lang=%s text_len=%d", info.language, len(text))
return text
def _stt_mlx_audio_sync(wav_path: str, language: str | None) -> str:
"""mlx-audio STT — sync, runs in executor — macOS Apple Silicon only."""
from mlx_audio.stt.utils import load_model
if "mlx_whisper" not in MODELS_CACHE:
logger.info("Loading mlx-audio whisper model (first call)...")
MODELS_CACHE["mlx_whisper"] = load_model(
"mlx-community/whisper-large-v3-turbo-asr-fp16"
)
model = MODELS_CACHE["mlx_whisper"]
result = model.generate(wav_path, language=language)
return result.text if hasattr(result, "text") else str(result)
async def _stt_whisper_cli(wav_path: str, language: str | None) -> str:
"""whisper CLI fallback."""
proc = await asyncio.create_subprocess_exec(
"whisper", wav_path,
"--language", language or "uk",
"--model", "base",
"--output_format", "txt",
"--output_dir", "/tmp",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=90)
txt_path = Path(wav_path).with_suffix(".txt")
if txt_path.exists():
return txt_path.read_text().strip()
raise RuntimeError("whisper CLI produced no output")
@router.post("/stt")
async def speech_to_text(
audio: UploadFile = File(...),
model: str = Query("auto", description="STT model: auto|faster-whisper|mlx-audio|whisper-cli"),
language: Optional[str] = Query(None, description="Language code (auto-detect if None)"),
):
"""
STT pipeline:
1. Convert audio to WAV via ffmpeg (if available; skip if already WAV)
2. faster-whisper (primary — Docker/Linux)
3. mlx-audio (macOS Apple Silicon)
4. whisper CLI (last resort)
"""
tmp_path: str | None = None
wav_path: str | None = None
try:
content = await audio.read()
if not content:
raise HTTPException(400, "Empty audio file")
# Detect MIME type
fname = audio.filename or "audio.webm"
suffix = Path(fname).suffix or ".webm"
if audio.content_type and "wav" in audio.content_type:
suffix = ".wav"
elif audio.content_type and "ogg" in audio.content_type:
suffix = ".ogg"
tmp_id = uuid.uuid4().hex[:8]
tmp_path = f"/tmp/stt_in_{tmp_id}{suffix}"
wav_path = f"/tmp/stt_wav_{tmp_id}.wav"
with open(tmp_path, "wb") as f:
f.write(content)
# Convert to WAV (required by whisper models)
converted = False
if suffix == ".wav":
import shutil
shutil.copy(tmp_path, wav_path)
converted = True
else:
converted = await _convert_audio_to_wav(tmp_path, wav_path)
if not converted:
# No ffmpeg — try to use input directly (faster-whisper accepts many formats)
import shutil
shutil.copy(tmp_path, wav_path)
converted = True
if not Path(wav_path).exists():
raise HTTPException(500, "Audio conversion failed — ffmpeg missing and no WAV input")
errors: list[str] = []
loop = asyncio.get_event_loop()
# ── 1. faster-whisper ─────────────────────────────────────────────
if model in ("auto", "faster-whisper"):
_t0_stt = asyncio.get_event_loop().time()
try:
_wpath = wav_path # capture for lambda
_lang = language
text = await asyncio.wait_for(
loop.run_in_executor(None, _stt_faster_whisper_sync, _wpath, _lang),
timeout=60.0,
)
_stt_ms = int((asyncio.get_event_loop().time() - _t0_stt) * 1000)
_prom_stt_observe("faster-whisper", _stt_ms)
return {"text": text, "model": "faster-whisper", "language": language,
"compute_ms": _stt_ms}
except Exception as e:
errors.append(f"faster-whisper: {e}")
logger.warning("faster-whisper failed: %s", e)
# ── 2. mlx-audio (macOS) ─────────────────────────────────────────
if model in ("auto", "mlx-audio"):
try:
_wpath = wav_path
_lang = language
text = await asyncio.wait_for(
loop.run_in_executor(None, _stt_mlx_audio_sync, _wpath, _lang),
timeout=60.0,
)
return {"text": text, "model": "mlx-audio", "language": language}
except Exception as e:
errors.append(f"mlx-audio: {e}")
logger.warning("mlx-audio failed: %s", e)
# ── 3. whisper CLI ────────────────────────────────────────────────
if model in ("auto", "whisper-cli"):
try:
text = await asyncio.wait_for(
_stt_whisper_cli(wav_path, language), timeout=90.0
)
return {"text": text, "model": "whisper-cli", "language": language}
except Exception as e:
errors.append(f"whisper-cli: {e}")
logger.warning("whisper-cli failed: %s", e)
raise HTTPException(503, f"All STT engines failed: {'; '.join(str(e)[:80] for e in errors)}")
except HTTPException:
raise
except Exception as e:
logger.error("STT error: %s", e)
raise HTTPException(500, str(e)[:200])
finally:
for p in [tmp_path, wav_path]:
if p:
Path(p).unlink(missing_ok=True)
# ── Voices list ───────────────────────────────────────────────────────────────
@router.get("/voices")
async def list_voices():
edge_voices = []
try:
import edge_tts # noqa: F401
edge_voices = [
{"id": "default", "name": "Polina Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
{"id": "Polina", "name": "Polina Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
{"id": "Ostap", "name": "Ostap Neural (uk-UA)", "lang": "uk-UA", "engine": "edge-tts"},
]
except ImportError:
pass
piper_voices = []
if Path(os.path.expanduser("~/.local/share/piper-voices/uk-UA-low/uk-UA-low.onnx")).exists():
piper_voices = [{"id": "uk-UA-low", "name": "Ukrainian Low (uk-UA)", "lang": "uk-UA", "engine": "piper"}]
macos_voices = []
if os.path.exists("/usr/bin/say") or os.path.exists("/usr/local/bin/say"):
macos_voices = [
{"id": "Milena", "name": "Milena (uk-UA, macOS)", "lang": "uk-UA", "engine": "say"},
{"id": "Yuri", "name": "Yuri (uk-UA, macOS)", "lang": "uk-UA", "engine": "say"},
]
espeak_voices = []
if _espeak_available():
espeak_voices = [
{"id": "uk", "name": "Ukrainian (espeak-ng)", "lang": "uk-UA", "engine": "espeak-ng"},
{"id": "en-us", "name": "English US (espeak-ng)", "lang": "en-US", "engine": "espeak-ng"},
]
return {
"edge": edge_voices,
"piper": piper_voices,
"macos": macos_voices,
"espeak": espeak_voices,
}