Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da194c0a8f | ||
|
|
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;
|
||||
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 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'
|
||||
|
||||
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 { 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';
|
||||
|
||||
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