add sentences borders enable in pc player
This commit is contained in:
@@ -52,6 +52,7 @@ GET /api/v1/videos
|
||||
GET /api/v1/videos/{sha256}
|
||||
GET /api/v1/videos/{sha256}/content
|
||||
GET /api/v1/videos/{sha256}/sentence-boundaries
|
||||
POST /api/v1/audio/sentence-boundaries
|
||||
POST /api/v1/sentences/explain
|
||||
POST /api/v1/videos/{sha256}/sentences/{index}/assessments
|
||||
|
||||
@@ -67,6 +68,11 @@ DELETE /api/v1/admin/videos/{sha256}
|
||||
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
||||
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
||||
|
||||
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
|
||||
authenticated client, generates boundaries in memory/temp storage only, and
|
||||
returns a sentence document. It does not register or archive the uploaded
|
||||
audio.
|
||||
|
||||
## AI Sentence Explanation
|
||||
|
||||
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.
|
||||
|
||||
@@ -2,6 +2,7 @@ import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import mimetypes
|
||||
import tempfile
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
@@ -57,7 +58,7 @@ from .models import (
|
||||
VideoUploadResponse,
|
||||
)
|
||||
from .oss import VolcanoOSSUploader
|
||||
from .processing import VideoProcessor, split_sentence_text
|
||||
from .processing import VideoProcessor, generate_audio_boundary_document, split_sentence_text
|
||||
from .repository import VideoRepository
|
||||
from .store import BoundaryStore
|
||||
from .transcription import MossTranscriber, Transcriber
|
||||
@@ -72,6 +73,7 @@ SHA256_PATH = ApiPath(
|
||||
)
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
|
||||
MAX_AUDIO_BOUNDARY_BYTES = 512 * 1024 * 1024
|
||||
|
||||
|
||||
class UploadTooLargeError(ValueError):
|
||||
@@ -329,6 +331,47 @@ def create_app(
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
return SentenceExplanationResponse(explanation=explanation)
|
||||
|
||||
@application.post(
|
||||
"/api/v1/audio/sentence-boundaries",
|
||||
response_model=SentenceBoundaryDocument,
|
||||
)
|
||||
async def generate_sentence_boundaries(
|
||||
file: UploadFile = File(...),
|
||||
language: Optional[str] = Form(default=None, max_length=32),
|
||||
user: AuthenticatedUser = Depends(current_user),
|
||||
) -> SentenceBoundaryDocument:
|
||||
del user
|
||||
max_bytes = min(MAX_AUDIO_BOUNDARY_BYTES, service_settings.max_upload_bytes)
|
||||
with tempfile.TemporaryDirectory(prefix="audio-boundaries-") as temp_dir:
|
||||
audio_path = Path(temp_dir) / "audio.wav"
|
||||
try:
|
||||
audio_hash, _size = await run_in_threadpool(
|
||||
_persist_upload,
|
||||
file.file,
|
||||
audio_path,
|
||||
max_bytes,
|
||||
)
|
||||
document = await run_in_threadpool(
|
||||
generate_audio_boundary_document,
|
||||
video_hash=audio_hash,
|
||||
audio_path=audio_path,
|
||||
transcriber=moss,
|
||||
language=language,
|
||||
)
|
||||
except UploadTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Ad-hoc audio sentence boundary generation failed.")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Sentence boundary generation failed. Please try again later.",
|
||||
) from exc
|
||||
finally:
|
||||
await file.close()
|
||||
return document
|
||||
|
||||
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
|
||||
def unenroll_course(
|
||||
video_hash: str = SHA256_PATH,
|
||||
|
||||
@@ -14,7 +14,8 @@ from .audio_metrics import (
|
||||
refine_sentence_start_ms,
|
||||
)
|
||||
from .config import Settings
|
||||
from .generate_boundaries import ALGORITHM_VERSION, make_entry
|
||||
from .generate_boundaries import ALGORITHM_VERSION, make_entry, media_duration_ms
|
||||
from sentence_analysis import detect_sentence_boundaries
|
||||
from .models import SentenceBoundary, SentenceBoundaryDocument
|
||||
from .oss import VolcanoOSSUploader
|
||||
from .repository import VideoRepository
|
||||
@@ -27,6 +28,7 @@ from .transcription import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
|
||||
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
|
||||
|
||||
|
||||
class VideoProcessor:
|
||||
@@ -417,6 +419,67 @@ def document_from_transcript(
|
||||
)
|
||||
|
||||
|
||||
def generate_audio_boundary_document(
|
||||
*,
|
||||
video_hash: str,
|
||||
audio_path: Path,
|
||||
transcriber: Transcriber,
|
||||
language: Optional[str] = None,
|
||||
) -> SentenceBoundaryDocument:
|
||||
"""Generate sentence boundaries for a client-supplied audio file.
|
||||
|
||||
The caller owns ``audio_path`` and is responsible for deleting it. Nothing
|
||||
in this function copies or registers the audio in application storage.
|
||||
"""
|
||||
duration_ms = media_duration_ms(audio_path)
|
||||
transcript = None
|
||||
if transcriber.available:
|
||||
try:
|
||||
transcript = transcriber.transcribe(audio_path, language)
|
||||
except Exception as exc:
|
||||
logger.warning("Audio boundary transcription failed; using silence fallback: %s", exc)
|
||||
|
||||
if transcript is not None:
|
||||
document = document_from_transcript(
|
||||
video_hash=video_hash,
|
||||
duration_ms=duration_ms,
|
||||
transcript=transcript,
|
||||
language=language,
|
||||
audio_path=audio_path,
|
||||
)
|
||||
if document.sentences:
|
||||
return document
|
||||
|
||||
starts = detect_sentence_boundaries(audio_path)
|
||||
if not starts:
|
||||
raise ValueError("No sentence boundaries could be detected in the audio.")
|
||||
|
||||
starts_ms = sorted({max(0, min(duration_ms - 1, int(round(start * 1000)))) for start in starts})
|
||||
sentences: List[SentenceBoundary] = []
|
||||
for index, start_ms in enumerate(starts_ms):
|
||||
end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms
|
||||
if end_ms <= start_ms:
|
||||
continue
|
||||
sentences.append(
|
||||
SentenceBoundary(
|
||||
index=len(sentences),
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
text=None,
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
if not sentences:
|
||||
raise ValueError("Detected audio boundaries do not form valid sentence ranges.")
|
||||
|
||||
return SentenceBoundaryDocument(
|
||||
video_hash=video_hash,
|
||||
duration_ms=duration_ms,
|
||||
algorithm_version=SILENCE_ALGORITHM_VERSION,
|
||||
sentences=sentences,
|
||||
)
|
||||
|
||||
|
||||
def _media_duration_ms(path: Path) -> int:
|
||||
from .generate_boundaries import media_duration_ms
|
||||
|
||||
|
||||
@@ -80,6 +80,42 @@ def test_invalid_hash_is_rejected(tmp_path):
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_adhoc_audio_boundaries_do_not_persist_audio(tmp_path, monkeypatch):
|
||||
client = make_client(tmp_path)
|
||||
upload = tmp_path / "audio.wav"
|
||||
upload.write_bytes(b"not-a-real-audio")
|
||||
|
||||
import sentence_api.processing as processing_module
|
||||
|
||||
monkeypatch.setattr(processing_module, "detect_sentence_boundaries", lambda path: [0.0, 1.0])
|
||||
monkeypatch.setattr(processing_module, "media_duration_ms", lambda path: 2000)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/audio/sentence-boundaries",
|
||||
files={"file": ("audio.wav", upload.read_bytes(), "audio/wav")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["algorithm_version"] == "silence-rms-v1"
|
||||
assert payload["duration_ms"] == 2000
|
||||
assert [(item["start_ms"], item["end_ms"]) for item in payload["sentences"]] == [(0, 1000), (1000, 2000)]
|
||||
assert not (tmp_path / "data" / "work").exists() or not any((tmp_path / "data" / "work").iterdir())
|
||||
assert not (tmp_path / "data" / "v").exists() or not any((tmp_path / "data" / "v").iterdir())
|
||||
|
||||
|
||||
def test_adhoc_audio_boundaries_require_login(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
client.headers.clear()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/audio/sentence-boundaries",
|
||||
files={"file": ("audio.wav", b"audio", "audio/wav")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sentence_explanation_requires_login(tmp_path):
|
||||
index_path = tmp_path / "boundaries.json"
|
||||
index_path.write_text("{}", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user