Spaces:
Sleeping
Sleeping
Sagar Patel commited on
Commit ·
28032c3
1
Parent(s): 2982073
Fix voice transcription handling
Browse files- backend/modal_api.py +23 -4
- tests/test_modal_api.py +15 -0
- tests/test_speech.py +7 -0
- tests/test_ui_audio_flow.py +2 -2
- voiceledger/speech/transcribe.py +23 -4
- voiceledger/ui/gradio_app.py +2 -2
backend/modal_api.py
CHANGED
|
@@ -19,15 +19,15 @@ REQUEST_TIMEOUT_SECONDS = 120
|
|
| 19 |
|
| 20 |
|
| 21 |
def transcribe_audio(
|
| 22 |
-
audio_path:
|
| 23 |
-
fallback: Callable[[
|
| 24 |
) -> str:
|
| 25 |
"""Transcribe audio through Modal, falling back locally if unavailable."""
|
|
|
|
| 26 |
endpoint_url = os.getenv(MODAL_TRANSCRIBE_URL_ENV)
|
| 27 |
-
if not endpoint_url or
|
| 28 |
return fallback(audio_path)
|
| 29 |
|
| 30 |
-
path = Path(audio_path)
|
| 31 |
if not path.exists():
|
| 32 |
return fallback(audio_path)
|
| 33 |
|
|
@@ -79,3 +79,22 @@ def _auth_headers() -> dict[str, str]:
|
|
| 79 |
if not token:
|
| 80 |
return {}
|
| 81 |
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def transcribe_audio(
|
| 22 |
+
audio_path: Any,
|
| 23 |
+
fallback: Callable[[Any], str],
|
| 24 |
) -> str:
|
| 25 |
"""Transcribe audio through Modal, falling back locally if unavailable."""
|
| 26 |
+
path = _coerce_audio_path(audio_path)
|
| 27 |
endpoint_url = os.getenv(MODAL_TRANSCRIBE_URL_ENV)
|
| 28 |
+
if not endpoint_url or path is None:
|
| 29 |
return fallback(audio_path)
|
| 30 |
|
|
|
|
| 31 |
if not path.exists():
|
| 32 |
return fallback(audio_path)
|
| 33 |
|
|
|
|
| 79 |
if not token:
|
| 80 |
return {}
|
| 81 |
return {"Authorization": f"Bearer {token}"}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _coerce_audio_path(audio_value: Any) -> Path | None:
|
| 85 |
+
"""Extract a local audio filepath from Gradio audio values."""
|
| 86 |
+
if audio_value is None:
|
| 87 |
+
return None
|
| 88 |
+
if isinstance(audio_value, (str, Path)):
|
| 89 |
+
return Path(audio_value)
|
| 90 |
+
if isinstance(audio_value, dict):
|
| 91 |
+
for key in ("path", "name", "file", "filepath"):
|
| 92 |
+
value = audio_value.get(key)
|
| 93 |
+
if value:
|
| 94 |
+
return Path(value)
|
| 95 |
+
if isinstance(audio_value, (list, tuple)):
|
| 96 |
+
for value in audio_value:
|
| 97 |
+
path = _coerce_audio_path(value)
|
| 98 |
+
if path is not None:
|
| 99 |
+
return path
|
| 100 |
+
return None
|
tests/test_modal_api.py
CHANGED
|
@@ -82,6 +82,21 @@ def test_transcribe_audio_uses_modal_response(tmp_path: Path, monkeypatch) -> No
|
|
| 82 |
assert transcript == "Sold 12 mangoes"
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def test_transcribe_audio_falls_back_when_url_missing(tmp_path: Path, monkeypatch) -> None:
|
| 86 |
audio_path = tmp_path / "audio.wav"
|
| 87 |
audio_path.write_bytes(b"audio")
|
|
|
|
| 82 |
assert transcript == "Sold 12 mangoes"
|
| 83 |
|
| 84 |
|
| 85 |
+
def test_transcribe_audio_accepts_gradio_dict_payload(tmp_path: Path, monkeypatch) -> None:
|
| 86 |
+
audio_path = tmp_path / "audio.wav"
|
| 87 |
+
audio_path.write_bytes(b"audio")
|
| 88 |
+
monkeypatch.setenv(modal_api.MODAL_TRANSCRIBE_URL_ENV, "https://modal.example/transcribe")
|
| 89 |
+
|
| 90 |
+
def fake_post(*args, **kwargs) -> FakeResponse:
|
| 91 |
+
return FakeResponse({"transcript": "Paid 500 for supplies"})
|
| 92 |
+
|
| 93 |
+
monkeypatch.setattr(modal_api.requests, "post", fake_post)
|
| 94 |
+
|
| 95 |
+
transcript = modal_api.transcribe_audio({"path": str(audio_path)}, fallback=lambda _: "fallback")
|
| 96 |
+
|
| 97 |
+
assert transcript == "Paid 500 for supplies"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def test_transcribe_audio_falls_back_when_url_missing(tmp_path: Path, monkeypatch) -> None:
|
| 101 |
audio_path = tmp_path / "audio.wav"
|
| 102 |
audio_path.write_bytes(b"audio")
|
tests/test_speech.py
CHANGED
|
@@ -15,3 +15,10 @@ def test_transcribe_audio_requires_existing_file(tmp_path: Path) -> None:
|
|
| 15 |
|
| 16 |
with pytest.raises(TranscriptionError, match="does not exist"):
|
| 17 |
transcribe_audio(missing_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
with pytest.raises(TranscriptionError, match="does not exist"):
|
| 17 |
transcribe_audio(missing_file)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_transcribe_audio_accepts_gradio_dict_for_missing_file(tmp_path: Path) -> None:
|
| 21 |
+
missing_file = tmp_path / "missing.wav"
|
| 22 |
+
|
| 23 |
+
with pytest.raises(TranscriptionError, match="does not exist"):
|
| 24 |
+
transcribe_audio({"path": str(missing_file)})
|
tests/test_ui_audio_flow.py
CHANGED
|
@@ -6,8 +6,8 @@ from voiceledger.ui import gradio_app
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_transcribe_and_parse_audio_handles_transcription_error(monkeypatch) -> None:
|
| 9 |
-
def fail_transcription(_:
|
| 10 |
-
raise
|
| 11 |
|
| 12 |
monkeypatch.setattr(gradio_app.modal_api, "transcribe_audio", lambda audio, fallback: fail_transcription(audio))
|
| 13 |
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_transcribe_and_parse_audio_handles_transcription_error(monkeypatch) -> None:
|
| 9 |
+
def fail_transcription(_: object) -> str:
|
| 10 |
+
raise RuntimeError("test failure")
|
| 11 |
|
| 12 |
monkeypatch.setattr(gradio_app.modal_api, "transcribe_audio", lambda audio, fallback: fail_transcription(audio))
|
| 13 |
|
voiceledger/speech/transcribe.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
| 5 |
from functools import lru_cache
|
| 6 |
from pathlib import Path
|
| 7 |
from collections.abc import Iterable
|
| 8 |
-
from typing import Protocol
|
| 9 |
|
| 10 |
|
| 11 |
MODEL_SIZE = "small"
|
|
@@ -28,17 +28,17 @@ class TranscriptionError(RuntimeError):
|
|
| 28 |
"""Raised when audio transcription fails."""
|
| 29 |
|
| 30 |
|
| 31 |
-
def transcribe_audio(audio_path:
|
| 32 |
"""Transcribe an audio file with the faster-whisper small model.
|
| 33 |
|
| 34 |
The model is loaded lazily and cached after the first call. This keeps the
|
| 35 |
Gradio app responsive at startup while still using the requested small
|
| 36 |
open-source speech model for actual transcription.
|
| 37 |
"""
|
| 38 |
-
|
|
|
|
| 39 |
raise TranscriptionError("No audio file was provided.")
|
| 40 |
|
| 41 |
-
path = Path(audio_path)
|
| 42 |
if not path.exists():
|
| 43 |
raise TranscriptionError(f"Audio file does not exist: {path}")
|
| 44 |
|
|
@@ -62,6 +62,25 @@ def transcribe_audio(audio_path: str | Path | None) -> str:
|
|
| 62 |
return transcript.strip()
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
@lru_cache(maxsize=1)
|
| 66 |
def _get_model() -> WhisperModelProtocol:
|
| 67 |
"""Load and cache the faster-whisper small model."""
|
|
|
|
| 5 |
from functools import lru_cache
|
| 6 |
from pathlib import Path
|
| 7 |
from collections.abc import Iterable
|
| 8 |
+
from typing import Any, Protocol
|
| 9 |
|
| 10 |
|
| 11 |
MODEL_SIZE = "small"
|
|
|
|
| 28 |
"""Raised when audio transcription fails."""
|
| 29 |
|
| 30 |
|
| 31 |
+
def transcribe_audio(audio_path: Any) -> str:
|
| 32 |
"""Transcribe an audio file with the faster-whisper small model.
|
| 33 |
|
| 34 |
The model is loaded lazily and cached after the first call. This keeps the
|
| 35 |
Gradio app responsive at startup while still using the requested small
|
| 36 |
open-source speech model for actual transcription.
|
| 37 |
"""
|
| 38 |
+
path = _coerce_audio_path(audio_path)
|
| 39 |
+
if path is None:
|
| 40 |
raise TranscriptionError("No audio file was provided.")
|
| 41 |
|
|
|
|
| 42 |
if not path.exists():
|
| 43 |
raise TranscriptionError(f"Audio file does not exist: {path}")
|
| 44 |
|
|
|
|
| 62 |
return transcript.strip()
|
| 63 |
|
| 64 |
|
| 65 |
+
def _coerce_audio_path(audio_value: Any) -> Path | None:
|
| 66 |
+
"""Extract a local audio filepath from Gradio audio values."""
|
| 67 |
+
if audio_value is None:
|
| 68 |
+
return None
|
| 69 |
+
if isinstance(audio_value, (str, Path)):
|
| 70 |
+
return Path(audio_value)
|
| 71 |
+
if isinstance(audio_value, dict):
|
| 72 |
+
for key in ("path", "name", "file", "filepath"):
|
| 73 |
+
value = audio_value.get(key)
|
| 74 |
+
if value:
|
| 75 |
+
return Path(value)
|
| 76 |
+
if isinstance(audio_value, (list, tuple)):
|
| 77 |
+
for value in audio_value:
|
| 78 |
+
path = _coerce_audio_path(value)
|
| 79 |
+
if path is not None:
|
| 80 |
+
return path
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
@lru_cache(maxsize=1)
|
| 85 |
def _get_model() -> WhisperModelProtocol:
|
| 86 |
"""Load and cache the faster-whisper small model."""
|
voiceledger/ui/gradio_app.py
CHANGED
|
@@ -299,11 +299,11 @@ def _parse_note(note: str) -> tuple[dict[str, Any], dict[str, Any], str]:
|
|
| 299 |
return payload, payload, status
|
| 300 |
|
| 301 |
|
| 302 |
-
def _transcribe_and_parse_audio(audio_path:
|
| 303 |
"""Transcribe recorded audio, parse the transcript, and return UI updates."""
|
| 304 |
try:
|
| 305 |
transcript = modal_api.transcribe_audio(audio_path, fallback=local_transcribe_audio)
|
| 306 |
-
except
|
| 307 |
empty_payload = _empty_transaction_payload()
|
| 308 |
return "", empty_payload, None, f"Transcription failed: {exc}"
|
| 309 |
|
|
|
|
| 299 |
return payload, payload, status
|
| 300 |
|
| 301 |
|
| 302 |
+
def _transcribe_and_parse_audio(audio_path: Any) -> tuple[str, dict[str, Any], dict[str, Any] | None, str]:
|
| 303 |
"""Transcribe recorded audio, parse the transcript, and return UI updates."""
|
| 304 |
try:
|
| 305 |
transcript = modal_api.transcribe_audio(audio_path, fallback=local_transcribe_audio)
|
| 306 |
+
except Exception as exc:
|
| 307 |
empty_payload = _empty_transaction_payload()
|
| 308 |
return "", empty_payload, None, f"Transcription failed: {exc}"
|
| 309 |
|