end of lyricks and misic generation

This commit is contained in:
Thomas Demirdjian
2025-08-26 13:45:50 +02:00
parent 2a34e9d547
commit 0ca7544005
14 changed files with 641 additions and 375 deletions
+20 -5
View File
@@ -16,7 +16,14 @@
"fallbackToCacheTimeout": 0 "fallbackToCacheTimeout": 0
}, },
"packagerOpts": { "packagerOpts": {
"sourceExts": ["js", "json", "ts", "tsx", "jsx", "vue"] "sourceExts": [
"js",
"json",
"ts",
"tsx",
"jsx",
"vue"
]
}, },
"splash": { "splash": {
"image": "./assets/splash.png", "image": "./assets/splash.png",
@@ -29,7 +36,9 @@
"supportsTablet": true, "supportsTablet": true,
"requireFullScreen": true, "requireFullScreen": true,
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"associatedDomains": ["applinks:minuit.starter"], "associatedDomains": [
"applinks:minuit.starter"
],
"bundleIdentifier": "com.minuit.starter", "bundleIdentifier": "com.minuit.starter",
"infoPlist": { "infoPlist": {
"UISupportedInterfaceOrientations": [ "UISupportedInterfaceOrientations": [
@@ -40,7 +49,10 @@
"UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight" "UIInterfaceOrientationLandscapeRight"
], ],
"LSApplicationQueriesSchemes": ["itms-apps", "minuit"] "LSApplicationQueriesSchemes": [
"itms-apps",
"minuit"
]
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -57,7 +69,9 @@
"backgroundColor": "#FFFFFF" "backgroundColor": "#FFFFFF"
}, },
"package": "com.minuit.starter", "package": "com.minuit.starter",
"permissions": ["android.permission.RECORD_AUDIO"] "permissions": [
"android.permission.RECORD_AUDIO"
]
}, },
"web": { "web": {
"bundler": "metro" "bundler": "metro"
@@ -105,7 +119,8 @@
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone", "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone",
"recordAudioAndroid": true "recordAudioAndroid": true
} }
] ],
"expo-audio"
], ],
"extra": { "extra": {
"eas": { "eas": {
+5 -4
View File
@@ -10,7 +10,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"firebase-admin": "^12.1.0", "firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0", "firebase-functions": "^5.0.0",
"genkit": "^1.16.0" "genkit": "^1.16.0",
"zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.15.0", "eslint": "^8.15.0",
@@ -4749,9 +4750,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+5 -1
View File
@@ -18,11 +18,15 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"firebase-admin": "^12.1.0", "firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.0", "firebase-functions": "^5.0.0",
"genkit": "^1.16.0" "genkit": "^1.16.0",
"zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.15.0", "eslint": "^8.15.0",
"eslint-config-google": "^0.14.0" "eslint-config-google": "^0.14.0"
}, },
"overrides": {
"zod": "3.23.8"
},
"private": true "private": true
} }
View File
+125 -131
View File
@@ -329,174 +329,168 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
* Cette fonction est appelée par l'API Suno lorsque la génération * Cette fonction est appelée par l'API Suno lorsque la génération
* de musique est terminée * de musique est terminée
*/ */
exports.sunoCallback = onRequest( exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
{
methods: ["POST"],
},
async (req, res) => {
try { try {
console.log("🎵 Received music generation callback body:", req.body); console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
if (req.method !== "POST") { if (req.method !== "POST") {
console.warn("⚠️ Méthode non autorisée:", req.method); console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
return res.status(405).json({ error: "Méthode non autorisée" }); return res.status(405).json({ error: "Méthode non autorisée" });
} }
const { code, msg, data } = req.body || {}; const body = req.body || {};
console.log("🎵 Callback details:", { code, msg, data }); const code = body.code ?? body.statusCode ?? null;
const callbackType = (body?.data?.callbackType || "").toString().toLowerCase();
// Extraire les données du callback - peut être dans data ou directement dans req.body const status = (body.status || body.state || callbackType)
const callbackData = data || req.body || {}; .toString()
const { .toLowerCase();
id, const taskId =
taskId, body.taskId ||
status, body.task_id ||
audio_url: audioUrl, body?.data?.taskId ||
video_url: videoUrl, body?.data?.task_id ||
image_url: imageUrl, null;
lyric, const tracks = Array.isArray(body?.data?.data)
title, ? body.data.data
tags, : Array.isArray(body.data)
duration, ? body.data
error_message: errorMessage,
} = callbackData;
// Normaliser le statut global et le taskId
const overallStatus = status || code || callbackData?.status || null;
const overallTaskId = callbackData?.taskId || callbackData?.task_id || null;
// Déterminer les éléments piste(s) à traiter
const items = Array.isArray(callbackData?.data)
? callbackData.data
: Array.isArray(callbackData?.response?.data)
? callbackData.response.data
: Array.isArray(callbackData?.sunoData)
? callbackData.sunoData
: id || audioUrl || imageUrl || title || tags || duration
? [callbackData]
: []; : [];
if (!items.length) { console.log("🎯 [SunoCallback] Détails:", {
// Aucun contenu piste à mettre à jour: acquitter le callback sans erreur code,
return res.status(200).json({ status,
success: true, callbackType,
message: "Callback reçu (aucune piste à mettre à jour)", taskId,
taskId: overallTaskId, count: tracks.length,
status: overallStatus,
}); });
// On n'agit que si code === 200 et status === "complete"
if (code !== 200 || status !== "complete") {
console.log("️ [SunoCallback] Callback ignoré (code/status)", {
code,
status,
});
return res.status(200).json({ success: true, ignored: true });
} }
// Retrouver l'association taskId -> projectId via le champ sunoTaskId du projet if (!taskId) {
let mappedProjectId = null; console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
return res.status(200).json({ success: true, ignored: true });
}
// 1) Récupérer le projectId associé au taskId
let projectId = null;
try { try {
if (overallTaskId) {
const projSnap = await db const projSnap = await db
.collection("projects") .collection("projects")
.where("sunoTaskId", "==", overallTaskId) .where("sunoTaskId", "==", taskId)
.limit(1) .limit(1)
.get(); .get();
if (!projSnap.empty) { if (!projSnap.empty) {
mappedProjectId = projSnap.docs[0].id; projectId = projSnap.docs[0].id;
}
} }
} catch (e) { } catch (e) {
// ignore mapping error, we'll proceed without projectId console.error("❌ [SunoCallback] Erreur lookup project par taskId:", e);
} }
const updates = []; if (!projectId) {
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
for (const item of items) { return res.status(200).json({ success: true, ignored: true });
const trackId = item.id || item.musicId || item.audioId || item.audio_id;
if (!trackId) {
continue;
} }
// Chercher le document correspondant dans Firestore // 2) Extraire jusqu'à 2 URLs audio
const musicRef = db.collection("music").doc(trackId); const audioUrls = tracks
const musicDoc = await musicRef.get(); .map(
(t) =>
t.audio_url ||
t.audioUrl ||
t.stream_audio_url ||
t.streamAudioUrl,
)
.filter(Boolean)
.slice(0, 2);
const updateData = { if (audioUrls.length < 2) {
status: item.status || overallStatus, console.warn(
updatedAt: admin.firestore.FieldValue.serverTimestamp(), "⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
{
found: audioUrls.length,
tracksSample: tracks.map((t) => ({
id: t.id,
has_audio_url: !!t.audio_url,
has_stream_audio_url: !!t.stream_audio_url,
})),
},
);
}
// 3) Télécharger et sauvegarder dans Cloud Storage + récupérer download URLs
const bucket = admin.storage().bucket();
console.log("🪣 [SunoCallback] Bucket:", bucket.name);
const saveOne = async (url, index) => {
if (!url) return null;
try {
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
const resp = await axios.get(url, { responseType: "arraybuffer" });
const buffer = Buffer.from(resp.data);
const path = `musics/${projectId}/sound${index + 1}.mp3`;
const token = require("crypto").randomUUID();
const file = bucket.file(path);
await file.save(buffer, {
resumable: false,
metadata: {
contentType: "audio/mpeg",
cacheControl: "public, max-age=31536000",
metadata: { firebaseStorageDownloadTokens: token },
},
});
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
path,
)}?alt=media&token=${token}`;
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
return { path, url: downloadUrl };
} catch (e) {
console.error(
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
e.message,
);
return null;
}
}; };
const audioU = item.audio_url || item.audioUrl || item.streamAudioUrl; const [p1, p2] = await Promise.all([
const videoU = item.video_url || item.videoUrl; saveOne(audioUrls[0], 0),
const imageU = item.image_url || item.imageUrl; saveOne(audioUrls[1], 1),
const lyricText = item.lyric || item.prompt; ]);
const titleText = item.title;
const tagsText = item.tags;
const durationVal = item.duration;
const errMsg = item.error_message || item.errorMessage;
if (audioU) updateData.audioUrl = audioU; // 4) Mettre à jour le statut du projet
if (videoU) updateData.videoUrl = videoU;
if (imageU) updateData.imageUrl = imageU;
if (lyricText) updateData.lyrics = lyricText;
if (titleText) updateData.title = titleText;
if (tagsText) updateData.style = tagsText;
if (durationVal) updateData.duration = durationVal;
if (errMsg) updateData.errorMessage = errMsg;
if (overallTaskId) updateData.taskId = overallTaskId;
if (mappedProjectId) updateData.projectId = mappedProjectId;
if (!musicDoc.exists) {
updates.push(
musicRef
.set(
{
...updateData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
)
.then(() => ({ id: trackId, ok: true }))
.catch(() => ({ id: trackId, ok: false })),
);
} else {
updates.push(
musicRef
.update(updateData)
.then(() => ({ id: trackId, ok: true }))
.catch(() => ({ id: trackId, ok: false })),
);
}
}
const results = await Promise.all(updates);
const updated = results.filter((r) => r.ok).map((r) => r.id);
// Mettre à jour le statut du projet si on connaît le projectId
if (
mappedProjectId &&
(overallStatus === 200 ||
overallStatus === "SUCCESS" ||
callbackData?.callbackType === "complete")
) {
try { try {
await db.collection("projects").doc(mappedProjectId).set( const musicUrls = [p1?.url, p2?.url].filter(Boolean);
await db.collection("projects").doc(projectId).set(
{ {
musicStatus: "GENERATED", musicStatus: "GENERATED",
musicUrls,
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true }, { merge: true },
); );
} catch (_) {} console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,
musicUrlsCount: musicUrls.length,
});
} catch (e) {
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
} }
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
message: "Callback traité avec succès", projectId,
updated, saved: [p1, p2].filter(Boolean),
taskId: overallTaskId,
status: overallStatus,
}); });
} catch (error) { } catch (error) {
logger.error("❌ Erreur lors du traitement du callback:", error); logger.error("❌ [SunoCallback] Erreur interne:", error);
res.status(500).json({ res
error: "Erreur interne du serveur", .status(500)
message: error.message, .json({ error: "Erreur interne du serveur", message: error.message });
});
} }
}, });
);
File diff suppressed because one or more lines are too long
+1
View File
@@ -40,6 +40,7 @@
"add": "^2.0.6", "add": "^2.0.6",
"deprecated-react-native-prop-types": "^3.0.1", "deprecated-react-native-prop-types": "^3.0.1",
"expo": "~52.0.47", "expo": "~52.0.47",
"expo-audio": "~0.3.5",
"expo-av": "~15.0.2", "expo-av": "~15.0.2",
"expo-blur": "~14.0.3", "expo-blur": "~14.0.3",
"expo-build-properties": "~0.13.3", "expo-build-properties": "~0.13.3",
+24 -3
View File
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
runOnJS,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { LinearGradient } from "./LinearGradient/LinearGradient"; import { LinearGradient } from "./LinearGradient/LinearGradient";
import { Palette, Style } from "../styles"; import { Palette, Style } from "../styles";
@@ -11,7 +12,7 @@ import { FONT_FAMILY } from "../styles/Fonts";
const INITIAL_BOX_SIZE = 6; const INITIAL_BOX_SIZE = 6;
export default ({ value, maxValue }) => { export default ({ value, maxValue, progress, onSeek, seekEnabled = false }) => {
const offset = useSharedValue(0); const offset = useSharedValue(0);
const boxWidth = useSharedValue(INITIAL_BOX_SIZE); const boxWidth = useSharedValue(INITIAL_BOX_SIZE);
const [layout, setLayout] = useState(null); const [layout, setLayout] = useState(null);
@@ -19,7 +20,9 @@ export default ({ value, maxValue }) => {
const SLIDER_WIDTH = layout?.width; const SLIDER_WIDTH = layout?.width;
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE; const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
const pan = Gesture.Pan().onChange((event) => { const pan = Gesture.Pan()
.enabled(seekEnabled)
.onChange((event) => {
offset.value = offset.value =
Math.abs(offset.value) <= MAX_VALUE Math.abs(offset.value) <= MAX_VALUE
? offset.value + event.changeX <= 0 ? offset.value + event.changeX <= 0
@@ -31,8 +34,26 @@ export default ({ value, maxValue }) => {
const newWidth = INITIAL_BOX_SIZE + offset.value; const newWidth = INITIAL_BOX_SIZE + offset.value;
boxWidth.value = newWidth; boxWidth.value = newWidth;
})
.onEnd(() => {
if (!seekEnabled || !onSeek || !MAX_VALUE) return;
const ratio = MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0;
// Reanimated -> JS thread bridge
runOnJS(onSeek)(ratio);
}); });
// Reflect external progress into the slider UI
useEffect(() => {
if (typeof progress === "number" && layout?.width) {
const max = layout.width - INITIAL_BOX_SIZE;
const clamped = Math.max(0, Math.min(1, progress));
const newOffset = clamped * max;
offset.value = newOffset;
boxWidth.value = INITIAL_BOX_SIZE + newOffset;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, layout?.width]);
const boxStyle = useAnimatedStyle(() => { const boxStyle = useAnimatedStyle(() => {
return { return {
width: INITIAL_BOX_SIZE + offset.value, width: INITIAL_BOX_SIZE + offset.value,
+8 -1
View File
@@ -62,11 +62,18 @@ const ComposeSong = () => {
}, [selectedIndex, genres, voice, instruments, rhythm]); }, [selectedIndex, genres, voice, instruments, rhythm]);
const musicConfig = useMemo(() => { const musicConfig = useMemo(() => {
const lyricsArr = []; let lyricsArr = [];
if (Array.isArray(project?.lyrics)) {
lyricsArr = project.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
lyrics: s?.lyrics || "",
}));
} else {
const c = project?.lyrics?.couplet; const c = project?.lyrics?.couplet;
const r = project?.lyrics?.refrain; const r = project?.lyrics?.refrain;
if (c) lyricsArr.push({ type: "couplet", lyrics: c }); if (c) lyricsArr.push({ type: "couplet", lyrics: c });
if (r) lyricsArr.push({ type: "refrain", lyrics: r }); if (r) lyricsArr.push({ type: "refrain", lyrics: r });
}
return { return {
title: project?.title || "", title: project?.title || "",
lyrics: lyricsArr, lyrics: lyricsArr,
+18 -4
View File
@@ -119,8 +119,15 @@ const CreatingSong = ({ active, config }) => {
{ {
sunoTaskId: taskId, sunoTaskId: taskId,
musicStatus: "GENERATING", musicStatus: "GENERATING",
generationStartAt: musicConfig: {
firebase.firestore.FieldValue.serverTimestamp(), title: config?.title || "",
lyrics: config?.lyrics || [],
genres: config?.genres || [],
voice: config?.voice || "",
instruments: config?.instruments || [],
tempo: config?.tempo || "",
},
generationStartAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
@@ -221,12 +228,19 @@ const CreatingSong = ({ active, config }) => {
</Text> </Text>
</View> </View>
<GradientButton <GradientButton
title="Découvrir ma musique" title={
musicStatus === "GENERATING"
? "Création en cours..."
: "Découvrir ma musique"
}
disabled={musicStatus === "GENERATING"}
containerStyle={{ containerStyle={{
width: "80%", width: "80%",
alignSelf: "center", alignSelf: "center",
}} }}
onPress={() => navigate(Routes.SongReady, { result })} onPress={() =>
navigate(Routes.SongReady, { projectId: config?.projectId })
}
/> />
</View> </View>
</BlurView> </BlurView>
+228 -29
View File
@@ -1,22 +1,178 @@
import React, { useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import Slider from "../../components/Slider"; import Slider from "../../components/Slider";
import { Image, Platform, Pressable, View } from "react-native"; import { Image, Platform, Pressable, View, Text } from "react-native";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { Style } from "../../styles"; import { Style } from "../../styles";
import { responsiveWidth } from "react-native-responsive-dimensions";
import { gutters, size } from "../../styles/Style"; import { gutters, size } from "../../styles/Style";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import ValidateModal from "../../components/modal/ValidateModal"; import ValidateModal from "../../components/modal/ValidateModal";
import { useRoute } from "@react-navigation/core";
import firebase from "../../config/firebase";
import { useAudioPlayer } from "expo-audio";
import { responsiveHeight } from "react-native-responsive-dimensions";
const SongReady = () => { const SongReady = () => {
const params = useRoute().params || {};
const projectId = params?.projectId;
const [showValidateModal, setShowValidateModal] = useState(false); const [showValidateModal, setShowValidateModal] = useState(false);
const [musicUrls, setMusicUrls] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState({ 0: false, 1: false });
const [progressInfo, setProgressInfo] = useState({
0: { pos: 0, dur: 0 },
1: { pos: 0, dur: 0 },
});
const player0 = useAudioPlayer(
musicUrls[0] ? { uri: musicUrls[0] } : undefined,
);
const player1 = useAudioPlayer(
musicUrls[1] ? { uri: musicUrls[1] } : undefined,
);
// Charger les URLs depuis le document projet
useEffect(() => {
if (!projectId) return;
const unsub = firebase
.firestore()
.collection("projects")
.doc(projectId)
.onSnapshot((doc) => {
const data = doc.data() || {};
const urls = Array.isArray(data?.musicUrls)
? data.musicUrls.slice(0, 2)
: [];
setMusicUrls(urls);
});
return () => unsub?.();
}, [projectId]);
// Sync progression depuis les players
useEffect(() => {
const id = setInterval(() => {
const d0 = (player0?.duration || 0) * 1000;
const p0 = (player0?.currentTime || 0) * 1000;
const d1 = (player1?.duration || 0) * 1000;
const p1 = (player1?.currentTime || 0) * 1000;
setProgressInfo({ 0: { pos: p0, dur: d0 }, 1: { pos: p1, dur: d1 } });
setIsPlaying({ 0: !!player0?.playing, 1: !!player1?.playing });
}, 300);
return () => clearInterval(id);
}, [player0, player1]);
const togglePlay = async (idx) => {
const url = musicUrls[idx];
if (!url) return;
try {
// Pause l'autre piste si elle joue
const other = idx === 0 ? 1 : 0;
if (isPlaying[other]) {
if (other === 0) await player0?.pause?.();
else await player1?.pause?.();
setIsPlaying((p) => ({ ...p, [other]: false }));
}
const player = idx === 0 ? player0 : player1;
if (!player) return;
if (player.playing) {
await player.pause?.();
setIsPlaying((p) => ({ ...p, [idx]: false }));
} else {
await player.play?.();
setIsPlaying((p) => ({ ...p, [idx]: true }));
}
} catch (e) {
console.log("Audio error", e?.message);
}
};
const fmt = (ms) => {
const total = Math.max(0, Math.floor((ms || 0) / 1000));
const m = Math.floor(total / 60)
.toString()
.padStart(1, "0");
const s = (total % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const onSeek = async (idx, ratio) => {
try {
const info = progressInfo[idx] || {};
const dur = info.dur || 0;
const pos = Math.floor(dur * ratio);
const player = idx === 0 ? player0 : player1;
if (player && dur > 0) {
await player.seekTo?.(Math.floor((pos || 0) / 1000));
}
} catch (e) {
console.log("Seek error", e?.message);
}
};
const validateSelection = async () => {
try {
const url = musicUrls[selectedIndex];
if (!projectId || !url) return;
await firebase
.firestore()
.collection("projects")
.doc(projectId)
.set(
{
song: { index: selectedIndex, url },
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
navigate(Routes.PouchReady);
} catch (e) {
console.log("Validate error", e?.message);
}
};
const onPressRegenerate = async () => {
try {
if (!projectId) return goBack();
const doc = await firebase
.firestore()
.collection("projects")
.doc(projectId)
.get();
const project = doc.data() || {};
const musicConfig = project?.musicConfig || {};
const callable = firebase
.functions()
.httpsCallable("music-generateMusic");
const { data } = await callable({
...musicConfig,
projectId,
});
const taskId =
data?.response?.data?.taskId || data?.response?.data?.task_id;
if (taskId) {
await firebase.firestore().collection("projects").doc(projectId).set(
{
sunoTaskId: taskId,
musicStatus: "GENERATING",
generationStartAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
}
} catch (e) {
console.log("Regenerate error", e?.message);
} finally {
goBack();
}
};
return ( return (
<Page headerType="NONE" backgroundImg={background.studioBG2}> <Page headerType="NONE" backgroundImg={background.studioBG2}>
@@ -25,39 +181,85 @@ const SongReady = () => {
<CreateLyricsHeader <CreateLyricsHeader
title="Ta chanson est prête !" title="Ta chanson est prête !"
subTitle="Quen penses-tu ?" subTitle="Quen penses-tu ?"
/> containerStyle={{
<View marginBottom: responsiveHeight(2),
style={{
...size({ size: responsiveWidth(80) }),
alignSelf: "center",
borderRadius: 1000,
overflow: "hidden",
marginTop: 16,
}} }}
> />
<View style={{ gap: 16 }}>
{[0, 1].map((idx) => (
<BlurView <BlurView
key={idx}
intensity={40} intensity={40}
tint="dark" tint="dark"
style={{ ...Style.containerCenter, flex: 1 }} style={{
borderRadius: 16,
overflow: "hidden",
padding: 12,
backgroundColor: "#FFFFFF0A",
}}
experimentalBlurMethod={ experimentalBlurMethod={
Platform.OS !== "ios" ? "dimezisBlurView" : "none" Platform.OS !== "ios" ? "dimezisBlurView" : "none"
} }
> >
<Image source={icons.disk} /> <View
</BlurView> style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
</View> >
<View style={{ marginTop: 49 }}>
<Slider value="0" maxValue="2:11" />
<Pressable <Pressable
onPress={() => togglePlay(idx)}
style={{ ...Style.containerCenter, ...size({ size: 48 }) }}
>
<Image source={isPlaying[idx] ? icons.pause : icons.play} />
</Pressable>
<View style={{ flex: 1 }}>
<Text
style={{ style={{
alignSelf: "center", color: "white",
...size({ size: 48 }), marginBottom: responsiveHeight(1),
...Style.containerCenter, }}
>{`Morceau ${idx + 1}`}</Text>
<Slider
value={fmt(progressInfo[idx]?.pos)}
maxValue={fmt(progressInfo[idx]?.dur)}
progress={
progressInfo[idx]?.dur
? (progressInfo[idx].pos || 0) / progressInfo[idx].dur
: 0
}
seekEnabled={!!musicUrls[idx]}
onSeek={(ratio) => onSeek(idx, ratio)}
/>
</View>
<Pressable
onPress={() => setSelectedIndex(idx)}
style={{ ...Style.containerCenter, ...size({ size: 24 }) }}
>
<View
style={{
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: "white",
alignItems: "center",
justifyContent: "center",
}} }}
> >
<Image source={icons.play} /> {selectedIndex === idx && (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: "white",
}}
/>
)}
</View>
</Pressable> </Pressable>
</View> </View>
</BlurView>
))}
</View>
</View> </View>
<View <View
style={{ style={{
@@ -70,21 +272,18 @@ const SongReady = () => {
<BorderGradientButton <BorderGradientButton
title="Regénérer" title="Regénérer"
icon={icons.stars} icon={icons.stars}
onPress={() => onPress={onPressRegenerate}
navigate(Routes.Regenerate, {
progress: 63,
})
}
/> />
<GradientButton <GradientButton
title="Valider" title="Choisir ce morceau"
onPress={() => setShowValidateModal(true)} onPress={() => setShowValidateModal(true)}
disabled={!musicUrls?.length}
/> />
</View> </View>
<ValidateModal <ValidateModal
visible={showValidateModal} visible={showValidateModal}
onClose={() => setShowValidateModal(false)} onClose={() => setShowValidateModal(false)}
onPressValidate={() => navigate(Routes.PouchReady)} onPressValidate={validateSelection}
/> />
</Page> </Page>
); );
+31 -14
View File
@@ -1,5 +1,5 @@
import { View, Text, ScrollView, Pressable } from "react-native"; import { View, Text, ScrollView, Pressable, Alert } from "react-native";
import React, { useEffect, useState, useMemo } from "react"; import React, { useState } from "react";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { background } from "../../assets"; import { background } from "../../assets";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
@@ -9,11 +9,11 @@ import { gutters } from "../../styles";
import firebase from "../../config/firebase"; import firebase from "../../config/firebase";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import useDataFromRef from "../../hooks/useDataFromRef"; import useDataFromRef from "../../hooks/useDataFromRef";
import { responsiveHeight } from "react-native-responsive-dimensions";
const Studio = () => { const Studio = () => {
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null);
const isDisabled = useMemo(() => !selectedId, [selectedId]);
const user = firebase.auth().currentUser; const user = firebase.auth().currentUser;
const { data: projects } = useDataFromRef({ const { data: projects } = useDataFromRef({
@@ -131,14 +131,11 @@ const Studio = () => {
> >
Derniers projets générés Derniers projets générés
</Text> </Text>
<ScrollView <ScrollView contentContainerStyle={{ gap: 10, paddingRight: 6 }}>
style={{ maxHeight: 260 }}
contentContainerStyle={{ gap: 10, paddingRight: 6 }}
>
{projects.map((p) => { {projects.map((p) => {
const couplet = p?.lyrics?.couplet || ""; const couplet = p?.lyrics?.couplet || "";
const preview = couplet.split("\n").slice(0, 2).join(" "); const preview = couplet.split("\n").slice(0, 2).join(" ");
const selected = selectedId === p.id; const isSelected = selected === p;
const createdAt = p?.createdAt?.toDate const createdAt = p?.createdAt?.toDate
? p.createdAt.toDate() ? p.createdAt.toDate()
: p?.createdAt : p?.createdAt
@@ -151,13 +148,13 @@ const Studio = () => {
return ( return (
<Pressable <Pressable
key={p.id} key={p.id}
onPress={() => setSelectedId(p.id)} onPress={() => setSelected(p)}
style={{ style={{
backgroundColor: "#0F0C1933", backgroundColor: "#0F0C1933",
borderRadius: 12, borderRadius: 12,
padding: 12, padding: 12,
borderWidth: selected ? 2 : 0, borderWidth: isSelected ? 2 : 0,
borderColor: selected ? "#F94697" : "transparent", borderColor: isSelected ? "#F94697" : "transparent",
}} }}
> >
<Text <Text
@@ -197,9 +194,29 @@ const Studio = () => {
<View style={{ justifyContent: "flex-end" }}> <View style={{ justifyContent: "flex-end" }}>
<GradientButton <GradientButton
title="Commencer" title="Commencer"
disabled={isDisabled} disabled={!selected}
onPress={() => navigate(Routes.Compose, { projectId: selectedId })} onPress={() => {
if (selected.musicStatus === "GENERATING") {
Alert.alert(
"Attention",
"La chanson est en cours de génération. Veuillez patienter.",
);
} else {
navigate(Routes.Compose, { projectId: selected.id });
}
}}
/> />
{selected?.musicUrls?.length > 0 && (
<GradientButton
title="Ecouter les audios"
containerStyle={{
marginTop: responsiveHeight(2),
}}
onPress={() =>
navigate(Routes.SongReady, { projectId: selected.id })
}
/>
)}
</View> </View>
</Page> </Page>
); );
+45 -57
View File
@@ -23,25 +23,35 @@ const Lyrics = ({ navigation }) => {
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const initial = useMemo(() => { const initial = useMemo(() => {
if (!lyricsData || !lyricsData?.success) return {};
const title = lyricsData?.title || ""; const title = lyricsData?.title || "";
const sections = Array.isArray(lyricsData?.lyrics) ? lyricsData.lyrics : []; const aiSections = Array.isArray(lyricsData?.lyrics) ? lyricsData.lyrics : [];
const couplets = sections // Respecter l'ordre de la structure choisie si disponible
.filter((s) => (s?.type || "").toLowerCase().includes("couplet")) const targetStructure = Array.isArray(config?.structure)
.map((s) => s?.lyrics || ""); ? config.structure.map((t) => (t || "").toLowerCase())
const refrains = sections : null;
.filter((s) => (s?.type || "").toLowerCase().includes("refrain")) if (aiSections.length && targetStructure && aiSections.length === targetStructure.length) {
.map((s) => s?.lyrics || ""); return { title, sections: aiSections.map((s) => ({ type: (s?.type || "").toLowerCase(), lyrics: s?.lyrics || "" })) };
}
// Sinon, créer à partir de la structure
if (targetStructure && targetStructure.length) {
return { return {
title, title,
couplet: couplets.join("\n\n"), sections: targetStructure.map((t) => ({ type: t, lyrics: "" })),
refrain: refrains.join("\n\n"),
}; };
}, [lyricsData]); }
// Fallback vide
return { title, sections: [] };
}, [lyricsData, config]);
const [titleValue, setTitleValue] = useState(initial.title || ""); const [titleValue, setTitleValue] = useState(initial.title || "");
const [coupletValue, setCoupletValue] = useState(initial.couplet || ""); const [sections, setSections] = useState(initial.sections || []);
const [refrainValue, setRefrainValue] = useState(initial.refrain || ""); const setSectionAt = (index, value) => {
setSections((prev) => {
const next = [...prev];
if (next[index]) next[index] = { ...next[index], lyrics: value };
return next;
});
};
// Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente // Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente
const regenerate = useCallback(() => { const regenerate = useCallback(() => {
@@ -70,10 +80,10 @@ const Lyrics = ({ navigation }) => {
const user = firebase.auth().currentUser; const user = firebase.auth().currentUser;
const payload = { const payload = {
title: titleValue?.trim() || "", title: titleValue?.trim() || "",
lyrics: { lyrics: (sections || []).map((s) => ({
couplet: coupletValue || "", type: (s?.type || "").toLowerCase(),
refrain: refrainValue || "", lyrics: s?.lyrics || "",
}, })),
config: sanitize(config), config: sanitize(config),
selections: sanitize(selections), selections: sanitize(selections),
userId: user ? user.uid : null, userId: user ? user.uid : null,
@@ -88,14 +98,7 @@ const Lyrics = ({ navigation }) => {
} finally { } finally {
await setIsLoading(false); await setIsLoading(false);
} }
}, [ }, [titleValue, sections, config, selections, setIsLoading]);
titleValue,
coupletValue,
refrainValue,
config,
selections,
setIsLoading,
]);
return ( return (
<Page headerType="NONE"> <Page headerType="NONE">
@@ -124,40 +127,25 @@ const Lyrics = ({ navigation }) => {
value={titleValue} value={titleValue}
setValue={setTitleValue} setValue={setTitleValue}
/> />
<View {sections.map((s, idx) => {
style={{ // Calculer l'index humain par type
height: 45, const type = (s?.type || "").toLowerCase();
justifyContent: "center", const countBefore = sections
backgroundColor: "#00000080", .slice(0, idx)
borderRadius: 14, .filter((x) => (x?.type || "").toLowerCase() === type).length;
paddingHorizontal: 12, const labelBase = type === "refrain" ? "Refrain" : "Couplet";
marginTop: 20, const label = `${labelBase} ${countBefore + 1}`;
}} return (
>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
Introduction instrumentale longue
</Text>
</View>
<CustomInput <CustomInput
label="Couplet" key={idx}
placeholder="Couplet" label={label}
height={225} placeholder={labelBase}
value={coupletValue} height={type === "refrain" ? 170 : 225}
setValue={setCoupletValue} value={s?.lyrics || ""}
/> setValue={(val) => setSectionAt(idx, val)}
<CustomInput
label="Refrain"
placeholder="Refrain"
height={170}
value={refrainValue}
setValue={setRefrainValue}
/> />
);
})}
</ScrollView> </ScrollView>
</ItemContainer> </ItemContainer>
</View> </View>
+5
View File
@@ -5031,6 +5031,11 @@ expo-asset@~11.0.5:
invariant "^2.2.4" invariant "^2.2.4"
md5-file "^3.2.3" md5-file "^3.2.3"
expo-audio@~0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-0.3.5.tgz#1f151ec9919e163019f1aa76a117657e0ea6b613"
integrity sha512-gzpDH3vZI1FDL1Q8pXryACtNIW+idZ/zIZ8WqdTRzJuzxucazrG2gLXUS2ngcXQBn09Jyz4RUnU10Tu2N7/Hgg==
expo-av@~15.0.2: expo-av@~15.0.2:
version "15.0.2" version "15.0.2"
resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-15.0.2.tgz#65bb08658a7fe3a67aa47da614abbfae9adb684e" resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-15.0.2.tgz#65bb08658a7fe3a67aa47da614abbfae9adb684e"