Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,75 @@ async def health():
)


@app.get("/health/filesystem", response_model=models.FilesystemHealthResponse)
async def filesystem_health():
"""Check filesystem health: directory existence, write permissions, and disk space."""
import shutil

dirs_to_check = {
"generations": config.get_generations_dir(),
"profiles": config.get_profiles_dir(),
"data": config.get_data_dir(),
}

checks: list[models.DirectoryCheck] = []
all_ok = True

for _label, dir_path in dirs_to_check.items():
exists = dir_path.exists()
writable = False
error = None
if exists:
# Probe writability with a temp file
probe = dir_path / ".voicebox_probe"
try:
probe.write_text("ok")
probe.unlink()
writable = True
except PermissionError:
error = "Permission denied"
except OSError as e:
error = str(e)
finally:
try:
probe.unlink(missing_ok=True)
except Exception:
pass
else:
error = "Directory does not exist"

if not exists or not writable:
all_ok = False

checks.append(
models.DirectoryCheck(
path=str(dir_path),
exists=exists,
writable=writable,
error=error,
)
)

# Disk space for the data directory
disk_free_mb = None
disk_total_mb = None
try:
usage = shutil.disk_usage(str(config.get_data_dir()))
disk_free_mb = round(usage.free / (1024 * 1024), 1)
disk_total_mb = round(usage.total / (1024 * 1024), 1)
if disk_free_mb < 500:
all_ok = False
except OSError:
all_ok = False

return models.FilesystemHealthResponse(
healthy=all_ok,
disk_free_mb=disk_free_mb,
disk_total_mb=disk_total_mb,
directories=checks,
)


# ============================================
# VOICE PROFILE ENDPOINTS
# ============================================
Expand Down Expand Up @@ -728,7 +797,30 @@ async def download_chatterbox_background():
audio_path = config.get_generations_dir() / f"{generation_id}.wav"

from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
import errno

try:
save_audio(audio, str(audio_path), sample_rate)
except BrokenPipeError:
raise HTTPException(
status_code=500,
detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
)
except OSError as save_err:
err_no = getattr(save_err, "errno", None) or (
getattr(save_err.__cause__, "errno", None)
if save_err.__cause__
else None
)
if err_no == errno.ENOENT:
msg = f"Audio save failed: directory not found — {audio_path.parent}"
elif err_no == errno.EACCES:
msg = f"Audio save failed: permission denied — {audio_path.parent}"
elif err_no == errno.ENOSPC:
msg = "Audio save failed: no disk space remaining"
else:
msg = f"Audio save failed: {save_err}"
raise HTTPException(status_code=500, detail=msg)

# Create history entry
generation = await history.create_generation(
Expand Down
16 changes: 16 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ class HealthResponse(BaseModel):
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)


class DirectoryCheck(BaseModel):
"""Health status for a single directory."""
path: str
exists: bool
writable: bool
error: Optional[str] = None


class FilesystemHealthResponse(BaseModel):
"""Response model for filesystem health check."""
healthy: bool
disk_free_mb: Optional[float] = None
disk_total_mb: Optional[float] = None
directories: List[DirectoryCheck]


class ModelStatus(BaseModel):
"""Response model for model status."""
model_name: str
Expand Down
35 changes: 32 additions & 3 deletions backend/utils/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,43 @@ def save_audio(
sample_rate: int = 24000,
) -> None:
"""
Save audio file.

Save audio file with atomic write and error handling.

Writes to a temporary file first, then atomically renames to the
target path. This prevents corrupted/partial WAV files if the
process is interrupted mid-write.

Args:
audio: Audio array
path: Output path
sample_rate: Sample rate

Raises:
OSError: If file cannot be written
"""
sf.write(path, audio, sample_rate)
from pathlib import Path
import os

temp_path = f"{path}.tmp"
try:
# Ensure parent directory exists
Path(path).parent.mkdir(parents=True, exist_ok=True)

# Write to temporary file first
sf.write(temp_path, audio, sample_rate)

# Atomic rename to final path
os.replace(temp_path, path)

except Exception as e:
# Clean up temp file on failure
try:
if Path(temp_path).exists():
Path(temp_path).unlink()
except Exception:
pass # Best effort cleanup

raise OSError(f"Failed to save audio to {path}: {e}") from e


def trim_tts_output(
Expand Down