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 SongMetadataDBApi = require('../db/api/song_metadata');
|
||||
const CoverArtworksDBApi = require('../db/api/cover_artworks');
|
||||
const AssetsDBApi = require('../db/api/assets');
|
||||
|
||||
const { Op } = db.Sequelize;
|
||||
|
||||
@ -98,13 +99,42 @@ function buildDescription({ genreName, languageName, promptText, vocalMode, targ
|
||||
.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() {
|
||||
const year = new Date().getFullYear().toString().slice(-2);
|
||||
const random = Math.random().toString().slice(2, 7);
|
||||
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 {
|
||||
project: {
|
||||
id: project.id,
|
||||
@ -133,6 +163,15 @@ function mapSessionSummary(project, song, generationRequest, mixSession, masteri
|
||||
href: `/recording_sessions/${recordingSession.id}`,
|
||||
}
|
||||
: null,
|
||||
vocalAsset: vocalAsset
|
||||
? {
|
||||
id: vocalAsset.id,
|
||||
name: vocalAsset.name,
|
||||
fileName: vocalUpload?.name || vocalAsset.name,
|
||||
publicUrl: vocalUpload?.publicUrl || null,
|
||||
href: `/assets/${vocalAsset.id}`,
|
||||
}
|
||||
: null,
|
||||
mixSession: {
|
||||
id: mixSession.id,
|
||||
status: mixSession.status,
|
||||
@ -369,6 +408,12 @@ module.exports = class StudioService {
|
||||
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 [genre, language, selectedPreset, musicModel] = await Promise.all([
|
||||
db.genres.findOne({ where: { id: data.genreId, ...scopedWhere } }),
|
||||
@ -444,12 +489,30 @@ module.exports = class StudioService {
|
||||
{ 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(
|
||||
{
|
||||
project: project.id,
|
||||
song: song.id,
|
||||
requested_user: currentUser.id,
|
||||
model: musicModel?.id || null,
|
||||
input_asset: vocalAsset?.id || null,
|
||||
request_type: data.vocalMode === 'upload' ? 'generate_beat_from_vocals' : 'generate_beat_from_text',
|
||||
prompt_text: promptText,
|
||||
target_genre: genre.id,
|
||||
@ -490,6 +553,7 @@ module.exports = class StudioService {
|
||||
instrument: track.instrument,
|
||||
volume_db: track.volume_db,
|
||||
pan: index % 2 === 0 ? -5 : 5,
|
||||
source_asset: track.track_type === 'vocal' && vocalAsset ? vocalAsset.id : null,
|
||||
organizations: organizationId,
|
||||
},
|
||||
{ currentUser, transaction },
|
||||
@ -597,6 +661,8 @@ module.exports = class StudioService {
|
||||
recordingSession,
|
||||
songMetadata,
|
||||
coverArtwork,
|
||||
vocalAsset,
|
||||
uploadedVocal,
|
||||
),
|
||||
arrangementSections: arrangementSections.map((section) => ({
|
||||
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