Serve audio as whatever it actually is
audio/mpeg was hardcoded at both points call audio is written and served, from back when the node produced nothing but 16 kbps MP3. It now uploads FLAC, and a browser will not play a FLAC body labelled audio/mpeg. storage.py grows one extension -> Content-Type map, used by the GCS upload and by /media. Keyed off the object's real extension, so every existing .mp3 recording keeps working with no migration -- and _safe_audio_filename already accepted .flac, so object naming needed nothing. Also flags what this costs: /media sends the whole body with Accept-Ranges: none, which was fine at ~60 KB per call and is not fine at ~1.3 MB/min. Noted at the header and in DEFERRED.md, whose stated reason for deferring Range support was the old file size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
457e6d7e0f
commit
1bfa856d1b
@@ -44,11 +44,33 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|||||||
The original extension is preserved only if it's a known audio type.
|
The original extension is preserved only if it's a known audio type.
|
||||||
"""
|
"""
|
||||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
if ext not in AUDIO_CONTENT_TYPES:
|
||||||
ext = ".mp3"
|
ext = ".mp3"
|
||||||
return f"{call_id}{ext}"
|
return f"{call_id}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
# Extension → Content-Type. The node used to send nothing but 16 kbps MP3, so
|
||||||
|
# "audio/mpeg" was hardcoded at every point audio is written or served; it now
|
||||||
|
# sends FLAC (lossless, for Whisper's benefit — see call_recorder.py's AUDIO_*
|
||||||
|
# constants) and a stored object mislabelled audio/mpeg will not play in a
|
||||||
|
# browser. Old .mp3 objects keep working: the map is keyed off the real
|
||||||
|
# extension, not off what the current node happens to produce.
|
||||||
|
AUDIO_CONTENT_TYPES = {
|
||||||
|
".flac": "audio/flac",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
".aac": "audio/aac",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def content_type_for(name: str) -> str:
|
||||||
|
"""Content-Type for a stored audio object, by extension. Defaults to MP3."""
|
||||||
|
ext = os.path.splitext(name or "")[-1].lower()
|
||||||
|
return AUDIO_CONTENT_TYPES.get(ext, "audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||||
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
@@ -65,7 +87,7 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
|||||||
else:
|
else:
|
||||||
client = storage.Client()
|
client = storage.Client()
|
||||||
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
blob.upload_from_string(data, content_type=content_type_for(safe_name))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_upload)
|
await asyncio.to_thread(_upload)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ service-account private key on the VM — is involved anywhere in this path.
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
|
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
@@ -42,12 +42,18 @@ async def get_call_audio(
|
|||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
media_type="audio/mpeg",
|
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
|
||||||
|
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
|
||||||
|
# object's extension so old .mp3 recordings keep working unchanged.
|
||||||
|
media_type=content_type_for(gcs_uri),
|
||||||
headers={
|
headers={
|
||||||
"Content-Length": str(len(data)),
|
"Content-Length": str(len(data)),
|
||||||
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
# Whole body at once, no Range support. This was comfortable at
|
||||||
# whole body is sent at once and the browser seeks within its own
|
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
|
||||||
# buffer. Range support would only matter for long files.
|
# long call is now tens of MB and the browser must download all of
|
||||||
|
# it before playback starts. Acceptable for typical few-second
|
||||||
|
# transmissions, but this is the change that makes Range support
|
||||||
|
# actually matter — see DEFERRED.md.
|
||||||
"Accept-Ranges": "none",
|
"Accept-Ranges": "none",
|
||||||
# Immutable content, but the URL expires — cache privately only.
|
# Immutable content, but the URL expires — cache privately only.
|
||||||
"Cache-Control": "private, max-age=3600",
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
|||||||
Reference in New Issue
Block a user