MANZI
This commit is contained in:
parent
fe111dfa46
commit
bd12d12528
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
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_ordersRoutes = require('./routes/marketplace_orders');
|
||||
const studioRoutes = require('./routes/studio');
|
||||
|
||||
|
||||
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/studio',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
studioRoutes,
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
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;
|
||||
621
backend/src/services/studio.js
Normal file
621
backend/src/services/studio.js
Normal file
@ -0,0 +1,621 @@
|
||||
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 { 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 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) {
|
||||
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,
|
||||
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 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 generationRequest = await GenerationRequestsDBApi.create(
|
||||
{
|
||||
project: project.id,
|
||||
song: song.id,
|
||||
requested_user: currentUser.id,
|
||||
model: musicModel?.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,
|
||||
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,
|
||||
),
|
||||
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 AsideMenuList from './AsideMenuList'
|
||||
import { MenuAsideItem } from '../interfaces'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
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 { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -8,6 +8,13 @@ const menuAside: MenuAsideItem[] = [
|
||||
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',
|
||||
label: 'Users',
|
||||
|
||||
@ -1,166 +1,179 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { mdiAlbum, mdiChevronRight, mdiLogin, mdiMusic, mdiRobot, mdiShieldAccount } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import React, { ReactElement } from 'react';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
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 [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('background');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'SA AI Music Studio'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
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>)
|
||||
}
|
||||
};
|
||||
const workflowPillars = [
|
||||
{
|
||||
title: 'Create the beat',
|
||||
body: 'Start from a text prompt and shape BPM, key, vibe, and South African genre DNA in seconds.',
|
||||
icon: mdiRobot,
|
||||
},
|
||||
{
|
||||
title: 'Bring in vocals',
|
||||
body: 'Plan for uploads or recording sessions, with language-aware vocal workflows for multilingual artists.',
|
||||
icon: mdiMusic,
|
||||
},
|
||||
{
|
||||
title: 'Ship release-ready audio',
|
||||
body: 'Queue mix, mastering, metadata, artwork, and export for streaming, radio, or club delivery.',
|
||||
icon: mdiAlbum,
|
||||
},
|
||||
];
|
||||
|
||||
function HomePage() {
|
||||
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>
|
||||
<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>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{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 className="min-h-screen bg-[#07070C] text-white">
|
||||
<div className="mx-auto max-w-7xl px-6 pb-16 pt-6 sm:px-8 lg:px-10">
|
||||
<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">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<nav className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<main className="mt-10 grid gap-8 xl:grid-cols-[1.4fr_0.9fr] xl:items-center">
|
||||
<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>
|
||||
</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>;
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
947
frontend/src/pages/studio.tsx
Normal file
947
frontend/src/pages/studio.tsx
Normal file
@ -0,0 +1,947 @@
|
||||
import {
|
||||
mdiAlbum,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiChevronRight,
|
||||
mdiClockOutline,
|
||||
mdiExportVariant,
|
||||
mdiMicrophone,
|
||||
mdiMusic,
|
||||
mdiOpenInNew,
|
||||
mdiRobot,
|
||||
mdiTuneVariant,
|
||||
mdiWaveform,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import FormField from '../components/FormField';
|
||||
import NotificationBar from '../components/NotificationBar';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type GenreOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
typical_bpm_min?: number;
|
||||
typical_bpm_max?: number;
|
||||
};
|
||||
|
||||
type LanguageOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
iso_code?: string;
|
||||
};
|
||||
|
||||
type MasteringPresetOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
target: string;
|
||||
lufs_target?: number;
|
||||
true_peak_db?: number;
|
||||
};
|
||||
|
||||
type AiModelOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
model_type: string;
|
||||
provider?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type RecentSession = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
mood?: string;
|
||||
target_bpm?: number;
|
||||
target_key?: string;
|
||||
updatedAt: string;
|
||||
genre?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
projectHref: string;
|
||||
song?: {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
} | null;
|
||||
generationRequest?: {
|
||||
id: string;
|
||||
status: string;
|
||||
request_type: string;
|
||||
href: string;
|
||||
} | null;
|
||||
mixSession?: {
|
||||
id: string;
|
||||
status: string;
|
||||
href: string;
|
||||
} | null;
|
||||
masteringSession?: {
|
||||
id: string;
|
||||
status: string;
|
||||
href: string;
|
||||
} | null;
|
||||
exportJob?: {
|
||||
id: string;
|
||||
status: string;
|
||||
format: string;
|
||||
href: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type CreatedLink = {
|
||||
id: string;
|
||||
href: string;
|
||||
status?: string;
|
||||
format?: string;
|
||||
title?: string;
|
||||
bpm?: number;
|
||||
key_signature?: string;
|
||||
mood?: string;
|
||||
request_type?: string;
|
||||
distribution_status?: string;
|
||||
isrc?: string;
|
||||
};
|
||||
|
||||
type CreatedSession = {
|
||||
project: CreatedLink & { name?: string };
|
||||
song: CreatedLink;
|
||||
generationRequest: CreatedLink;
|
||||
recordingSession?: CreatedLink | null;
|
||||
mixSession: CreatedLink;
|
||||
masteringSession: CreatedLink;
|
||||
exportJob: CreatedLink;
|
||||
songMetadata: CreatedLink;
|
||||
coverArtwork: CreatedLink;
|
||||
arrangementSections: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
section_type: string;
|
||||
start_bar: number;
|
||||
bar_length: number;
|
||||
}>;
|
||||
tracks: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
track_type: string;
|
||||
instrument: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type LaunchpadResponse = {
|
||||
genres: GenreOption[];
|
||||
languages: LanguageOption[];
|
||||
masteringPresets: MasteringPresetOption[];
|
||||
aiModels: AiModelOption[];
|
||||
recentSessions: RecentSession[];
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
genreId: string;
|
||||
languageId: string;
|
||||
mood: string;
|
||||
targetBpm: string;
|
||||
targetKey: string;
|
||||
vocalMode: 'instrumental' | 'upload' | 'record';
|
||||
promptText: string;
|
||||
notes: string;
|
||||
masteringPresetId: string;
|
||||
exportFormat: 'wav' | 'mp3' | 'stems_zip';
|
||||
includeStems: boolean;
|
||||
};
|
||||
|
||||
const initialForm: FormState = {
|
||||
title: '',
|
||||
genreId: '',
|
||||
languageId: '',
|
||||
mood: 'energetic',
|
||||
targetBpm: '',
|
||||
targetKey: '',
|
||||
vocalMode: 'upload',
|
||||
promptText: '',
|
||||
notes: '',
|
||||
masteringPresetId: '',
|
||||
exportFormat: 'wav',
|
||||
includeStems: true,
|
||||
};
|
||||
|
||||
const moodOptions = ['happy', 'sad', 'spiritual', 'energetic', 'romantic', 'chill', 'aggressive'];
|
||||
const vocalModes: Array<{ value: FormState['vocalMode']; title: string; description: string }> = [
|
||||
{
|
||||
value: 'upload',
|
||||
title: 'Upload vocal',
|
||||
description: 'Prepare a beat around an imported vocal take and queue vocal treatment.',
|
||||
},
|
||||
{
|
||||
value: 'record',
|
||||
title: 'Record later',
|
||||
description: 'Start with an instrumental draft and a planned recording session.',
|
||||
},
|
||||
{
|
||||
value: 'instrumental',
|
||||
title: 'Instrumental only',
|
||||
description: 'Build a beat-first session without a vocal chain yet.',
|
||||
},
|
||||
];
|
||||
|
||||
const stageCards = [
|
||||
{
|
||||
label: 'Beat generation',
|
||||
detail: 'Queue a South African genre-aware instrumental brief.',
|
||||
icon: mdiRobot,
|
||||
},
|
||||
{
|
||||
label: 'Vocal workflow',
|
||||
detail: 'Prepare upload or recording steps with language-aware context.',
|
||||
icon: mdiMicrophone,
|
||||
},
|
||||
{
|
||||
label: 'Auto mix & master',
|
||||
detail: 'Stage mix, mastering, metadata, artwork, and export in one go.',
|
||||
icon: mdiTuneVariant,
|
||||
},
|
||||
];
|
||||
|
||||
function getStatusClasses(status?: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'succeeded':
|
||||
case 'generated':
|
||||
case 'ready':
|
||||
return 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200';
|
||||
case 'running':
|
||||
case 'recording':
|
||||
case 'in_progress':
|
||||
case 'ready_for_mix':
|
||||
case 'ready_for_master':
|
||||
return 'border-sky-400/40 bg-sky-500/10 text-sky-200';
|
||||
case 'failed':
|
||||
return 'border-rose-400/40 bg-rose-500/10 text-rose-200';
|
||||
default:
|
||||
return 'border-violet-400/40 bg-violet-500/10 text-violet-200';
|
||||
}
|
||||
}
|
||||
|
||||
function prettyStatus(value?: string) {
|
||||
return (value || 'queued').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatRelativeDate(value: string) {
|
||||
const date = new Date(value);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60)));
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
const StudioPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const canCreateProjects = hasPermission(currentUser, 'CREATE_PROJECTS');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const [genres, setGenres] = useState<GenreOption[]>([]);
|
||||
const [languages, setLanguages] = useState<LanguageOption[]>([]);
|
||||
const [masteringPresets, setMasteringPresets] = useState<MasteringPresetOption[]>([]);
|
||||
const [aiModels, setAiModels] = useState<AiModelOption[]>([]);
|
||||
const [recentSessions, setRecentSessions] = useState<RecentSession[]>([]);
|
||||
const [createdSession, setCreatedSession] = useState<CreatedSession | null>(null);
|
||||
|
||||
const selectedGenre = useMemo(
|
||||
() => genres.find((genre) => genre.id === form.genreId) || null,
|
||||
[form.genreId, genres],
|
||||
);
|
||||
|
||||
const selectedPreset = useMemo(
|
||||
() => masteringPresets.find((preset) => preset.id === form.masteringPresetId) || null,
|
||||
[form.masteringPresetId, masteringPresets],
|
||||
);
|
||||
|
||||
const loadLaunchpad = async () => {
|
||||
const { data } = await axios.get<LaunchpadResponse>('/studio/launchpad');
|
||||
|
||||
setGenres(data.genres || []);
|
||||
setLanguages(data.languages || []);
|
||||
setMasteringPresets(data.masteringPresets || []);
|
||||
setAiModels(data.aiModels || []);
|
||||
setRecentSessions(data.recentSessions || []);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
genreId: current.genreId || data.genres?.[0]?.id || '',
|
||||
languageId: current.languageId || data.languages?.[0]?.id || '',
|
||||
masteringPresetId: current.masteringPresetId || data.masteringPresets?.[0]?.id || '',
|
||||
targetBpm:
|
||||
current.targetBpm ||
|
||||
(data.genres?.[0]?.typical_bpm_min ? String(data.genres[0].typical_bpm_min) : ''),
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
await loadLaunchpad();
|
||||
} catch (error) {
|
||||
console.error('Failed to load studio launchpad:', error);
|
||||
setErrorMessage('We could not load the studio launchpad right now. Please refresh and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGenre) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((current) => {
|
||||
if (current.targetBpm) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
targetBpm: selectedGenre.typical_bpm_min ? String(selectedGenre.typical_bpm_min) : '',
|
||||
};
|
||||
});
|
||||
}, [selectedGenre]);
|
||||
|
||||
const handleChange =
|
||||
(field: keyof FormState) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const target = event.target;
|
||||
const value = target instanceof HTMLInputElement && target.type === 'checkbox' ? target.checked : target.value;
|
||||
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
} as FormState));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
setSuccessMessage('');
|
||||
|
||||
const { data } = await axios.post<{ message: string; session: CreatedSession }>('/studio/launchpad', form);
|
||||
setCreatedSession(data.session);
|
||||
setSuccessMessage(data.message);
|
||||
setForm((current) => ({
|
||||
...initialForm,
|
||||
genreId: current.genreId,
|
||||
languageId: current.languageId,
|
||||
masteringPresetId: current.masteringPresetId,
|
||||
targetBpm: current.targetBpm,
|
||||
}));
|
||||
await loadLaunchpad();
|
||||
} catch (error) {
|
||||
console.error('Failed to create studio session:', error);
|
||||
setErrorMessage(
|
||||
axios.isAxiosError(error)
|
||||
? error.response?.data || 'We could not create the session. Please review the form and try again.'
|
||||
: 'We could not create the session. Please review the form and try again.',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickStats = [
|
||||
{
|
||||
label: 'SA genres ready',
|
||||
value: genres.length || '—',
|
||||
icon: mdiMusic,
|
||||
},
|
||||
{
|
||||
label: 'Languages supported',
|
||||
value: languages.length || '—',
|
||||
icon: mdiMicrophone,
|
||||
},
|
||||
{
|
||||
label: 'AI engines online',
|
||||
value: aiModels.length || '—',
|
||||
icon: mdiRobot,
|
||||
},
|
||||
{
|
||||
label: 'Recent sessions',
|
||||
value: recentSessions.length || '0',
|
||||
icon: mdiAlbum,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Studio Launchpad')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Studio Launchpad" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="mb-6 grid gap-6 xl:grid-cols-[1.8fr_1fr]">
|
||||
<CardBox className="overflow-hidden border border-violet-500/20 bg-gradient-to-br from-slate-950 via-slate-900 to-violet-950 text-white shadow-2xl shadow-violet-950/30">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.24em] text-violet-200">
|
||||
South African AI music workflow
|
||||
</div>
|
||||
<h1 className="max-w-2xl text-3xl font-semibold leading-tight md:text-4xl">
|
||||
Turn a beat idea into a staged studio session in one launch.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 md:text-base">
|
||||
Pick your genre, vocal workflow, language, mastering target, and export format. The launchpad instantly creates a linked project, song, AI generation request, arrangement, mix, mastering, metadata, artwork, and export job.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/projects/projects-list"
|
||||
className="inline-flex items-center rounded-full border border-white/10 bg-white/10 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/15"
|
||||
>
|
||||
View all projects
|
||||
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/generation_requests/generation_requests-list"
|
||||
className="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-violet-300/40 hover:text-white"
|
||||
>
|
||||
AI queue
|
||||
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{quickStats.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-400">{item.label}</span>
|
||||
<BaseIcon path={item.icon} size={22} className="text-violet-200" />
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-white">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-slate-800 bg-slate-950 text-white">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Active AI chain</p>
|
||||
<h2 className="mt-2 text-xl font-semibold">What gets staged automatically</h2>
|
||||
</div>
|
||||
<BaseIcon path={mdiWaveform} size={26} className="text-violet-300" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{stageCards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-white/10 p-2 text-violet-200">
|
||||
<BaseIcon path={card.icon} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{card.label}</div>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">{card.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<NotificationBar color="danger" icon={mdiClockOutline}>
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{successMessage ? (
|
||||
<NotificationBar color="success" icon={mdiCheckCircleOutline}>
|
||||
{successMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{!canCreateProjects ? (
|
||||
<NotificationBar color="warning" icon={mdiClockOutline}>
|
||||
Your role can review studio sessions, but it cannot launch new ones yet. Ask an admin for project creation access.
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 2xl:grid-cols-[1.55fr_1fr]">
|
||||
<CardBox className="border border-slate-800 bg-slate-950 text-white">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">First delivery workflow</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Create a launch-ready studio session</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-violet-500/30 bg-violet-500/10 px-4 py-2 text-xs uppercase tracking-[0.2em] text-violet-200">
|
||||
Project → Song → Mix → Master → Export
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField label="Project title" help="Use a release-ready working title for the session.">
|
||||
<input
|
||||
name="title"
|
||||
placeholder="Midnight Log Drum Prayer"
|
||||
value={form.title}
|
||||
onChange={handleChange('title')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Main vocal language" help="Used to stage vocal processing and metadata context.">
|
||||
<select value={form.languageId} onChange={handleChange('languageId')} disabled={!canCreateProjects || isSubmitting}>
|
||||
<option value="">Select language</option>
|
||||
{languages.map((language) => (
|
||||
<option key={language.id} value={language.id}>
|
||||
{language.name}
|
||||
{language.iso_code ? ` (${language.iso_code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<FormField label="South African genre" help="Choose the dominant production style for the generated beat.">
|
||||
<select value={form.genreId} onChange={handleChange('genreId')} disabled={!canCreateProjects || isSubmitting}>
|
||||
<option value="">Select genre</option>
|
||||
{genres.map((genre) => (
|
||||
<option key={genre.id} value={genre.id}>
|
||||
{genre.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Mood" help="Controls arrangement, energy, and mastering direction.">
|
||||
<select value={form.mood} onChange={handleChange('mood')} disabled={!canCreateProjects || isSubmitting}>
|
||||
{moodOptions.map((mood) => (
|
||||
<option key={mood} value={mood}>
|
||||
{prettyStatus(mood)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Vocal workflow" help="Choose whether this starts from vocals, a planned recording, or an instrumental draft.">
|
||||
<select value={form.vocalMode} onChange={handleChange('vocalMode')} disabled={!canCreateProjects || isSubmitting}>
|
||||
{vocalModes.map((mode) => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Beat prompt" help="Describe the groove, emotion, instrumentation, and arrangement vibe.">
|
||||
<textarea
|
||||
name="promptText"
|
||||
placeholder="Soulful Amapiano with warm log drum movement, emotional piano chords, and a radio-ready vocal pocket for Xitsonga hooks."
|
||||
value={form.promptText}
|
||||
onChange={handleChange('promptText')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField label="Creative notes" help="Optional notes for mix balance, vocal texture, or arrangement details.">
|
||||
<textarea
|
||||
name="notes"
|
||||
placeholder="Keep the intro DJ-friendly, push the bass in the chorus, and leave space for call-and-response adlibs."
|
||||
value={form.notes}
|
||||
onChange={handleChange('notes')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<FormField label="Target BPM" help="You can refine the genre default.">
|
||||
<input
|
||||
name="targetBpm"
|
||||
type="number"
|
||||
min={60}
|
||||
max={180}
|
||||
value={form.targetBpm}
|
||||
onChange={handleChange('targetBpm')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Target key" help="Optional if you already know the vocal key center.">
|
||||
<input
|
||||
name="targetKey"
|
||||
placeholder="F# minor"
|
||||
value={form.targetKey}
|
||||
onChange={handleChange('targetKey')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<FormField label="Mastering preset" help="This stages the master destination and loudness profile.">
|
||||
<select
|
||||
value={form.masteringPresetId}
|
||||
onChange={handleChange('masteringPresetId')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
>
|
||||
<option value="">Select mastering preset</option>
|
||||
{masteringPresets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Export target" help="Choose the initial delivery format to queue.">
|
||||
<select value={form.exportFormat} onChange={handleChange('exportFormat')} disabled={!canCreateProjects || isSubmitting}>
|
||||
<option value="wav">WAV · studio quality</option>
|
||||
<option value="mp3">MP3 · quick share</option>
|
||||
<option value="stems_zip">Stems ZIP · distribution prep</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<label className="mt-2 flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-900/60 px-4 py-3 text-sm text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.includeStems}
|
||||
onChange={handleChange('includeStems')}
|
||||
disabled={!canCreateProjects || isSubmitting}
|
||||
className="h-4 w-4 rounded border-slate-600 bg-slate-950 text-violet-400 focus:ring-violet-500"
|
||||
/>
|
||||
Include stems export in the first session package.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label={isSubmitting ? 'Launching session…' : 'Launch studio session'}
|
||||
icon={mdiRobot}
|
||||
disabled={!canCreateProjects || isSubmitting || isLoading}
|
||||
className="!rounded-full !px-6"
|
||||
/>
|
||||
<BaseButton
|
||||
color="white"
|
||||
outline
|
||||
label="Open songs"
|
||||
href="/songs/songs-list"
|
||||
icon={mdiAlbum}
|
||||
className="!rounded-full !px-6 !text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CardBox className="border border-slate-800 bg-slate-950 text-white">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Session preview</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">Current setup</h3>
|
||||
</div>
|
||||
<BaseIcon path={mdiExportVariant} size={24} className="text-violet-300" />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4 text-sm text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Genre DNA</div>
|
||||
<div className="mt-2 text-lg font-medium text-white">{selectedGenre?.name || 'Choose a genre'}</div>
|
||||
<p className="mt-2 leading-6 text-slate-300">{selectedGenre?.description || 'South African groove details will appear here once you pick a genre.'}</p>
|
||||
{selectedGenre?.typical_bpm_min || selectedGenre?.typical_bpm_max ? (
|
||||
<div className="mt-3 text-xs uppercase tracking-[0.18em] text-violet-200">
|
||||
Typical BPM {selectedGenre?.typical_bpm_min || '—'}–{selectedGenre?.typical_bpm_max || '—'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Mastering target</div>
|
||||
<div className="mt-2 text-lg font-medium text-white">{selectedPreset?.name || 'Select a preset'}</div>
|
||||
<p className="mt-2 leading-6 text-slate-300">
|
||||
{selectedPreset
|
||||
? `${prettyStatus(selectedPreset.target)} master${selectedPreset.lufs_target ? ` · LUFS ${selectedPreset.lufs_target}` : ''}${selectedPreset.true_peak_db ? ` · Peak ${selectedPreset.true_peak_db} dB` : ''}`
|
||||
: 'Choose how the first export should be prepared for streaming, radio, or club playback.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Vocal route</div>
|
||||
<div className="mt-2 text-lg font-medium text-white">
|
||||
{vocalModes.find((mode) => mode.value === form.vocalMode)?.title || 'Upload vocal'}
|
||||
</div>
|
||||
<p className="mt-2 leading-6 text-slate-300">
|
||||
{vocalModes.find((mode) => mode.value === form.vocalMode)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-slate-800 bg-slate-950 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">AI engines</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">Available models</h3>
|
||||
</div>
|
||||
<BaseIcon path={mdiRobot} size={24} className="text-violet-300" />
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{aiModels.length ? (
|
||||
aiModels.map((model) => (
|
||||
<div key={model.id} className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-white">{model.name}</div>
|
||||
<p className="text-sm text-slate-300">
|
||||
{prettyStatus(model.model_type)} · {model.provider || 'Internal'}
|
||||
{model.version ? ` · ${model.version}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={20} className="text-emerald-300" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
||||
No active AI models are visible for this workspace yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdSession ? (
|
||||
<CardBox className="mt-6 border border-emerald-500/20 bg-gradient-to-br from-emerald-950/70 via-slate-950 to-slate-950 text-white">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-emerald-300">Session created</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Everything is linked and ready for review</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-xs uppercase tracking-[0.2em] text-emerald-200">
|
||||
End-to-end MVP slice complete
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Project', item: createdSession.project, icon: mdiMusic },
|
||||
{ label: 'Song', item: createdSession.song, icon: mdiAlbum },
|
||||
{ label: 'Generation', item: createdSession.generationRequest, icon: mdiRobot },
|
||||
{ label: 'Recording', item: createdSession.recordingSession, icon: mdiMicrophone },
|
||||
{ label: 'Mix', item: createdSession.mixSession, icon: mdiWaveform },
|
||||
{ label: 'Master', item: createdSession.masteringSession, icon: mdiTuneVariant },
|
||||
{ label: 'Export', item: createdSession.exportJob, icon: mdiExportVariant },
|
||||
{ label: 'Metadata', item: createdSession.songMetadata, icon: mdiCheckCircleOutline },
|
||||
{ label: 'Artwork', item: createdSession.coverArtwork, icon: mdiOpenInNew },
|
||||
].map((entry) =>
|
||||
entry.item ? (
|
||||
<Link
|
||||
key={entry.label}
|
||||
href={entry.item.href}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:border-emerald-300/40 hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-white/10 p-2 text-emerald-200">
|
||||
<BaseIcon path={entry.icon} size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">{entry.label}</div>
|
||||
<div className="mt-1 text-base font-medium text-white">
|
||||
{entry.item.title || entry.item.name || prettyStatus(entry.item.format || entry.item.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseIcon path={mdiChevronRight} size={18} className="text-slate-400" />
|
||||
</div>
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Arrangement builder</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{createdSession.arrangementSections.map((section) => (
|
||||
<div key={section.id} className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-white">{section.label}</div>
|
||||
<div className="text-sm text-slate-400">{prettyStatus(section.section_type)}</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-slate-300">
|
||||
<div>Bar {section.start_bar}</div>
|
||||
<div>{section.bar_length} bars</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Track blueprint</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{createdSession.tracks.map((track) => (
|
||||
<div key={track.id} className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-white">{track.name}</div>
|
||||
<div className="text-sm text-slate-400">{prettyStatus(track.track_type)}</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-violet-400/30 bg-violet-500/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-violet-200">
|
||||
{prettyStatus(track.instrument)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
<CardBox className="mt-6 border border-slate-800 bg-slate-950 text-white">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Recent work</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Recent studio sessions</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/projects/projects-list"
|
||||
className="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-violet-300/40 hover:text-white"
|
||||
>
|
||||
Full project library
|
||||
<BaseIcon path={mdiOpenInNew} size={16} className="ml-2" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-slate-300">
|
||||
Loading the latest studio sessions…
|
||||
</div>
|
||||
) : recentSessions.length ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{recentSessions.map((session) => (
|
||||
<div key={session.id} className="rounded-3xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
{session.genre?.name || 'Studio session'}
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">{session.name}</h3>
|
||||
</div>
|
||||
<div className={`rounded-full border px-3 py-1 text-xs uppercase tracking-[0.16em] ${getStatusClasses(session.status)}`}>
|
||||
{prettyStatus(session.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
{session.target_bpm ? <span className="rounded-full border border-white/10 px-3 py-1">{session.target_bpm} BPM</span> : null}
|
||||
{session.target_key ? <span className="rounded-full border border-white/10 px-3 py-1">{session.target_key}</span> : null}
|
||||
{session.mood ? <span className="rounded-full border border-white/10 px-3 py-1">{prettyStatus(session.mood)}</span> : null}
|
||||
<span className="rounded-full border border-white/10 px-3 py-1">Updated {formatRelativeDate(session.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
session.song ? { label: 'Song', href: session.song.href, status: session.song.title } : null,
|
||||
session.generationRequest
|
||||
? {
|
||||
label: 'Generation',
|
||||
href: session.generationRequest.href,
|
||||
status: prettyStatus(session.generationRequest.status),
|
||||
}
|
||||
: null,
|
||||
session.mixSession
|
||||
? { label: 'Mix', href: session.mixSession.href, status: prettyStatus(session.mixSession.status) }
|
||||
: null,
|
||||
session.masteringSession
|
||||
? {
|
||||
label: 'Master',
|
||||
href: session.masteringSession.href,
|
||||
status: prettyStatus(session.masteringSession.status),
|
||||
}
|
||||
: null,
|
||||
session.exportJob
|
||||
? {
|
||||
label: 'Export',
|
||||
href: session.exportJob.href,
|
||||
status: `${prettyStatus(session.exportJob.status)} · ${session.exportJob.format.toUpperCase()}`,
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={`${session.id}-${item?.label}`}
|
||||
href={item?.href || session.projectHref}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 transition hover:border-violet-300/30 hover:bg-slate-900"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{item?.label}</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{item?.status}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between border-t border-white/10 pt-4">
|
||||
<Link href={session.projectHref} className="inline-flex items-center text-sm font-medium text-violet-200 transition hover:text-white">
|
||||
Open project
|
||||
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
|
||||
</Link>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Studio pipeline live</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/5 p-8 text-center text-sm text-slate-300">
|
||||
No studio sessions yet. Launch your first beat-to-master workflow above and it will appear here instantly.
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
StudioPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_PROJECTS'}>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default StudioPage;
|
||||
Loading…
x
Reference in New Issue
Block a user