Autosave: 20260412-184445
This commit is contained in:
parent
bd12d12528
commit
da194c0a8f
@ -10,6 +10,7 @@ const MasteringSessionsDBApi = require('../db/api/mastering_sessions');
|
|||||||
const ExportsDBApi = require('../db/api/exports');
|
const ExportsDBApi = require('../db/api/exports');
|
||||||
const SongMetadataDBApi = require('../db/api/song_metadata');
|
const SongMetadataDBApi = require('../db/api/song_metadata');
|
||||||
const CoverArtworksDBApi = require('../db/api/cover_artworks');
|
const CoverArtworksDBApi = require('../db/api/cover_artworks');
|
||||||
|
const AssetsDBApi = require('../db/api/assets');
|
||||||
|
|
||||||
const { Op } = db.Sequelize;
|
const { Op } = db.Sequelize;
|
||||||
|
|
||||||
@ -98,13 +99,42 @@ function buildDescription({ genreName, languageName, promptText, vocalMode, targ
|
|||||||
.join('. ');
|
.join('. ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUploadedAudioFile(rawFile) {
|
||||||
|
if (!rawFile || typeof rawFile !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `${rawFile.name || ''}`.trim();
|
||||||
|
const privateUrl = `${rawFile.privateUrl || ''}`.trim();
|
||||||
|
const publicUrl = `${rawFile.publicUrl || ''}`.trim();
|
||||||
|
|
||||||
|
if (!name || !privateUrl || !publicUrl || !rawFile.new) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\.(mp3|wav)$/i.test(name) && !/\.(mp3|wav)$/i.test(privateUrl)) {
|
||||||
|
throw badRequest('Only MP3 and WAV vocal uploads are supported right now.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeInBytes = Number(rawFile.sizeInBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rawFile.id || undefined,
|
||||||
|
name,
|
||||||
|
sizeInBytes: Number.isFinite(sizeInBytes) && sizeInBytes > 0 ? sizeInBytes : null,
|
||||||
|
privateUrl,
|
||||||
|
publicUrl,
|
||||||
|
new: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function generateIsrc() {
|
function generateIsrc() {
|
||||||
const year = new Date().getFullYear().toString().slice(-2);
|
const year = new Date().getFullYear().toString().slice(-2);
|
||||||
const random = Math.random().toString().slice(2, 7);
|
const random = Math.random().toString().slice(2, 7);
|
||||||
return `ZA-AIM-${year}-${random}`;
|
return `ZA-AIM-${year}-${random}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapSessionSummary(project, song, generationRequest, mixSession, masteringSession, exportJob, recordingSession, songMetadata, coverArtwork) {
|
function mapSessionSummary(project, song, generationRequest, mixSession, masteringSession, exportJob, recordingSession, songMetadata, coverArtwork, vocalAsset, vocalUpload) {
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
@ -133,6 +163,15 @@ function mapSessionSummary(project, song, generationRequest, mixSession, masteri
|
|||||||
href: `/recording_sessions/${recordingSession.id}`,
|
href: `/recording_sessions/${recordingSession.id}`,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
vocalAsset: vocalAsset
|
||||||
|
? {
|
||||||
|
id: vocalAsset.id,
|
||||||
|
name: vocalAsset.name,
|
||||||
|
fileName: vocalUpload?.name || vocalAsset.name,
|
||||||
|
publicUrl: vocalUpload?.publicUrl || null,
|
||||||
|
href: `/assets/${vocalAsset.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
mixSession: {
|
mixSession: {
|
||||||
id: mixSession.id,
|
id: mixSession.id,
|
||||||
status: mixSession.status,
|
status: mixSession.status,
|
||||||
@ -369,6 +408,12 @@ module.exports = class StudioService {
|
|||||||
throw badRequest('Target BPM must be between 60 and 180.');
|
throw badRequest('Target BPM must be between 60 and 180.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadedVocal = sanitizeUploadedAudioFile(data.vocalUpload);
|
||||||
|
|
||||||
|
if (data.vocalMode === 'upload' && !uploadedVocal) {
|
||||||
|
throw badRequest('Upload an MP3 or WAV vocal take before launching the session.');
|
||||||
|
}
|
||||||
|
|
||||||
const scopedWhere = organizationId ? { organizationsId: organizationId } : {};
|
const scopedWhere = organizationId ? { organizationsId: organizationId } : {};
|
||||||
const [genre, language, selectedPreset, musicModel] = await Promise.all([
|
const [genre, language, selectedPreset, musicModel] = await Promise.all([
|
||||||
db.genres.findOne({ where: { id: data.genreId, ...scopedWhere } }),
|
db.genres.findOne({ where: { id: data.genreId, ...scopedWhere } }),
|
||||||
@ -444,12 +489,30 @@ module.exports = class StudioService {
|
|||||||
{ currentUser, transaction },
|
{ currentUser, transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const vocalAsset = uploadedVocal
|
||||||
|
? await AssetsDBApi.create(
|
||||||
|
{
|
||||||
|
asset_type: 'audio',
|
||||||
|
audio_role: 'vocal_raw',
|
||||||
|
name: uploadedVocal.name,
|
||||||
|
uploaded_user: currentUser.id,
|
||||||
|
project: project.id,
|
||||||
|
song: song.id,
|
||||||
|
is_stereo: false,
|
||||||
|
organizations: organizationId,
|
||||||
|
file_blobs: [uploadedVocal],
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const generationRequest = await GenerationRequestsDBApi.create(
|
const generationRequest = await GenerationRequestsDBApi.create(
|
||||||
{
|
{
|
||||||
project: project.id,
|
project: project.id,
|
||||||
song: song.id,
|
song: song.id,
|
||||||
requested_user: currentUser.id,
|
requested_user: currentUser.id,
|
||||||
model: musicModel?.id || null,
|
model: musicModel?.id || null,
|
||||||
|
input_asset: vocalAsset?.id || null,
|
||||||
request_type: data.vocalMode === 'upload' ? 'generate_beat_from_vocals' : 'generate_beat_from_text',
|
request_type: data.vocalMode === 'upload' ? 'generate_beat_from_vocals' : 'generate_beat_from_text',
|
||||||
prompt_text: promptText,
|
prompt_text: promptText,
|
||||||
target_genre: genre.id,
|
target_genre: genre.id,
|
||||||
@ -490,6 +553,7 @@ module.exports = class StudioService {
|
|||||||
instrument: track.instrument,
|
instrument: track.instrument,
|
||||||
volume_db: track.volume_db,
|
volume_db: track.volume_db,
|
||||||
pan: index % 2 === 0 ? -5 : 5,
|
pan: index % 2 === 0 ? -5 : 5,
|
||||||
|
source_asset: track.track_type === 'vocal' && vocalAsset ? vocalAsset.id : null,
|
||||||
organizations: organizationId,
|
organizations: organizationId,
|
||||||
},
|
},
|
||||||
{ currentUser, transaction },
|
{ currentUser, transaction },
|
||||||
@ -597,6 +661,8 @@ module.exports = class StudioService {
|
|||||||
recordingSession,
|
recordingSession,
|
||||||
songMetadata,
|
songMetadata,
|
||||||
coverArtwork,
|
coverArtwork,
|
||||||
|
vocalAsset,
|
||||||
|
uploadedVocal,
|
||||||
),
|
),
|
||||||
arrangementSections: arrangementSections.map((section) => ({
|
arrangementSections: arrangementSections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
|
|||||||
139
frontend/src/components/Studio/AudioWaveformPreview.tsx
Normal file
139
frontend/src/components/Studio/AudioWaveformPreview.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file?: File | null;
|
||||||
|
audioUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAR_COUNT = 56;
|
||||||
|
|
||||||
|
function createWaveformBars(channelData: Float32Array) {
|
||||||
|
const blockSize = Math.max(1, Math.floor(channelData.length / BAR_COUNT));
|
||||||
|
const bars: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < BAR_COUNT; index += 1) {
|
||||||
|
const start = index * blockSize;
|
||||||
|
const end = Math.min(channelData.length, start + blockSize);
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let sampleIndex = start; sampleIndex < end; sampleIndex += 1) {
|
||||||
|
sum += Math.abs(channelData[sampleIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const average = end > start ? sum / (end - start) : 0;
|
||||||
|
bars.push(Math.min(100, Math.max(8, Math.round(average * 280))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioWaveformPreview = ({
|
||||||
|
file,
|
||||||
|
audioUrl,
|
||||||
|
title = 'Waveform preview',
|
||||||
|
subtitle,
|
||||||
|
emptyMessage = 'Add an audio file to preview its waveform.',
|
||||||
|
isLoading = false,
|
||||||
|
}: Props) => {
|
||||||
|
const [bars, setBars] = useState<number[]>([]);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : audioUrl || ''), [audioUrl, file]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (file && previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file, previewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
const buildWaveform = async () => {
|
||||||
|
if (!file && !audioUrl) {
|
||||||
|
setBars([]);
|
||||||
|
setErrorMessage('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
const audioBuffer = file ? await file.arrayBuffer() : await fetch(audioUrl as string).then((response) => response.arrayBuffer());
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || !window.AudioContext) {
|
||||||
|
throw new Error('AudioContext is unavailable in this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContext = new window.AudioContext();
|
||||||
|
const decoded = await audioContext.decodeAudioData(audioBuffer.slice(0));
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBars(createWaveformBars(decoded.getChannelData(0)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to render waveform preview:', error);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
setBars([]);
|
||||||
|
setErrorMessage('Waveform preview is unavailable for this file, but the audio upload is still attached.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (audioContext && audioContext.state !== 'closed') {
|
||||||
|
audioContext.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildWaveform();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
|
||||||
|
if (audioContext && audioContext.state !== 'closed') {
|
||||||
|
audioContext.close().catch(() => null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [audioUrl, file]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/70 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{title}</div>
|
||||||
|
<div className="mt-2 text-sm font-medium text-white">{subtitle || file?.name || 'Awaiting audio upload'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 px-3 py-1 text-[11px] uppercase tracking-[0.16em] text-slate-400">
|
||||||
|
{isLoading ? 'Rendering…' : previewUrl ? 'Ready to review' : 'No audio'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex h-24 items-end gap-1 overflow-hidden rounded-2xl border border-white/10 bg-slate-900/70 px-3 py-2">
|
||||||
|
{bars.length ? (
|
||||||
|
bars.map((barHeight, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${barHeight}`}
|
||||||
|
className="min-w-[4px] flex-1 rounded-full bg-gradient-to-t from-violet-500 via-violet-300 to-emerald-300 opacity-90"
|
||||||
|
style={{ height: `${barHeight}%` }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-center text-sm text-slate-500">{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewUrl ? <audio controls className="mt-4 w-full" src={previewUrl} preload="metadata" /> : null}
|
||||||
|
{errorMessage ? <p className="mt-3 text-xs leading-5 text-amber-200">{errorMessage}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioWaveformPreview;
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user