sound stuff

This commit is contained in:
Olive Vaughn 2026-09-30 03:31:19 -04:00
parent 309c47e0a7
commit ae03b61dca
22 changed files with 487 additions and 105 deletions

View file

@ -161,6 +161,18 @@ def _extract_stills(job, proxy_path, frames_dir, frames, root):
MAX_RATE = 120 # a capture rate; past this the container is describing something else
def probe_audio(path):
"""The length of an uploaded sound in seconds, refusing a file with no audio."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-show_format", "-of", "json", str(path)]))
if not any(s.get("codec_type") == "audio" for s in data.get("streams", [])):
raise ValueError("the uploaded file has no audio stream")
duration = float(data.get("format", {}).get("duration") or 0)
if duration <= 0:
raise ValueError("the sound's length is unknown")
return duration
def probe(path):
"""What the upload is, as far as choosing a proxy rate goes.

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-09-30 07:14
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0009_revision_blocks'),
]
operations = [
migrations.CreateModel(
name='Sound',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('filename', models.CharField(max_length=255)),
('duration', models.FloatField(help_text='seconds, as ffprobe reports it')),
('created', models.DateTimeField(auto_now_add=True)),
('blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sound_for', to='clips.blob')),
],
),
]

View file

@ -52,6 +52,18 @@ class Source(models.Model):
created = models.DateTimeField(auto_now_add=True)
class Sound(models.Model):
"""An uploaded sound file — mp3, wav, whatever the browser can decode — kept
as uploaded. Not footage: it has no frames and nothing measures it, so it
skips extraction and an audio node plays its bytes directly."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
filename = models.CharField(max_length=255)
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)
class Extraction(models.Model):
"""One requested decode of a source into immutable footage."""

View file

@ -34,7 +34,7 @@ from django.core.management import call_command
from django.test import TestCase, override_settings
from clips import blobs, extraction
from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Source
from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Sound, Source
BLOB_DIR = tempfile.mkdtemp(prefix="arthur-test-blobs-")
@ -721,6 +721,36 @@ class UploadTests(TestCase):
self.assertEqual(27, job.progress)
job.save.assert_called_once_with(update_fields=["progress", "updated"])
def test_an_uploaded_mp3_is_a_sound_and_not_footage(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "tone.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1.5", str(path),
], check=True, capture_output=True)
payload = path.read_bytes()
uploaded = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("tone.mp3", payload, content_type="audio/mpeg")})
self.assertEqual(201, uploaded.status_code, uploaded.content)
sound = uploaded.json()
self.assertEqual("tone.mp3", sound["label"])
self.assertAlmostEqual(1.5, sound["duration"], delta=0.1)
self.assertEqual(payload, b"".join(self.client.get(sound["audio"]).streaming_content))
self.assertEqual(sound, self.client.get(f"/api/sounds/{sound['id']}").json())
self.assertEqual([sound], self.client.get("/api/sounds").json()["sounds"])
self.assertEqual(0, Source.objects.count())
again = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("again.mp3", payload, content_type="audio/mpeg")})
self.assertEqual(200, again.status_code)
self.assertEqual(1, Sound.objects.count())
def test_a_file_without_audio_is_not_a_sound(self):
refused = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("notes.txt", b"not audio", content_type="text/plain")})
self.assertEqual(400, refused.status_code)
def test_uploaded_video_extracts_to_reopenable_footage(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "four-frames.mp4"

View file

@ -22,6 +22,8 @@ urlpatterns = [
path("logout", views.logout),
path("detector", views.detector),
path("sources", views.sources),
path("sounds", views.sounds),
path("sounds/<uuid:sound_id>", views.sound_detail),
path("extractions", views.extractions),
path("extractions/<str:key>", views.extraction_detail),
path("footage", views.footage_list),

View file

@ -43,7 +43,7 @@ from django.views.decorators.http import require_http_methods
from . import blobs, extraction
from .consumers import broadcast
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Sound, Source
KEY_LENGTH = 71 # "sha256:" + 64 hex
@ -225,6 +225,41 @@ def sources(request):
return JsonResponse({"error": str(exc)}, status=400)
def _sound_json(row):
return {"id": str(row.id), "label": row.filename, "duration": row.duration,
"audio": f"/blob/{row.blob_id}"}
@require_http_methods(["GET", "POST"])
def sounds(request):
if request.method == "GET":
return JsonResponse({"sounds": [_sound_json(row)
for row in Sound.objects.order_by("-created")]})
upload = request.FILES.get("file")
if upload is None:
return JsonResponse({"error": "upload a sound as the file field"}, status=400)
try:
digest, size = blobs.write_stream(upload.chunks())
duration = extraction.probe_audio(blobs.path_for(digest))
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size,
"media_type": upload.content_type or "audio/mpeg"})
row, created = Sound.objects.get_or_create(
blob=blob, defaults={"filename": Path(upload.name).name[:255],
"duration": duration})
return JsonResponse(_sound_json(row), status=201 if created else 200)
except (ValueError, OSError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
@require_http_methods(["GET"])
def sound_detail(request, sound_id):
try:
return JsonResponse(_sound_json(Sound.objects.get(id=sound_id)))
except Sound.DoesNotExist:
return JsonResponse({"error": "no such sound"}, status=404)
def _extraction_json(row):
return {"key": row.key, "source": str(row.source_id), "state": row.state,
"progress": row.progress, "error": row.error,