mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
Merge pull request #263 from jamiepine/fix/atomic-save-error-handling
fix: atomic audio save with error handling and filesystem health endpoint
This commit is contained in:
+93
-1
@@ -233,6 +233,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
|
# VOICE PROFILE ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -762,7 +831,30 @@ async def generate_speech(
|
|||||||
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
||||||
|
|
||||||
from .utils.audio import save_audio
|
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
|
# Create history entry
|
||||||
generation = await history.create_generation(
|
generation = await history.create_generation(
|
||||||
|
|||||||
@@ -131,6 +131,22 @@ class HealthResponse(BaseModel):
|
|||||||
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
|
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):
|
class ModelStatus(BaseModel):
|
||||||
"""Response model for model status."""
|
"""Response model for model status."""
|
||||||
model_name: str
|
model_name: str
|
||||||
|
|||||||
+32
-3
@@ -70,14 +70,43 @@ def save_audio(
|
|||||||
sample_rate: int = 24000,
|
sample_rate: int = 24000,
|
||||||
) -> None:
|
) -> 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:
|
Args:
|
||||||
audio: Audio array
|
audio: Audio array
|
||||||
path: Output path
|
path: Output path
|
||||||
sample_rate: Sample rate
|
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(
|
def trim_tts_output(
|
||||||
|
|||||||
Reference in New Issue
Block a user