Autosave: 20260412-184445

This commit is contained in:
Flatlogic Bot 2026-04-12 18:44:46 +00:00
parent bd12d12528
commit da194c0a8f
3 changed files with 1314 additions and 4 deletions

View File

@ -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,

View 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