Enhance audio processing capabilities: add FFmpeg for audio transcoding, update database schema, and improve UI for audio file uploads
This commit is contained in:
+78
-8
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user