Enhance audio processing capabilities: add FFmpeg for audio transcoding, update database schema, and improve UI for audio file uploads

This commit is contained in:
2026-09-26 21:51:50 +02:00
parent eb83b16b54
commit 0f7d5e950c
8 changed files with 135 additions and 20 deletions
+78 -8
View File
@@ -1,6 +1,9 @@
import asyncio
import os
import re
import sqlite3
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -44,10 +47,26 @@ def initialize_db() -> None:
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
USING fts5(note_id UNINDEXED, transcript)"""
)
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
if "original_audio_path" not in columns:
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
initialize_db()
def transcode_audio(source_path: Path, output_path: Path) -> None:
subprocess.run(
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source_path), "-vn", "-ac", "1", "-ar", "22050",
"-c:a", "pcm_s16le", str(output_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=300,
)
def update_transcript(note_id: str, transcript: str, status: str) -> None:
with connect_db() as connection:
@@ -63,6 +82,23 @@ def update_transcript(note_id: str, transcript: str, status: str) -> None:
)
def remove_transcribed_audio(note_id: str) -> None:
with connect_db() as connection:
note = connection.execute(
"SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
return
for audio_path in (note["audio_path"], note["original_audio_path"]):
if audio_path:
Path(audio_path).unlink(missing_ok=True)
with connect_db() as connection:
connection.execute(
"UPDATE notes SET audio_path = '', original_audio_path = NULL WHERE id = ?",
(note_id,),
)
async def transcribe_note(note_id: str, audio_path: Path) -> None:
if not WHISPER_SERVER_URL:
return
@@ -76,9 +112,12 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
)
response.raise_for_status()
transcript = response.json().get("text", "").strip()
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
except Exception:
update_transcript(note_id, "", "transcription_failed")
return
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
if transcript:
remove_transcribed_audio(note_id)
@app.get("/api/health")
@@ -102,15 +141,39 @@ async def create_note(
if existing:
return {"id": note_id, "status": existing["status"]}
suffix = Path(audio.filename or "recording.webm").suffix.lower()
if not suffix or len(suffix) > 10:
if not re.fullmatch(r"\.[a-z0-9]{1,9}", suffix):
suffix = ".audio"
audio_path = AUDIO_DIR / f"{note_id}{suffix}"
original_audio_path = AUDIO_DIR / f"{note_id}-original{suffix}"
audio_path = AUDIO_DIR / f"{note_id}.wav"
temporary_output = None
try:
with audio_path.open("wb") as destination:
with original_audio_path.open("wb") as destination:
while chunk := await audio.read(1024 * 1024):
destination.write(chunk)
with tempfile.NamedTemporaryFile(suffix=".wav", dir=AUDIO_DIR, delete=False) as temporary_file:
temporary_output = Path(temporary_file.name)
await asyncio.to_thread(transcode_audio, original_audio_path, temporary_output)
temporary_output.replace(audio_path)
except FileNotFoundError as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Audio conversion is unavailable") from error
except subprocess.CalledProcessError as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Unsupported or invalid audio file") from error
except subprocess.TimeoutExpired as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=422, detail="Audio conversion timed out") from error
except Exception as error:
original_audio_path.unlink(missing_ok=True)
audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Could not store audio") from error
finally:
await audio.close()
@@ -126,8 +189,10 @@ async def create_note(
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
with connect_db() as connection:
connection.execute(
"INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)",
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), status),
"""INSERT OR IGNORE INTO notes
(id, created_at, filename, audio_path, original_audio_path, status)
VALUES (?, ?, ?, ?, ?, ?)""",
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), str(original_audio_path), status),
)
if WHISPER_SERVER_URL:
background_tasks.add_task(transcribe_note, note_id, audio_path)
@@ -173,13 +238,16 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
def delete_note(note_id: str) -> dict[str, str]:
with connect_db() as connection:
note = connection.execute(
"SELECT audio_path FROM notes WHERE id = ?", (note_id,)
"SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
Path(note["audio_path"]).unlink(missing_ok=True)
if note["audio_path"]:
Path(note["audio_path"]).unlink(missing_ok=True)
if note["original_audio_path"]:
Path(note["original_audio_path"]).unlink(missing_ok=True)
return {"id": note_id, "status": "deleted"}
@@ -191,6 +259,8 @@ def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
if not exists:
raise HTTPException(status_code=404, detail="Note not found")
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
if transcript:
remove_transcribed_audio(note_id)
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}