Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da194c0a8f | ||
|
|
bd12d12528 |
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -74,6 +73,7 @@ const cover_artworksRoutes = require('./routes/cover_artworks');
|
|||||||
const marketplace_listingsRoutes = require('./routes/marketplace_listings');
|
const marketplace_listingsRoutes = require('./routes/marketplace_listings');
|
||||||
|
|
||||||
const marketplace_ordersRoutes = require('./routes/marketplace_orders');
|
const marketplace_ordersRoutes = require('./routes/marketplace_orders');
|
||||||
|
const studioRoutes = require('./routes/studio');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
const getBaseUrl = (url) => {
|
||||||
@ -185,6 +185,12 @@ app.use('/api/marketplace_listings', passport.authenticate('jwt', {session: fals
|
|||||||
|
|
||||||
app.use('/api/marketplace_orders', passport.authenticate('jwt', {session: false}), marketplace_ordersRoutes);
|
app.use('/api/marketplace_orders', passport.authenticate('jwt', {session: false}), marketplace_ordersRoutes);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/api/studio',
|
||||||
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
studioRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
26
backend/src/routes/studio.js
Normal file
26
backend/src/routes/studio.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const StudioService = require('../services/studio');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/launchpad',
|
||||||
|
checkPermissions('READ_PROJECTS'),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = await StudioService.getLaunchpad(req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/launchpad',
|
||||||
|
checkPermissions('CREATE_PROJECTS'),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = await StudioService.createLaunchpadSession(req.body || {}, req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
687
backend/src/services/studio.js
Normal file
687
backend/src/services/studio.js
Normal file
@ -0,0 +1,687 @@
|
|||||||
|
const db = require('../db/models');
|
||||||
|
const ProjectsDBApi = require('../db/api/projects');
|
||||||
|
const SongsDBApi = require('../db/api/songs');
|
||||||
|
const GenerationRequestsDBApi = require('../db/api/generation_requests');
|
||||||
|
const RecordingSessionsDBApi = require('../db/api/recording_sessions');
|
||||||
|
const TracksDBApi = require('../db/api/tracks');
|
||||||
|
const ArrangementSectionsDBApi = require('../db/api/arrangement_sections');
|
||||||
|
const MixSessionsDBApi = require('../db/api/mix_sessions');
|
||||||
|
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;
|
||||||
|
|
||||||
|
const moods = ['happy', 'sad', 'spiritual', 'energetic', 'romantic', 'chill', 'aggressive'];
|
||||||
|
const vocalModes = ['instrumental', 'upload', 'record'];
|
||||||
|
const exportFormats = ['wav', 'mp3', 'stems_zip'];
|
||||||
|
|
||||||
|
const arrangementBlueprint = [
|
||||||
|
{ section_type: 'intro', label: 'Atmospheric Intro', start_bar: 1, bar_length: 8, order_index: 1 },
|
||||||
|
{ section_type: 'verse', label: 'Verse Groove', start_bar: 9, bar_length: 16, order_index: 2 },
|
||||||
|
{ section_type: 'chorus', label: 'Main Hook', start_bar: 25, bar_length: 16, order_index: 3 },
|
||||||
|
{ section_type: 'bridge', label: 'Lift & Transition', start_bar: 41, bar_length: 8, order_index: 4 },
|
||||||
|
{ section_type: 'outro', label: 'DJ Friendly Outro', start_bar: 49, bar_length: 8, order_index: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function badRequest(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrganizationId(currentUser) {
|
||||||
|
return currentUser?.organizationsId || currentUser?.organizations?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArtistDisplay(currentUser) {
|
||||||
|
const name = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser?.email || 'Independent Artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrackBlueprint(genreName, vocalMode) {
|
||||||
|
const name = `${genreName || ''}`.toLowerCase();
|
||||||
|
|
||||||
|
let blueprint;
|
||||||
|
|
||||||
|
if (name.includes('amapiano') || name.includes('lekompo') || name.includes('bolobedu')) {
|
||||||
|
blueprint = [
|
||||||
|
{ name: 'Groove Drums', track_type: 'drums', instrument: 'traditional_drums', volume_db: -4 },
|
||||||
|
{ name: 'Log Drum Bass', track_type: 'bass', instrument: 'log_drum', volume_db: -3 },
|
||||||
|
{ name: 'Soul Chords', track_type: 'instrument', instrument: 'piano', volume_db: -5 },
|
||||||
|
{ name: 'Texture Synth', track_type: 'instrument', instrument: 'pads', volume_db: -8 },
|
||||||
|
];
|
||||||
|
} else if (name.includes('gospel') || name.includes('jazz')) {
|
||||||
|
blueprint = [
|
||||||
|
{ name: 'Drum Pocket', track_type: 'drums', instrument: 'perc', volume_db: -5 },
|
||||||
|
{ name: 'Piano Lead', track_type: 'instrument', instrument: 'piano', volume_db: -4 },
|
||||||
|
{ name: 'Warm Bass', track_type: 'bass', instrument: 'other', volume_db: -6 },
|
||||||
|
{ name: 'Choir Pad', track_type: 'instrument', instrument: 'strings', volume_db: -9 },
|
||||||
|
];
|
||||||
|
} else if (name.includes('maskandi')) {
|
||||||
|
blueprint = [
|
||||||
|
{ name: 'Maskandi Guitar', track_type: 'instrument', instrument: 'guitar', volume_db: -4 },
|
||||||
|
{ name: 'Rhythm Drums', track_type: 'drums', instrument: 'traditional_drums', volume_db: -6 },
|
||||||
|
{ name: 'Supporting Bass', track_type: 'bass', instrument: 'other', volume_db: -6 },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
blueprint = [
|
||||||
|
{ name: 'Main Drums', track_type: 'drums', instrument: 'perc', volume_db: -5 },
|
||||||
|
{ name: 'Bass Foundation', track_type: 'bass', instrument: 'other', volume_db: -5 },
|
||||||
|
{ name: 'Lead Synth', track_type: 'instrument', instrument: 'synth', volume_db: -6 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vocalMode !== 'instrumental') {
|
||||||
|
blueprint.unshift({ name: 'Lead Vocal', track_type: 'vocal', instrument: 'voice', volume_db: -3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
blueprint.push({ name: 'Master Bus', track_type: 'master', instrument: 'other', volume_db: -1 });
|
||||||
|
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescription({ genreName, languageName, promptText, vocalMode, targetLabel }) {
|
||||||
|
return [
|
||||||
|
`${genreName} studio workflow`,
|
||||||
|
languageName ? `for ${languageName} vocals` : null,
|
||||||
|
vocalMode !== 'instrumental' ? `with ${vocalMode === 'upload' ? 'uploaded' : 'planned live'} vocal capture` : 'as an instrumental-first draft',
|
||||||
|
targetLabel ? `aimed at ${targetLabel.toLowerCase().replace(/_/g, ' ')}` : null,
|
||||||
|
promptText ? `Seed prompt: ${promptText}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.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, vocalAsset, vocalUpload) {
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
href: `/projects/${project.id}`,
|
||||||
|
},
|
||||||
|
song: {
|
||||||
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
bpm: song.bpm,
|
||||||
|
key_signature: song.key_signature,
|
||||||
|
mood: song.mood,
|
||||||
|
href: `/songs/${song.id}`,
|
||||||
|
},
|
||||||
|
generationRequest: {
|
||||||
|
id: generationRequest.id,
|
||||||
|
status: generationRequest.status,
|
||||||
|
request_type: generationRequest.request_type,
|
||||||
|
href: `/generation_requests/${generationRequest.id}`,
|
||||||
|
},
|
||||||
|
recordingSession: recordingSession
|
||||||
|
? {
|
||||||
|
id: recordingSession.id,
|
||||||
|
status: recordingSession.status,
|
||||||
|
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,
|
||||||
|
href: `/mix_sessions/${mixSession.id}`,
|
||||||
|
},
|
||||||
|
masteringSession: {
|
||||||
|
id: masteringSession.id,
|
||||||
|
status: masteringSession.status,
|
||||||
|
href: `/mastering_sessions/${masteringSession.id}`,
|
||||||
|
},
|
||||||
|
exportJob: {
|
||||||
|
id: exportJob.id,
|
||||||
|
status: exportJob.status,
|
||||||
|
format: exportJob.format,
|
||||||
|
href: `/exports/${exportJob.id}`,
|
||||||
|
},
|
||||||
|
songMetadata: {
|
||||||
|
id: songMetadata.id,
|
||||||
|
distribution_status: songMetadata.distribution_status,
|
||||||
|
isrc: songMetadata.isrc,
|
||||||
|
href: `/song_metadata/${songMetadata.id}`,
|
||||||
|
},
|
||||||
|
coverArtwork: {
|
||||||
|
id: coverArtwork.id,
|
||||||
|
status: coverArtwork.status,
|
||||||
|
href: `/cover_artworks/${coverArtwork.id}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = class StudioService {
|
||||||
|
static async getLaunchpad(currentUser) {
|
||||||
|
const organizationId = getOrganizationId(currentUser);
|
||||||
|
const scopedWhere = organizationId ? { organizationsId: organizationId } : {};
|
||||||
|
|
||||||
|
const [genres, languages, masteringPresets, aiModels, recentProjects] = await Promise.all([
|
||||||
|
db.genres.findAll({
|
||||||
|
attributes: ['id', 'name', 'description', 'typical_bpm_min', 'typical_bpm_max'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
origin: 'south_africa',
|
||||||
|
},
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
limit: 16,
|
||||||
|
}),
|
||||||
|
db.languages.findAll({
|
||||||
|
attributes: ['id', 'name', 'iso_code', 'is_supported'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
is_supported: true,
|
||||||
|
},
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
limit: 12,
|
||||||
|
}),
|
||||||
|
db.mastering_presets.findAll({
|
||||||
|
attributes: ['id', 'name', 'target', 'lufs_target', 'true_peak_db'],
|
||||||
|
where: scopedWhere,
|
||||||
|
order: [['is_default', 'DESC'], ['name', 'ASC']],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
db.ai_models.findAll({
|
||||||
|
attributes: ['id', 'name', 'model_type', 'provider', 'version'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
is_active: true,
|
||||||
|
model_type: {
|
||||||
|
[Op.in]: ['music_generation', 'mixing', 'mastering', 'metadata'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [['model_type', 'ASC'], ['name', 'ASC']],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
db.projects.findAll({
|
||||||
|
attributes: ['id', 'name', 'status', 'target_bpm', 'target_key', 'mood', 'updatedAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.genres,
|
||||||
|
as: 'primary_genre',
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recentSessions = await Promise.all(
|
||||||
|
recentProjects.map(async (project) => {
|
||||||
|
const song = await db.songs.findOne({
|
||||||
|
attributes: ['id', 'title', 'bpm', 'key_signature', 'mood', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [generationRequest, mixSession, masteringSession, exportJob] = song
|
||||||
|
? await Promise.all([
|
||||||
|
db.generation_requests.findOne({
|
||||||
|
attributes: ['id', 'status', 'request_type', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
songId: song.id,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.mix_sessions.findOne({
|
||||||
|
attributes: ['id', 'status', 'mix_type', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
songId: song.id,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.mastering_sessions.findOne({
|
||||||
|
attributes: ['id', 'status', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
songId: song.id,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.exports.findOne({
|
||||||
|
attributes: ['id', 'status', 'format', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
songId: song.id,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [null, null, null, null];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
mood: project.mood,
|
||||||
|
target_bpm: project.target_bpm,
|
||||||
|
target_key: project.target_key,
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
genre: project.primary_genre ? { id: project.primary_genre.id, name: project.primary_genre.name } : null,
|
||||||
|
projectHref: `/projects/${project.id}`,
|
||||||
|
song: song
|
||||||
|
? {
|
||||||
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
href: `/songs/${song.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
generationRequest: generationRequest
|
||||||
|
? {
|
||||||
|
id: generationRequest.id,
|
||||||
|
status: generationRequest.status,
|
||||||
|
request_type: generationRequest.request_type,
|
||||||
|
href: `/generation_requests/${generationRequest.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
mixSession: mixSession
|
||||||
|
? {
|
||||||
|
id: mixSession.id,
|
||||||
|
status: mixSession.status,
|
||||||
|
href: `/mix_sessions/${mixSession.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
masteringSession: masteringSession
|
||||||
|
? {
|
||||||
|
id: masteringSession.id,
|
||||||
|
status: masteringSession.status,
|
||||||
|
href: `/mastering_sessions/${masteringSession.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
exportJob: exportJob
|
||||||
|
? {
|
||||||
|
id: exportJob.id,
|
||||||
|
status: exportJob.status,
|
||||||
|
format: exportJob.format,
|
||||||
|
href: `/exports/${exportJob.id}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
genres: genres.map((genre) => genre.toJSON()),
|
||||||
|
languages: languages.map((language) => language.toJSON()),
|
||||||
|
masteringPresets: masteringPresets.map((preset) => preset.toJSON()),
|
||||||
|
aiModels: aiModels.map((model) => model.toJSON()),
|
||||||
|
recentSessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createLaunchpadSession(data, currentUser) {
|
||||||
|
const title = `${data.title || ''}`.trim();
|
||||||
|
const promptText = `${data.promptText || ''}`.trim();
|
||||||
|
const organizationId = getOrganizationId(currentUser);
|
||||||
|
|
||||||
|
if (!title || title.length < 3) {
|
||||||
|
throw badRequest('Please enter a project title with at least 3 characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.genreId) {
|
||||||
|
throw badRequest('Please choose a South African genre to shape the session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.languageId) {
|
||||||
|
throw badRequest('Please choose the main vocal language for this session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!promptText || promptText.length < 12) {
|
||||||
|
throw badRequest('Please provide a more descriptive beat prompt so the session has direction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moods.includes(data.mood)) {
|
||||||
|
throw badRequest('Please choose a valid mood.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vocalModes.includes(data.vocalMode)) {
|
||||||
|
throw badRequest('Please choose a valid vocal workflow.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exportFormats.includes(data.exportFormat)) {
|
||||||
|
throw badRequest('Please choose a valid export format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBpm = data.targetBpm ? Number(data.targetBpm) : null;
|
||||||
|
if (targetBpm && (Number.isNaN(targetBpm) || targetBpm < 60 || targetBpm > 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 [genre, language, selectedPreset, musicModel] = await Promise.all([
|
||||||
|
db.genres.findOne({ where: { id: data.genreId, ...scopedWhere } }),
|
||||||
|
db.languages.findOne({ where: { id: data.languageId, ...scopedWhere } }),
|
||||||
|
data.masteringPresetId
|
||||||
|
? db.mastering_presets.findOne({ where: { id: data.masteringPresetId, ...scopedWhere } })
|
||||||
|
: db.mastering_presets.findOne({
|
||||||
|
where: scopedWhere,
|
||||||
|
order: [['is_default', 'DESC'], ['name', 'ASC']],
|
||||||
|
}),
|
||||||
|
db.ai_models.findOne({
|
||||||
|
where: {
|
||||||
|
...scopedWhere,
|
||||||
|
is_active: true,
|
||||||
|
model_type: 'music_generation',
|
||||||
|
},
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!genre) {
|
||||||
|
throw badRequest('The selected genre could not be found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
throw badRequest('The selected language could not be found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStatus = data.vocalMode === 'instrumental' ? 'in_progress' : 'ready_for_mix';
|
||||||
|
const now = new Date();
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await ProjectsDBApi.create(
|
||||||
|
{
|
||||||
|
name: title,
|
||||||
|
description: buildDescription({
|
||||||
|
genreName: genre.name,
|
||||||
|
languageName: language.name,
|
||||||
|
promptText,
|
||||||
|
vocalMode: data.vocalMode,
|
||||||
|
targetLabel: selectedPreset?.target,
|
||||||
|
}),
|
||||||
|
status: projectStatus,
|
||||||
|
target_bpm: targetBpm || genre.typical_bpm_min || null,
|
||||||
|
target_key: data.targetKey || null,
|
||||||
|
mood: data.mood,
|
||||||
|
target_duration_seconds: 240,
|
||||||
|
collaboration_enabled: false,
|
||||||
|
last_opened_at: now,
|
||||||
|
created_user: currentUser.id,
|
||||||
|
primary_genre: genre.id,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const song = await SongsDBApi.create(
|
||||||
|
{
|
||||||
|
project: project.id,
|
||||||
|
title,
|
||||||
|
artist_display: getArtistDisplay(currentUser),
|
||||||
|
genre: genre.id,
|
||||||
|
bpm: targetBpm || genre.typical_bpm_min || null,
|
||||||
|
key_signature: data.targetKey || null,
|
||||||
|
mood: data.mood,
|
||||||
|
duration_seconds: 240,
|
||||||
|
vocal_language: language.id,
|
||||||
|
explicit_content: false,
|
||||||
|
lyrics: data.notes ? `${promptText}\n\nCreative notes:\n${data.notes}` : promptText,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ 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,
|
||||||
|
target_bpm: song.bpm,
|
||||||
|
target_key: song.key_signature,
|
||||||
|
mood: data.mood,
|
||||||
|
status: 'queued',
|
||||||
|
queued_at: now,
|
||||||
|
estimated_cost: data.vocalMode === 'upload' ? 2.8 : 1.6,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordingSession =
|
||||||
|
data.vocalMode === 'instrumental'
|
||||||
|
? null
|
||||||
|
: await RecordingSessionsDBApi.create(
|
||||||
|
{
|
||||||
|
project: project.id,
|
||||||
|
song: song.id,
|
||||||
|
started_user: currentUser.id,
|
||||||
|
status: 'planned',
|
||||||
|
input_channels: 1,
|
||||||
|
sample_rate_hz: 48000,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const tracks = await Promise.all(
|
||||||
|
buildTrackBlueprint(genre.name, data.vocalMode).map((track, index) =>
|
||||||
|
TracksDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
name: track.name,
|
||||||
|
track_type: track.track_type,
|
||||||
|
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 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrangementSections = await Promise.all(
|
||||||
|
arrangementBlueprint.map((section) =>
|
||||||
|
ArrangementSectionsDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
section_type: section.section_type,
|
||||||
|
label: section.label,
|
||||||
|
start_bar: section.start_bar,
|
||||||
|
bar_length: section.bar_length,
|
||||||
|
order_index: section.order_index,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mixSession = await MixSessionsDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
requested_user: currentUser.id,
|
||||||
|
mix_type: 'auto_mix',
|
||||||
|
status: 'queued',
|
||||||
|
stereo_widening_amount: genre.name.toLowerCase().includes('gospel') ? 0.4 : 0.6,
|
||||||
|
compression_amount: 0.45,
|
||||||
|
eq_amount: 0.5,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const masteringSession = await MasteringSessionsDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
requested_user: currentUser.id,
|
||||||
|
preset: selectedPreset?.id || null,
|
||||||
|
status: 'queued',
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportJob = await ExportsDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
requested_user: currentUser.id,
|
||||||
|
format: data.exportFormat,
|
||||||
|
quality: data.exportFormat === 'wav' ? 'studio' : 'standard',
|
||||||
|
include_stems: Boolean(data.includeStems) || data.exportFormat === 'stems_zip',
|
||||||
|
status: 'queued',
|
||||||
|
requested_at: now,
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const songMetadata = await SongMetadataDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
isrc: generateIsrc(),
|
||||||
|
release_title: title,
|
||||||
|
release_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||||
|
label_name: 'Independent Release',
|
||||||
|
publisher: getArtistDisplay(currentUser),
|
||||||
|
copyright_notice: `© ${new Date().getFullYear()} ${getArtistDisplay(currentUser)}`,
|
||||||
|
distribution_status: 'ready',
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const coverArtwork = await CoverArtworksDBApi.create(
|
||||||
|
{
|
||||||
|
song: song.id,
|
||||||
|
prompt: `${genre.name} cover artwork, South African nightlife palette, ${data.mood} energy, premium music release design`,
|
||||||
|
status: 'generated',
|
||||||
|
organizations: organizationId,
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `${title} is now staged for beat generation, vocal prep, mix, mastering, and export.`,
|
||||||
|
session: {
|
||||||
|
...mapSessionSummary(
|
||||||
|
project,
|
||||||
|
song,
|
||||||
|
generationRequest,
|
||||||
|
mixSession,
|
||||||
|
masteringSession,
|
||||||
|
exportJob,
|
||||||
|
recordingSession,
|
||||||
|
songMetadata,
|
||||||
|
coverArtwork,
|
||||||
|
vocalAsset,
|
||||||
|
uploadedVocal,
|
||||||
|
),
|
||||||
|
arrangementSections: arrangementSections.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
section_type: section.section_type,
|
||||||
|
start_bar: section.start_bar,
|
||||||
|
bar_length: section.bar_length,
|
||||||
|
})),
|
||||||
|
tracks: tracks.map((track) => ({
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
track_type: track.track_type,
|
||||||
|
instrument: track.instrument,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
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;
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,13 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/studio',
|
||||||
|
label: 'Studio Launchpad',
|
||||||
|
icon: 'mdiMusicBoxMultiple' in icon ? icon['mdiMusicBoxMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_PROJECTS'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,166 +1,179 @@
|
|||||||
|
import { mdiAlbum, mdiChevronRight, mdiLogin, mdiMusic, mdiRobot, mdiShieldAccount } from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import React, { ReactElement } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const genreHighlights = [
|
||||||
|
'Amapiano',
|
||||||
|
'Gqom',
|
||||||
|
'Maskandi',
|
||||||
|
'Kwaito',
|
||||||
|
'Afro House',
|
||||||
|
'SA Gospel',
|
||||||
|
'Motswako',
|
||||||
|
'Lekompo',
|
||||||
|
'Cape Jazz',
|
||||||
|
'Afro Soul',
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
const workflowPillars = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
{
|
||||||
src: undefined,
|
title: 'Create the beat',
|
||||||
photographer: undefined,
|
body: 'Start from a text prompt and shape BPM, key, vibe, and South African genre DNA in seconds.',
|
||||||
photographer_url: undefined,
|
icon: mdiRobot,
|
||||||
})
|
},
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
{
|
||||||
const [contentType, setContentType] = useState('image');
|
title: 'Bring in vocals',
|
||||||
const [contentPosition, setContentPosition] = useState('background');
|
body: 'Plan for uploads or recording sessions, with language-aware vocal workflows for multilingual artists.',
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
icon: mdiMusic,
|
||||||
|
},
|
||||||
const title = 'SA AI Music Studio'
|
{
|
||||||
|
title: 'Ship release-ready audio',
|
||||||
// Fetch Pexels image/video
|
body: 'Queue mix, mastering, metadata, artwork, and export for streaming, radio, or club delivery.',
|
||||||
useEffect(() => {
|
icon: mdiAlbum,
|
||||||
async function fetchData() {
|
},
|
||||||
const image = await getPexelsImage();
|
];
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('SA AI Music Studio')}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A South African-first AI music production studio for beat generation, vocal workflows, mixing, mastering, and export."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-[#07070C] text-white">
|
||||||
<div
|
<div className="mx-auto max-w-7xl px-6 pb-16 pt-6 sm:px-8 lg:px-10">
|
||||||
className={`flex ${
|
<header className="flex flex-wrap items-center justify-between gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-4 backdrop-blur">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div>
|
||||||
} min-h-screen w-full`}
|
<div className="text-xs uppercase tracking-[0.28em] text-violet-300">SA AI Music Studio</div>
|
||||||
>
|
<div className="mt-1 text-lg font-semibold text-white">Browser-based studio workflow</div>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your SA AI Music Studio app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center '>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BaseButtons>
|
<nav className="flex flex-wrap items-center gap-3">
|
||||||
<BaseButton
|
<Link
|
||||||
href='/login'
|
href="/login"
|
||||||
label='Login'
|
className="inline-flex items-center rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-white transition hover:border-violet-300/40 hover:bg-white/5"
|
||||||
color='info'
|
>
|
||||||
className='w-full'
|
<BaseIcon path={mdiLogin} size={16} className="mr-2" />
|
||||||
/>
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center rounded-full bg-violet-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-violet-400"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiShieldAccount} size={16} className="mr-2" />
|
||||||
|
Admin interface
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
</BaseButtons>
|
<main className="mt-10 grid gap-8 xl:grid-cols-[1.4fr_0.9fr] xl:items-center">
|
||||||
</CardBox>
|
<section>
|
||||||
|
<div className="inline-flex items-center rounded-full border border-violet-400/20 bg-violet-500/10 px-4 py-2 text-xs uppercase tracking-[0.24em] text-violet-200">
|
||||||
|
South African genres · Vocal workflows · Mix & master
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 max-w-4xl text-5xl font-semibold leading-tight sm:text-6xl">
|
||||||
|
Build modern South African records with an AI-powered studio workflow.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-3xl text-lg leading-8 text-slate-300">
|
||||||
|
Generate genre-aware beats, stage multilingual vocal sessions, queue mixing and mastering, and organize exports for release — all from one dark, studio-first workspace.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
<Link
|
||||||
|
href="/studio"
|
||||||
|
className="inline-flex items-center rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-violet-100"
|
||||||
|
>
|
||||||
|
Open Studio Launchpad
|
||||||
|
<BaseIcon path={mdiChevronRight} size={18} className="ml-2" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:border-violet-300/40 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Sign in to continue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-wrap gap-3">
|
||||||
|
{genreHighlights.map((genre) => (
|
||||||
|
<span key={genre} className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">
|
||||||
|
{genre}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-br from-violet-600/20 via-slate-900 to-slate-950 p-6 shadow-2xl shadow-violet-950/20">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(168,85,247,0.22),_transparent_45%),radial-gradient(circle_at_bottom_left,_rgba(34,211,238,0.16),_transparent_40%)]" />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-violet-200">Launch-ready slice</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">Generate → Vocal prep → Master</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-emerald-200">
|
||||||
|
live MVP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{workflowPillars.map((pillar) => (
|
||||||
|
<div key={pillar.title} className="rounded-3xl border border-white/10 bg-black/30 p-5 backdrop-blur">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/10 p-3 text-violet-200">
|
||||||
|
<BaseIcon path={pillar.icon} size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-medium text-white">{pillar.title}</div>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-slate-300">{pillar.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Genre engine</div>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold">SA-first sound design</h2>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||||
|
Start sessions for Amapiano, Kwaito, Gqom, Maskandi, Gospel, Cape Jazz, Lekompo, and more with BPM-aware workflow presets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Vocal path</div>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold">Artists move faster</h2>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||||
|
Stage vocal uploads or recording-ready sessions, keep language context attached, and prepare the mix chain without leaving the browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Release prep</div>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold">From idea to export</h2>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||||
|
Queue mastering, metadata, artwork, and exports as part of the same workflow so the first version already feels like a product.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
2052
frontend/src/pages/studio.tsx
Normal file
2052
frontend/src/pages/studio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user