diff --git a/backend/alembic/versions/20261026_1200_add_audio_kind.py b/backend/alembic/versions/20261026_1200_add_audio_kind.py new file mode 100644 index 0000000..a907aa5 --- /dev/null +++ b/backend/alembic/versions/20261026_1200_add_audio_kind.py @@ -0,0 +1,40 @@ +"""add audio_kind to asset + +What an audio file is: music, speech or a sound effect, NULL when not known. It decides +whether an upload is transcribed (music and sound effects are not) and lets Video +Creator keep voice recordings out of a soundtrack search. See app/music.py. + +Existing rows stay NULL, which is treated exactly as before this column existed: +transcribed and analysed. The "Read file tags" library run fills it from tags. + +Revision ID: e4b2c9d17a35 +Revises: d71e4b9a2c86 +Create Date: 2026-10-26 12:00:00.000000+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'e4b2c9d17a35' +down_revision: Union[str, None] = 'd71e4b9a2c86' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.add_column(sa.Column('audio_kind', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + +def downgrade() -> None: + # The drop recreates `asset` on SQLite; see `a8e1c0f4d2b6` for why foreign keys are + # switched off around it. + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=OFF') + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.drop_column('audio_kind') + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=ON') diff --git a/backend/app/enrichment/page_import.py b/backend/app/enrichment/page_import.py index cdf81d2..ef5f1bb 100644 --- a/backend/app/enrichment/page_import.py +++ b/backend/app/enrichment/page_import.py @@ -350,6 +350,9 @@ def report(received: int, total: Optional[int]) -> None: filename=f"{stem[:200]}{path.suffix.lower()}", name=track.title, source=SOURCE_URL, + # A music library's track is music: never sent to Deepgram, and offered + # to a soundtrack search as what it is. + audio_kind=music.AUDIO_KIND_MUSIC, ) except asset_service.UnsupportedFile as exc: raise PageImportError("The site sent a file the library does not accept") from exc @@ -376,6 +379,9 @@ def apply_track_metadata( changes: dict[str, Any] = { "source_url": track.source_url or track.audio_url, "retrieved_at": utcnow(), + # Already set on a fresh import; this is for the copy found by checksum, which + # was perhaps uploaded by hand and never said what it was. + "audio_kind": music.AUDIO_KIND_MUSIC, } for name in ("creator", "publisher", "source_title", "license", "credit_line", "description"): value = getattr(track, name) diff --git a/backend/app/ingest/embedded_metadata.py b/backend/app/ingest/embedded_metadata.py index 2acb1c5..21a9707 100644 --- a/backend/app/ingest/embedded_metadata.py +++ b/backend/app/ingest/embedded_metadata.py @@ -192,6 +192,12 @@ def _music_from_tags(tags: Mapping[str, str]) -> dict[str, Any]: ): found["music_vocals"] = True + # Decided here, at upload, because transcription is queued the moment the file lands + # and the kind is what decides whether it is. + kind = music.kind_from_tags(music.decode_terms(found.get("music_genres")), found.get("music_bpm")) + if kind: + found["audio_kind"] = kind + return found diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py index a9c41a3..ffd2e51 100644 --- a/backend/app/models/asset.py +++ b/backend/app/models/asset.py @@ -147,6 +147,12 @@ class Asset(SQLModel, table=True): # "instrumental", or every untagged song would match a search for music without words. music_vocals: Optional[bool] = None music_energy: Optional[str] = None # low | medium | high + # What the audio is: music | speech | sfx, NULL when not known. Decides whether an + # upload is transcribed (music and sound effects are not) and lets Video Creator keep + # voice recordings out of a soundtrack search. Set from the file's tags, by a music + # page import, or by hand; never guessed from the sound, since a podcast with a music + # bed has a beat and a key and would lose its transcript. See app/music.py. + audio_kind: Optional[str] = None # ─── AI generation (M8) ────────────────────────────────────────────────── # What made this file, kept so it can be made again. Specified in diff --git a/backend/app/music.py b/backend/app/music.py index 5847cf0..4d94af0 100644 --- a/backend/app/music.py +++ b/backend/app/music.py @@ -45,6 +45,27 @@ MIN_BPM = 20.0 MAX_BPM = 300.0 +# What an audio file is, which decides what GAM does with it. `audio_kind` on the asset; +# NULL is "not known" and is treated as it always was (transcribed, analysed). +AUDIO_KIND_MUSIC = "music" +AUDIO_KIND_SPEECH = "speech" +AUDIO_KIND_SFX = "sfx" +AUDIO_KINDS: tuple[str, ...] = (AUDIO_KIND_MUSIC, AUDIO_KIND_SPEECH, AUDIO_KIND_SFX) + +# Kinds that are not transcribed on upload. Transcribing a song bills Deepgram per +# minute for, at best, its lyrics; a sound effect has nothing to say at all. Unknown is +# deliberately not here: losing a talk's transcript is the costly mistake, so only a +# confident "music" or "sound effect" skips it. The Transcribe button still works. +UNTRANSCRIBED_KINDS = frozenset({AUDIO_KIND_MUSIC, AUDIO_KIND_SFX}) + +# Genres that tag a recording of speech rather than music. ID3v1's own list has "Speech" +# (101) and "Audiobook"; podcast tools write "Podcast". A file tagged with one of these is +# speech however else it is tagged. +SPEECH_GENRES = frozenset( + {"speech", "spoken word", "spoken", "podcast", "audiobook", "audio book", "talk", + "lecture", "interview", "news", "comedy", "books & spoken"} +) + MAX_TERMS = 20 MAX_TERM_LENGTH = 60 # Long enough for "F# minor", "Abm", Camelot "12B" or Open Key "10d"; anything longer is @@ -182,3 +203,17 @@ def index_text(asset: Any) -> str: parts.append(f"{energy} energy") return " ".join(parts) + + +def kind_from_tags(genres: list[str], bpm: Optional[float]) -> Optional[str]: + """What a file's own tags say it is, or None when they say nothing either way. + + A speech genre wins. Otherwise a genre or a tempo means music: nobody tags a voice + memo with a BPM, and a genre on a non-speech file is a music genre. Untagged files + stay unknown, which keeps them transcribed. + """ + if any(genre.strip().lower() in SPEECH_GENRES for genre in genres): + return AUDIO_KIND_SPEECH + if genres or bpm is not None: + return AUDIO_KIND_MUSIC + return None diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 319ef58..8bf808e 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -369,7 +369,15 @@ def update_asset( """ asset = _owned(asset_id, user.id, session) changes = music.to_columns(payload.model_dump(exclude_unset=True)) + if changes.get("audio_kind") and asset.asset_type != "audio": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "not_audio", "message": "Only audio files have an audio kind"}, + ) + kind_before = asset.audio_kind asset = service.apply_metadata(session, asset, changes) + if "audio_kind" in changes and asset.audio_kind != kind_before: + service.follow_kind_change(session, asset) # Same reason as the read above, and it bites harder here: the library store replaces # its copy with whatever this returns, so a response with empty tags makes an # asset's tags disappear from the grid after a rename until the page is reloaded. diff --git a/backend/app/schemas_assets.py b/backend/app/schemas_assets.py index b245eaf..d012512 100644 --- a/backend/app/schemas_assets.py +++ b/backend/app/schemas_assets.py @@ -86,6 +86,8 @@ class AssetRead(BaseModel): # Null is "not known", which is different from False, "instrumental". music_vocals: Optional[bool] = None music_energy: Optional[str] = None + # music | speech | sfx, null when not known. Decides transcription; see app/music.py. + audio_kind: Optional[str] = None # Batch-loaded for a listing, never per row: sixty assets a page each asking for # their own tags is sixty queries that grow with the page. @@ -170,6 +172,7 @@ class AssetUpdate(BaseModel): music_key: Optional[str] = Field(default=None, max_length=music.MAX_KEY_LENGTH) music_vocals: Optional[bool] = None music_energy: Optional[Literal["low", "medium", "high"]] = None + audio_kind: Optional[Literal["music", "speech", "sfx"]] = None @field_validator("music_genres", "music_moods", "music_instruments") @classmethod diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index f57b190..e7e0b8d 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -110,6 +110,7 @@ def ingest_file( name: Optional[str] = None, source: str = SOURCE_UPLOAD, content_type: Optional[str] = None, + audio_kind: Optional[str] = None, ) -> Asset: """`ingest_upload` for bytes that are already a file on disk. @@ -121,6 +122,10 @@ def ingest_file( `name` overrides the filename-derived default. An import knows the real title, and deriving it back out of a filename would lose anything the filename could not hold. + + `audio_kind` is for a caller that knows what the audio is before it lands, a music + page import: set on the row before transcription is decided, which is the moment + after cataloguing, so a song from a music library is never sent to Deepgram. """ asset_type = _require_supported(filename) stored = storage.write_file(new_key(user_id, extension_of(filename)), source_path) @@ -134,6 +139,7 @@ def ingest_file( source=source, content_type=content_type, name=name, + audio_kind=audio_kind, ) @@ -157,6 +163,7 @@ def _catalogue( name: Optional[str] = None, origin_project_id: Optional[str] = None, origin_reference_id: Optional[str] = None, + audio_kind: Optional[str] = None, ) -> Asset: """Everything that happens once the bytes are safely stored.""" extension = extension_of(filename) @@ -173,6 +180,7 @@ def _catalogue( checksum_sha256=stored.sha256, origin_project_id=origin_project_id, origin_reference_id=origin_reference_id, + audio_kind=audio_kind if asset_type == TYPE_AUDIO else None, ) session.add(asset) session.commit() @@ -272,7 +280,9 @@ def apply_embedded_fields(session: Session, asset: Asset, embedded: dict) -> Non # What a harvest may fill. Not `name` or `description`: see ingest/embedded_metadata.py. -_EMBEDDED_FIELDS = frozenset(attribution.ATTRIBUTION_FIELDS) | frozenset(music.MUSIC_FIELDS) +_EMBEDDED_FIELDS = ( + frozenset(attribution.ATTRIBUTION_FIELDS) | frozenset(music.MUSIC_FIELDS) | {"audio_kind"} +) def _is_unset(value) -> bool: @@ -310,6 +320,11 @@ def _chain_transcription(session: Session, asset: Asset) -> None: if asset.source == SOURCE_AI: return + # Music and sound effects, by their tags, a music page import, or a person's say-so. + # Unknown is still transcribed: see `music.UNTRANSCRIBED_KINDS`. + if asset.audio_kind in music.UNTRANSCRIBED_KINDS: + return + try: if not load_deepgram_key(session, asset.user_id): logger.info( @@ -326,6 +341,19 @@ def _chain_transcription(session: Session, asset: Asset) -> None: logger.warning("Could not queue transcription for asset %s", asset.id, exc_info=True) +def follow_kind_change(session: Session, asset: Asset) -> None: + """Catch up on what an audio file's new kind says should have happened at upload. + + Marked speech: transcribe it, if it never was. Marked music: measure it. Neither + undoes anything; a transcript already made stays, since the person may want lyrics + searchable, and the guards inside each chain keep a second job from being queued. + """ + if asset.audio_kind == music.AUDIO_KIND_SPEECH and asset.transcript_status in (None, "none"): + _chain_transcription(session, asset) + elif asset.audio_kind == music.AUDIO_KIND_MUSIC: + _chain_music_analysis(session, asset) + + def _chain_music_analysis(session: Session, asset: Asset) -> None: """Measure an audio file's tempo, key and energy the moment it lands. @@ -343,7 +371,7 @@ def _chain_music_analysis(session: Session, asset: Asset) -> None: from app.jobs import enrichment as enrichment_jobs from app.models.job import KIND_ANALYZE_MUSIC - if not analysable(asset): + if not analysable(asset) or asset.audio_kind in (music.AUDIO_KIND_SPEECH, music.AUDIO_KIND_SFX): return try: if enrichment_jobs.active_job(session, asset.id, KIND_ANALYZE_MUSIC) is not None: @@ -953,6 +981,7 @@ def to_read_model( music_key=asset.music_key, music_vocals=asset.music_vocals, music_energy=asset.music_energy, + audio_kind=asset.audio_kind, tags=[ AssetTagRead(id=t.id, name=t.name, category_id=t.category_id) for t in (asset_tags or []) diff --git a/backend/tests/test_audio_kind.py b/backend/tests/test_audio_kind.py new file mode 100644 index 0000000..c294fe9 --- /dev/null +++ b/backend/tests/test_audio_kind.py @@ -0,0 +1,166 @@ +"""What an audio file is (music, speech, a sound effect) and what that decides. + +The rule under test: only a confident "music" or "sound effect" skips transcription. +Unknown keeps the behaviour every audio file had before the kind existed, because a +voice recording losing its transcript is the costly mistake. +""" + +import shutil +from pathlib import Path + +import pytest +from sqlmodel import select + +from app import music +from app.ingest import web +from app.models.asset import Asset +from app.models.job import KIND_ANALYZE_MUSIC, KIND_TRANSCRIBE, EnrichmentJob +from app.settings_store import DEEPGRAM_API_KEY, set_setting + +FIXTURES = Path(__file__).parent / "fixtures" +TEST_USER = "user-under-test" + + +@pytest.fixture +def deepgram(session): + """A Deepgram key, so transcription would be queued unless something stops it.""" + set_setting(session, TEST_USER, DEEPGRAM_API_KEY, "dg-test-key") + + +def _upload(client, name: str) -> dict: + files = [("files", (name, (FIXTURES / name).read_bytes(), "application/octet-stream"))] + return client.post("/api/assets", files=files).json()["created"][0] + + +def _jobs(session, asset_id: str, kind: str) -> list[EnrichmentJob]: + return session.exec( + select(EnrichmentJob).where(EnrichmentJob.asset_id == asset_id, EnrichmentJob.kind == kind) + ).all() + + +# ─── from tags ─────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "genres, bpm, expected", + [ + (["Ambient"], None, "music"), + ([], 92.0, "music"), + (["Podcast"], None, "speech"), + (["Speech"], 120.0, "speech"), # a speech genre wins over anything else + (["Spoken Word", "Jazz"], None, "speech"), + ([], None, None), + ], +) +def test_kind_from_tags(genres, bpm, expected): + assert music.kind_from_tags(genres, bpm) == expected + + +def test_a_tagged_song_is_music_and_is_not_transcribed(library, session, deepgram): + """music_audio.mp3 carries an ID3 genre and TBPM.""" + asset = _upload(library, "music_audio.mp3") + + assert asset["audio_kind"] == "music" + assert _jobs(session, asset["id"], KIND_TRANSCRIBE) == [] + + +def test_an_untagged_recording_is_unknown_and_is_still_transcribed(library, session, deepgram): + asset = _upload(library, "sample_audio.mp3") + + assert asset["audio_kind"] is None + assert len(_jobs(session, asset["id"], KIND_TRANSCRIBE)) == 1 + + +def test_a_video_has_no_audio_kind(library, session): + asset = _upload(library, "sample_video.mp4") + assert asset["audio_kind"] is None + response = library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": "music"}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "not_audio" + + +# ─── by hand ───────────────────────────────────────────────────────────────── + + +def test_marking_music_as_speech_transcribes_it(library, session, deepgram): + asset = _upload(library, "music_audio.mp3") + assert _jobs(session, asset["id"], KIND_TRANSCRIBE) == [] + + data = library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": "speech"}).json()["data"] + + assert data["audio_kind"] == "speech" + assert len(_jobs(session, asset["id"], KIND_TRANSCRIBE)) == 1 + + +def test_marking_speech_as_music_measures_it(library, session, tmp_path): + from tests import music_synth as synth + + # Long enough to be analysable, and tagged nothing, so it starts unknown. + path = synth.write_mp3(synth.song(100, 0, "major", seconds=12), tmp_path / "song.mp3") + files = [("files", ("song.mp3", path.read_bytes(), "audio/mpeg"))] + asset = library.post("/api/assets", files=files).json()["created"][0] + library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": "speech"}) + for job in _jobs(session, asset["id"], KIND_ANALYZE_MUSIC): + session.delete(job) + session.commit() + + library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": "music"}) + + assert len(_jobs(session, asset["id"], KIND_ANALYZE_MUSIC)) == 1 + + +def test_a_speech_file_is_not_analysed_as_music(library, session, tmp_path): + import subprocess + + from tests import music_synth as synth + + path = synth.write_mp3(synth.speechlike(seconds=12), tmp_path / "talk.mp3") + # Tagged as a podcast, as a podcast tool would. + tagged = tmp_path / "talk_tagged.mp3" + subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(path), "-c", "copy", + "-metadata", "genre=Podcast", str(tagged)], + check=True, + ) + files = [("files", ("talk.mp3", tagged.read_bytes(), "audio/mpeg"))] + asset = library.post("/api/assets", files=files).json()["created"][0] + + assert asset["audio_kind"] == "speech" + assert _jobs(session, asset["id"], KIND_ANALYZE_MUSIC) == [] + + +def test_an_invalid_kind_is_refused(library): + asset = _upload(library, "sample_audio.mp3") + response = library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": "podcast"}) + assert response.status_code == 422 + + +def test_clearing_the_kind_is_allowed(library): + asset = _upload(library, "music_audio.mp3") + data = library.patch(f"/api/assets/{asset['id']}", json={"audio_kind": None}).json()["data"] + assert data["audio_kind"] is None + + +# ─── page import ───────────────────────────────────────────────────────────── + + +def test_a_track_from_a_music_page_is_music_and_is_not_transcribed( + library, session, deepgram, monkeypatch +): + def download_audio(url, workdir, stem, **_kwargs): + target = Path(workdir) / f"{stem}.mp3" + shutil.copy(FIXTURES / "sample_audio.mp3", target) # untagged: tags say nothing + return target + + monkeypatch.setattr(web, "download_audio", download_audio) + from app.jobs import enrichment as enrichment_jobs + + track = {"audio_url": "https://a.example.org/song.mp3", "title": "Song"} + job = library.post("/api/assets/import-page", json={"tracks": [track]}).json()["data"][0] + monkeypatch.setattr(enrichment_jobs.queue(), "engine", session.get_bind()) + enrichment_jobs._run_job(job["id"]) + session.expire_all() + + asset = session.exec(select(Asset).where(Asset.name == "Song")).one() + assert asset.audio_kind == "music" + assert _jobs(session, asset.id, KIND_TRANSCRIBE) == [] diff --git a/docs/music-profile.md b/docs/music-profile.md index 676dfa9..1cfbad5 100644 --- a/docs/music-profile.md +++ b/docs/music-profile.md @@ -67,6 +67,28 @@ a guess would put songs under narration), genre and mood. **By hand,** in the asset's Music tab (audio assets only), or through `PATCH /api/assets/{id}`, where a list replaces the stored one and `[]` clears it. +## Audio kind + +`audio_kind`: `music`, `speech`, `sfx`, or NULL for not known. It decides what happens to +an audio upload: + +| Kind | Set by | Transcribed on upload | Analysed for tempo/key/energy | +|---|---|---|---| +| music | A genre or BPM tag; a music page import; by hand | No | Yes | +| speech | A speech genre tag (Podcast, Speech, Spoken Word, Audiobook…); by hand | Yes | No | +| sfx | By hand | No | No | +| not known | Anything else | **Yes**, as before the kind existed | Yes | + +Unknown is transcribed on purpose: a voice recording losing its transcript is the costly +mistake, so only a confident kind skips it. The kind is never guessed from the sound, +because a podcast over a music bed has a beat and a key. It is decided at upload, from +tags or from the importer, because transcription is queued the moment a file lands. + +Changing it in the Music tab ("This is") catches up: marked speech, an untranscribed +file is transcribed; marked music, it is measured. Nothing is undone, and the Transcribe +button works on any kind. Video Creator reads it to offer music, not voice memos, as a +soundtrack. + ## Search Genres, moods, instruments, "instrumental"/"vocals" and " energy" go into a new diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts index fba4cfd..86e4f86 100644 --- a/frontend/src/api/assets.ts +++ b/frontend/src/api/assets.ts @@ -92,6 +92,9 @@ export interface Asset { /** Null is "not known", which is not the same as false, "instrumental". */ music_vocals: boolean | null music_energy: MusicEnergy | null + /** What the audio is. Music and sound effects are not transcribed on upload; null + * ("not known") is, like speech. Set from tags, by a music page import, or by hand. */ + audio_kind: AudioKind | null /** M8. How a generated asset was made — enough to make it again. Null for anything * fal did not produce. The bases are named inside it, not as `parent_asset_id`: a @@ -175,8 +178,13 @@ export interface AssetUpdate { music_key?: string | null music_vocals?: boolean | null music_energy?: MusicEnergy | null + audio_kind?: AudioKind | null } +export const AUDIO_KINDS = ['music', 'speech', 'sfx'] as const + +export type AudioKind = (typeof AUDIO_KINDS)[number] + export const MUSIC_ENERGY_LEVELS = ['low', 'medium', 'high'] as const export type MusicEnergy = (typeof MUSIC_ENERGY_LEVELS)[number] diff --git a/frontend/src/components/MusicPanel.test.tsx b/frontend/src/components/MusicPanel.test.tsx index 5699ac3..7e9eba8 100644 --- a/frontend/src/components/MusicPanel.test.tsx +++ b/frontend/src/components/MusicPanel.test.tsx @@ -145,6 +145,17 @@ describe('MusicPanel', () => { expect(screen.queryByRole('button', { name: /Measure/ })).not.toBeInTheDocument() }) + it('says what the audio is, and saves a change of kind', async () => { + const user = userEvent.setup() + renderPanel() + + expect(screen.getByLabelText('This is')).toHaveValue('music') + await user.selectOptions(screen.getByLabelText('This is'), 'speech') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(update).toHaveBeenCalledWith('a1', { audio_kind: 'speech' }) + }) + it('will not save a tempo that is not one', async () => { const user = userEvent.setup() renderPanel() diff --git a/frontend/src/components/MusicPanel.tsx b/frontend/src/components/MusicPanel.tsx index 784cdbe..8c493e9 100644 --- a/frontend/src/components/MusicPanel.tsx +++ b/frontend/src/components/MusicPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import type { KeyboardEvent } from 'react' import { AudioWaveform, Check } from 'lucide-react' import { MUSIC_ENERGY_LEVELS } from '@/api/assets' -import type { Asset, AssetUpdate, MusicEnergy } from '@/api/assets' +import type { Asset, AssetUpdate, AudioKind, MusicEnergy } from '@/api/assets' import { enrichmentApi } from '@/api/enrichment' import EnrichmentButton from '@/components/EnrichmentButton' import { useLibraryStore } from '@/stores/library' @@ -43,6 +43,13 @@ interface Draft { music_key: string music_vocals: Vocals music_energy: '' | MusicEnergy + audio_kind: '' | AudioKind +} + +const KIND_LABELS: Record = { + music: 'Music', + speech: 'Speech', + sfx: 'Sound effect', } function draftFrom(asset: Asset): Draft { @@ -55,6 +62,7 @@ function draftFrom(asset: Asset): Draft { music_vocals: asset.music_vocals === null ? '' : asset.music_vocals ? 'vocals' : 'instrumental', music_energy: asset.music_energy ?? '', + audio_kind: asset.audio_kind ?? '', } } @@ -121,6 +129,9 @@ export default function MusicPanel({ asset }: Props) { if (draft.music_energy !== current.music_energy) { changes.music_energy = draft.music_energy || null } + if (draft.audio_kind !== current.audio_kind) { + changes.audio_kind = draft.audio_kind || null + } await update(asset.id, changes) flashSaved() @@ -143,6 +154,29 @@ export default function MusicPanel({ asset }: Props) { Anything the file's own tags said is already filled in.

+
+ + +

+ Speech, and anything not known, is transcribed. Music and sound effects are not; + marking a file as speech transcribes it. +

+
+ {/* Measures from the sound and fills only what is blank, so it can never undo a correction. Tempo, key and energy only: vocals, genre and mood need a person. */} {!asset.parent_asset_id && ( diff --git a/frontend/src/test-fixtures.ts b/frontend/src/test-fixtures.ts index d6f22f6..9649196 100644 --- a/frontend/src/test-fixtures.ts +++ b/frontend/src/test-fixtures.ts @@ -35,6 +35,7 @@ export const noAttribution: Pick< | 'music_key' | 'music_vocals' | 'music_energy' + | 'audio_kind' > = { source_url: null, creator: null, @@ -54,6 +55,7 @@ export const noAttribution: Pick< music_key: null, music_vocals: null, music_energy: null, + audio_kind: null, } /**