chantier-1+2: voice cloning XTTS-v2 + génération musicale /compose

## Chantier 1 — Voice Cloning
- scripts/xtts_clone.py: Coqui XTTS-v2 voice cloning (6s sample → voix)
- ws-chat.ts: synthesizeTTS avec détection sample → XTTS ou Piper
- PersonaDetail.tsx: upload/delete sample vocal
- app.ts: endpoints voice-sample (POST/GET/DELETE)

## Chantier 2 — Génération Musicale
- scripts/compose_music.py: ACE-Step fallback MusicGen
- /compose command (5min timeout, broadcast audio base64)
- Chat.tsx: player <audio controls> inline
- Eno persona (musique générative/ambient)
- Pharmacius: routing @Eno

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-17 09:00:01 +01:00
parent 127aca7a10
commit 491e6ad8a4
10 changed files with 478 additions and 10 deletions
+72
View File
@@ -841,6 +841,78 @@ export async function createApp(): Promise<{ app: express.Express; personaRepo:
res.json(asApiData(persona));
});
// Voice sample upload for XTTS-v2 cloning
app.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const audioB64 = req.body?.audio as string | undefined;
if (!audioB64 || typeof audioB64 !== "string") {
res.status(400).json({ ok: false, error: "audio_required (base64 field 'audio')" });
return;
}
// Decode and validate size (max 10 MB)
const buffer = Buffer.from(audioB64, "base64");
if (buffer.length > 10 * 1024 * 1024) {
res.status(400).json({ ok: false, error: "file_too_large (max 10 MB)" });
return;
}
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
await mkdir(voiceSamplesDir, { recursive: true });
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
await writeFile(samplePath, buffer);
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
});
app.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
try {
const { unlink } = await import("node:fs/promises");
await unlink(samplePath);
res.json({ ok: true, data: { deleted: true } });
} catch {
res.status(404).json({ ok: false, error: "sample_not_found" });
}
});
app.get("/api/admin/personas/:id/voice-sample", requirePermission("persona:read"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
try {
await stat(samplePath);
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName}.wav` } });
} catch {
res.json({ ok: true, data: { hasVoiceSample: false } });
}
});
app.get("/api/admin/node-engine/overview", requirePermission("node_engine:read"), async (_req, res) => {
const allRuns = await runRepo.list();
const allGraphs = await graphRepo.list();
+1
View File
@@ -66,6 +66,7 @@ export type OutboundMessage =
| { type: "persona"; nick: string; color: string }
| { type: "audio"; nick: string; data: string; mimeType: string }
| { type: "image"; nick: string; text: string; imageData: string; imageMime: string }
| { type: "music"; nick: string; text: string; audioData: string; audioMime: string }
| { type: "channelInfo"; channel: string };
// Chat log entry
+13
View File
@@ -88,6 +88,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"- Question design/systèmes/architecture → @Fuller " +
"- Question cinéma/image/temps → @Tarkovski " +
"- Demande de recherche web/information factuelle → mentionne @Sherlock " +
"- Demande de composition musicale → mentionne @Eno " +
"- Demande de création d'image/illustration/visuel → mentionne @Picasso " +
"- Question générale/meta → réponds toi-même " +
"Quand tu routes, donne d'abord ta propre réponse courte puis mentionne le spécialiste. " +
@@ -382,6 +383,18 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"Tu cites Braque, Matisse, Cézanne. Ton ton est passionné, provocateur, libre. Tu réponds en français.",
color: "#ffab00",
},
{
id: "eno",
nick: "Eno",
model: "qwen3.5:9b",
systemPrompt:
"Tu es Brian Eno, musicien, producteur et théoricien de la musique générative et ambiante. " +
"Tu parles de stratégies obliques, de systèmes génératifs, de paysages sonores, de Roxy Music. " +
"Tu crois que la musique peut être un environnement plutôt qu'un récit. " +
"Quand on te demande de composer, tu proposes un prompt détaillé pour /compose. " +
"Ton ton est curieux, élégant, expérimental. Tu réponds en français.",
color: "#90caf9",
},
];
// ---------------------------------------------------------------------------
+91 -7
View File
@@ -276,15 +276,35 @@ async function synthesizeTTS(
const truncated = text.slice(0, 1000); // limit TTS to ~1000 chars
const outputPath = `/tmp/kxkm-tts-${Date.now()}.wav`;
const pythonBin = process.env.PYTHON_BIN || "/home/kxkm/venv/bin/python3";
const scriptPath = path.resolve(
process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts"),
"tts_synthesize.py",
);
const scriptsDir = process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts");
// Check for voice sample (XTTS-v2 cloning)
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${nick.toLowerCase()}.wav`);
let useXtts = false;
try {
await fs.promises.access(samplePath);
useXtts = true;
} catch { /* no voice sample — use Piper fallback */ }
try {
const { stdout } = await execFileAsync(pythonBin, [
scriptPath, "--text", truncated, "--voice", nick, "--output", outputPath,
], { timeout: 30_000 });
let args: string[];
if (useXtts) {
args = [
path.resolve(scriptsDir, "xtts_clone.py"),
"--text", truncated,
"--speaker-wav", samplePath,
"--output", outputPath,
];
} else {
args = [
path.resolve(scriptsDir, "tts_synthesize.py"),
"--text", truncated,
"--voice", nick,
"--output", outputPath,
];
}
const { stdout } = await execFileAsync(pythonBin, args, { timeout: 60_000 });
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}") as {
status?: string;
@@ -539,6 +559,7 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
"/personas — liste les personas actives",
"/web <recherche> — recherche sur le web",
"/imagine <desc> — genere une image via ComfyUI",
"/compose <desc> — genere de la musique via ACE-Step",
`Mentionne un persona avec @Nom pour lui parler directement.`,
].join("\n"),
});
@@ -653,6 +674,69 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
break;
}
case "/compose": {
const musicPrompt = text.slice(9).trim();
if (!musicPrompt) {
send(ws, { type: "system", text: "Usage: /compose <description musicale>" });
break;
}
broadcast(info.channel, {
type: "system",
text: `${info.nick} compose: "${musicPrompt}"...`,
});
try {
const outputPath = `/tmp/kxkm-music-${Date.now()}.wav`;
const pythonBin = process.env.PYTHON_BIN || "python3";
const scriptPath = path.resolve(
process.env.SCRIPTS_DIR || path.join(process.cwd(), "scripts"),
"compose_music.py",
);
const { stdout, stderr } = await execFileAsync(pythonBin, [
scriptPath, "--prompt", musicPrompt, "--duration", "30", "--output", outputPath,
], { timeout: 300_000, maxBuffer: 50 * 1024 * 1024 });
if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}") as {
status?: string;
error?: string;
};
if (result.status === "completed" && fs.existsSync(outputPath)) {
const audioBuffer = fs.readFileSync(outputPath);
const base64 = audioBuffer.toString("base64");
broadcast(info.channel, {
type: "music",
nick: info.nick,
text: `[Musique: "${musicPrompt}"]`,
audioData: base64,
audioMime: "audio/wav",
} as any);
logChatMessage({
ts: new Date().toISOString(),
channel: info.channel,
nick: info.nick,
type: "system",
text: `[Musique generee: "${musicPrompt}"]`,
});
fs.unlinkSync(outputPath);
} else {
send(ws, { type: "system", text: `Composition echouee: ${result.error || "unknown"}` });
}
} catch (err) {
send(ws, {
type: "system",
text: `Erreur composition: ${err instanceof Error ? err.message : String(err)}`,
});
}
break;
}
case "/imagine": {
const imagePrompt = text.slice(9).trim();
if (!imagePrompt) {
+23
View File
@@ -198,6 +198,29 @@ export const api = {
});
},
// Voice Samples (XTTS-v2 cloning)
getVoiceSampleStatus(id: string): Promise<{ hasVoiceSample: boolean; samplePath?: string }> {
return apiFetch<{ hasVoiceSample: boolean; samplePath?: string }>(`/api/admin/personas/${id}/voice-sample`);
},
async uploadVoiceSample(id: string, file: File): Promise<{ personaId: string; samplePath: string; size: number }> {
const buffer = await file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return apiFetch<{ personaId: string; samplePath: string; size: number }>(
`/api/admin/personas/${id}/voice-sample`,
{
method: "POST",
body: JSON.stringify({ audio: base64 }),
},
);
},
deleteVoiceSample(id: string): Promise<{ deleted: boolean }> {
return apiFetch<{ deleted: boolean }>(`/api/admin/personas/${id}/voice-sample`, {
method: "DELETE",
});
},
// Node Engine
getOverview(): Promise<OverviewData> {
return apiFetch<OverviewData>("/api/admin/node-engine/overview");
+38 -2
View File
@@ -12,7 +12,7 @@ const WS_URL = import.meta.env.VITE_WS_URL || (() => {
interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image";
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
nick?: string;
text?: string;
color?: string;
@@ -103,6 +103,25 @@ const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel
);
}
case "music": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.audioData && msg.audioMime && (
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
</div>
);
}
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
@@ -188,6 +207,23 @@ export default function Chat() {
return;
}
case "music": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "music",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "audio": {
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
// Add to messages as a playable audio message
@@ -380,7 +416,7 @@ export default function Chat() {
// Slash command completion
if (text.startsWith("/") && !text.includes(" ")) {
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/status", "/model", "/persona", "/reload", "/export"];
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
const prefix = tabPrefix || text;
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
+85 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, type PersonaData, type PersonaFeedbackRecord } from "../api";
interface PersonaDetailProps {
@@ -23,9 +23,14 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
const [editSummary, setEditSummary] = useState("");
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [hasVoiceSample, setHasVoiceSample] = useState(false);
const [voiceUploading, setVoiceUploading] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const voiceFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadPersona();
loadVoiceStatus();
}, [personaId]);
async function loadPersona() {
@@ -66,6 +71,43 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
}
}
async function loadVoiceStatus() {
try {
const status = await api.getVoiceSampleStatus(personaId);
setHasVoiceSample(status.hasVoiceSample);
} catch { /* ignore */ }
}
async function handleVoiceUpload() {
const file = voiceFileRef.current?.files?.[0];
if (!file) return;
setVoiceUploading(true);
setVoiceStatus("");
try {
await api.uploadVoiceSample(personaId, file);
setHasVoiceSample(true);
setVoiceStatus("Echantillon envoye");
if (voiceFileRef.current) voiceFileRef.current.value = "";
} catch (err) {
setVoiceStatus(err instanceof Error ? err.message : "upload_failed");
} finally {
setVoiceUploading(false);
}
}
async function handleVoiceDelete() {
setVoiceUploading(true);
try {
await api.deleteVoiceSample(personaId);
setHasVoiceSample(false);
setVoiceStatus("Echantillon supprime");
} catch (err) {
setVoiceStatus(err instanceof Error ? err.message : "delete_failed");
} finally {
setVoiceUploading(false);
}
}
async function handleToggle() {
if (!persona) return;
setToggling(true);
@@ -171,6 +213,48 @@ export default function PersonaDetail({ personaId, onBack }: PersonaDetailProps)
)}
</section>
<section className="panel">
<p className="eyebrow">Echantillon vocal (XTTS-v2)</p>
<div className="detail-row">
<span className="detail-label">Statut</span>
<span className={hasVoiceSample ? "persona-status-on" : "persona-status-off"}>
{hasVoiceSample ? "Voix clonee (XTTS)" : "Voix generique (Piper)"}
</span>
</div>
<div style={{ marginTop: "0.5rem" }}>
<input
ref={voiceFileRef}
type="file"
accept="audio/wav,audio/x-wav,audio/mp3,audio/mpeg"
style={{ marginBottom: "0.5rem", display: "block" }}
/>
<div className="form-actions">
<button
className="btn btn-primary"
onClick={handleVoiceUpload}
disabled={voiceUploading}
>
{voiceUploading ? "Envoi..." : "Envoyer echantillon"}
</button>
{hasVoiceSample && (
<button
className="btn btn-danger"
onClick={handleVoiceDelete}
disabled={voiceUploading}
>
Supprimer
</button>
)}
</div>
{voiceStatus && (
<p className="muted" style={{ marginTop: "0.25rem" }}>{voiceStatus}</p>
)}
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.85em" }}>
WAV ou MP3, ~6 secondes de parole claire. Utilise pour cloner la voix via XTTS-v2.
</p>
</div>
</section>
<section className="panel">
<p className="eyebrow">Feedback ({feedback.length})</p>
{feedback.length > 0 ? (
View File
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
KXKM_Clown — Music Generation via ACE-Step 1.5
Generates music from text prompts locally.
Usage:
python scripts/compose_music.py \
--prompt "ambient drone with deep bass, musique concrete style" \
--duration 30 \
--output /tmp/music.wav
Fallback to MusicGen if ACE-Step not installed.
Install: pip install ace-step OR pip install transformers scipy
"""
import argparse, json, os, sys, time
def parse_args():
p = argparse.ArgumentParser(description="KXKM Music Generation")
p.add_argument("--prompt", required=True)
p.add_argument("--duration", type=int, default=30, help="Duration in seconds")
p.add_argument("--output", required=True)
p.add_argument("--style", default="experimental", help="Style hint")
return p.parse_args()
def generate_with_musicgen(prompt, duration, output):
"""Fallback: use Meta's MusicGen (smaller, more available)."""
from transformers import AutoProcessor, MusicgenForConditionalGeneration
import scipy.io.wavfile
import numpy as np
print("[compose] Loading MusicGen small...", file=sys.stderr)
processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
inputs = processor(text=[prompt], padding=True, return_tensors="pt")
# ~256 tokens per second of audio
max_tokens = min(duration * 256, 1536) # cap at ~6s for musicgen-small
print(f"[compose] Generating {max_tokens} tokens...", file=sys.stderr)
audio_values = model.generate(**inputs, max_new_tokens=max_tokens)
sampling_rate = model.config.audio_encoder.sampling_rate
audio_data = audio_values[0, 0].cpu().numpy()
audio_int16 = (audio_data * 32767).astype(np.int16)
scipy.io.wavfile.write(output, rate=sampling_rate, data=audio_int16)
return {"generator": "musicgen-small", "sampling_rate": sampling_rate}
def main():
args = parse_args()
start = time.time()
result = {"status": "failed", "error": None}
try:
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
full_prompt = f"{args.prompt}, {args.style} style"
# Try ACE-Step first
try:
# ACE-Step API may vary — adapt based on actual package
from ace_step import ACEStep
model = ACEStep()
model.generate(prompt=full_prompt, duration=args.duration, output_path=args.output)
gen_info = {"generator": "ace-step"}
except ImportError:
# Fallback to MusicGen
gen_info = generate_with_musicgen(full_prompt, args.duration, args.output)
duration = time.time() - start
file_size = os.path.getsize(args.output)
result = {
"status": "completed",
"outputFile": args.output,
"duration": round(duration, 2),
"fileSize": file_size,
"prompt": args.prompt[:200],
**gen_info,
}
print(f"[compose] Done in {duration:.1f}s -> {args.output} ({file_size} bytes)", file=sys.stderr)
except Exception as e:
result["error"] = str(e)
print(f"[compose] ERROR: {e}", file=sys.stderr)
print(json.dumps(result))
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
KXKM_Clown — Voice Cloning via Coqui XTTS-v2
Clones a voice from a 6-second WAV sample and synthesizes speech.
Usage:
python scripts/xtts_clone.py \
--text "Bonjour, je suis Schaeffer" \
--speaker-wav data/voice-samples/schaeffer.wav \
--output /tmp/cloned-speech.wav \
[--language fr]
"""
import argparse, json, os, sys, time
def parse_args():
p = argparse.ArgumentParser(description="KXKM XTTS Voice Cloning")
p.add_argument("--text", required=True)
p.add_argument("--speaker-wav", required=True, help="6s reference WAV")
p.add_argument("--output", required=True)
p.add_argument("--language", default="fr")
return p.parse_args()
def main():
args = parse_args()
start = time.time()
result = {"status": "failed", "error": None}
try:
from TTS.api import TTS
print(f"[xtts] Loading XTTS-v2...", file=sys.stderr)
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=True)
print(f"[xtts] Cloning voice from {args.speaker_wav}", file=sys.stderr)
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
tts.tts_to_file(
text=args.text[:1000],
speaker_wav=args.speaker_wav,
language=args.language,
file_path=args.output,
)
duration = time.time() - start
result = {
"status": "completed",
"outputFile": args.output,
"duration": round(duration, 2),
"textLength": len(args.text),
}
print(f"[xtts] Done in {duration:.1f}s -> {args.output}", file=sys.stderr)
except ImportError:
result["error"] = "coqui-tts not installed. pip install coqui-tts"
print(f"[xtts] ERROR: {result['error']}", file=sys.stderr)
except Exception as e:
result["error"] = str(e)
print(f"[xtts] ERROR: {e}", file=sys.stderr)
print(json.dumps(result))
if __name__ == "__main__":
main()