Refactor code structure for improved readability and maintainability

This commit is contained in:
L'électron rare
2026-03-24 19:14:56 +01:00
parent 1b7bd46b41
commit 76bac17334
14 changed files with 572 additions and 347 deletions
+3
View File
@@ -22,3 +22,6 @@ data/*
!data/manifeste.md
!data/manifeste_references_nouvelles.md
.claude/
apps/*
test-results/playwright-html/*
test-results/
+41 -3
View File
@@ -257,12 +257,50 @@ stateDiagram-v2
### Coordinateur
- [x] Audit SOTA 2026 intelligence + affectation agents par module
- [x] lot-552: cloture documentaire lot-192 + sync QA/veille OSS
### Backend API
- [x] lot-192: ws-commands modular extraction phases 1-2 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition)
- [ ] lot-192: phase 3 extraction edition avancee (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
- [x] lot-192: ws-commands modular extraction phases 1-3 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition, edition avancee)
- [ ] lot-549: renfort tests composition et export
## Delta Session 2026-03-24 — phase 3
### Backend API
- [x] lot-192: extraction complete du bloc compose avance (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
- [ ] lot-193: renfort tests composition et export
- [x] lot-552: cloture documentaire et durcissement final lot-192
- [ ] lot-549: renfort tests composition et export
- [ ] lot-548: waveform timeline UI v1
## Affectations Session 2026-03-24 — suite immediate
| Lot | Agent pilote | Sous-agents / competences | Portee immediate |
|---|---|---|---|
| lot-549 | Backend API | Polyglot Test Agent, QA, context-map | Etendre `apps/api/src/ws-commands.test.ts` et `apps/api/src/composition-store.test.ts`, verifier non-regression composition |
| lot-548 | Frontend | Expert React Frontend Engineer, gem-browser-tester, context-map | Remplacer la waveform canvas par une timeline waveform plus lisible sans regression mobile |
| lot-552 | Coordinateur | Planner/Docs, gem-researcher, refactor-plan | Maintenir PLAN/TODO/AGENTS/QA/OSS alignes avec l'etat reel et tracer les prochains deltas |
## Skills privilegies
- context-map: cartographie des fichiers et dependances avant modification.
- refactor-plan: sequencing prudent des changements multi-fichiers.
- polyglot-test-agent: lot-549 pour la recherche, le plan et l'implementation de tests additionnels.
## Delta Session 2026-03-24 — validation compose + waveform
### Backend API
- [x] lot-549: renfort tests composition et export
- Validation ciblee: `tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 OK
### Frontend
- [x] lot-548: waveform timeline UI v1
- Implementation: `wavesurfer.js` branche dans `apps/web/src/components/ComposePage.tsx` pour apercu piste + bloc timeline
- Validation: `npm run check` dans `apps/web` OK
## Delta Session 2026-03-24 — cleanup compose
### Frontend
- [x] lot-553: suppression du doublon `apps/web/src/components/ComposePage 2.tsx`
- Validation: `npm run check` dans `apps/web` OK
- Impact: reduction dette technique UI compose et elimination d'un faux point d'entree potentiel
## Delta Session 2026-03-24 — QA visuelle compose
### Frontend
- [x] lot-554: validation Playwright ciblee compose timeline
- Commande: `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
- Resultat: 2/2 OK
+26 -5
View File
@@ -1022,13 +1022,34 @@ Updated: 2026-03-20T12:00:00Z
## Delta Session 2026-03-24
- lot-192-ws-commands-extraction reste en cours.
- Phases 1-2 executees: extraction de /comp, /layer, /mix, /voice, /tracks, /undo, /solo, /unsolo, /rename, /dup, /bpm, /clear-comp, /preview, /gain, /loop, /swap et /info vers apps/api/src/ws-commands-compose.ts.
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK.
- Prochaine etape: phase 3 sur concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize, puis lot-193 tests composition.
- lot-192-ws-commands-extraction est cloture.
- Phases 1-3 executees: extraction de /comp, /layer, /mix, /voice, /tracks, /undo, /solo, /unsolo, /rename, /dup, /bpm, /clear-comp, /preview, /gain, /loop, /swap, /info, /concat, /silence, /template, /marker, /metronome, /delete, /suggest, /snapshot et /randomize vers apps/api/src/ws-commands-compose.ts.
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK, cloture documentaire lot-552 appliquee.
- Prochaine etape: lot-549 tests composition, puis lot-548 waveform/timeline UI.
## Delta Session 2026-03-24 — phase 3
- lot-192-ws-commands-extraction: phases 1-3 executees.
- Bloc compose complet extrait vers apps/api/src/ws-commands-compose.ts, incluant maintenant concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize.
- Validation: diagnostics TS OK, suite @kxkm/api relancee avec ws-commands.test.ts OK.
- Prochaine etape: lot-193 tests composition, puis lot-194 waveform/timeline UI.
- lot-552: synchronisation PLAN/TODO/AGENTS + rapport QA webdesign + veille OSS ciblee sur waveform/tests/orchestration.
- Prochaine etape: lot-549 tests composition, puis lot-194 waveform/timeline UI.
## Delta Session 2026-03-24 — validation compose + waveform
- lot-549-composition-tests est valide.
- Couverture ajoutee sur `apps/api/src/composition-store.test.ts` et `apps/api/src/ws-commands.test.ts` pour rebind composition active, clamps timeline, `/comp load`, `/mix`, `/snapshot`, `/template` et `/delete`.
- Validation lot-549: `../../node_modules/.bin/tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 tests OK.
- lot-548-waveform-ui-v1 est implemente dans `apps/web/src/components/ComposePage.tsx` avec `wavesurfer.js` pour les apercus de pistes et blocs timeline.
- Validation lot-548: `npm run check` dans `apps/web` OK.
- Prochaine etape recommandee: capture visuelle Playwright du compose timeline + nettoyage du doublon `ComposePage 2.tsx`.
## Delta Session 2026-03-24 — cleanup compose
- lot-553-cleanup-compose-doublon execute.
- Suppression du fichier duplique non reference `apps/web/src/components/ComposePage 2.tsx`.
- Validation: `npm run check` dans `apps/web` OK.
- Risque reduit: suppression d'une source de divergence entre UI compose legacy et UI active.
## Delta Session 2026-03-24 — QA visuelle compose
- lot-554-qa-compose-timeline execute.
- Validation Playwright ciblee sur `apps/web/e2e/visual-qa.spec.ts` (grep `ComposePage|composition timeline`).
- Resultat: 2/2 tests OK, captures regenerees `10-compose-page.png` et `18-composition-timeline.png`.
- Conclusion: rendu Compose/timeline stable apres migration waveform + cleanup doublon.
+21 -5
View File
@@ -412,12 +412,28 @@ Fait sur ce lot:
## Delta Session 2026-03-24
- [ ] lot-550 (en cours): lot-192 ws-commands extraction phases 1-2 (compose core + gestion composition) + audit SOTA 2026
- [ ] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
- [ ] lot-549: composition tests renfort (store/remix/export/timeline)
- [x] lot-550: lot-192 ws-commands extraction phases 1-2 (compose core + gestion composition) + audit SOTA 2026
- [x] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
- [x] lot-552: cloture documentaire et durcissement final lot-192
- [ ] lot-549 (en cours): composition tests renfort (store/remix/export/timeline)
## Delta Session 2026-03-24 — phase 3
- [x] lot-551: lot-192 phase 3 extraction edition avancee (concat/silence/template/marker/metronome/delete/suggest/snapshot/randomize)
- [ ] lot-552: cloture documentaire et durcissement final lot-192
- [ ] lot-549: composition tests renfort (store/remix/export/timeline)
- [x] lot-552: cloture documentaire et durcissement final lot-192
- [ ] lot-549 (en cours): composition tests renfort (store/remix/export/timeline)
- [ ] lot-548: waveform timeline UI v1 (desktop + mobile)
## Delta Session 2026-03-24 — validation compose + waveform
- [x] lot-549: composition tests renfort (store/remix/export/timeline)
- [x] lot-548: waveform timeline UI v1 (desktop + mobile)
- [ ] prochain lot suggere: capture QA visuelle compose timeline + nettoyage des doublons UI compose
## Delta Session 2026-03-24 — cleanup compose
- [x] lot-553: cleanup doublon ComposePage non reference
- [x] validation frontend apres cleanup (`npm run check`)
- [ ] prochain lot suggere: passe QA visuelle Playwright sur timeline compose waveform
## Delta Session 2026-03-24 — QA visuelle compose
- [x] lot-554: passe Playwright visuelle ciblee Compose/timeline
- [x] verification captures `10-compose-page.png` et `18-composition-timeline.png`
- [ ] prochain lot suggere: triage et purge artefacts test-results non necessaires au commit
+1 -1
View File
@@ -2,5 +2,5 @@
"nick": "Pharmacius",
"facts": [],
"summary": "",
"lastUpdated": "2026-03-24T16:42:42.979Z"
"lastUpdated": "2026-03-24T17:51:36.369Z"
}
+58
View File
@@ -229,4 +229,62 @@ describe("composition-store", () => {
assert.equal(onDisk.timeline.markers.length, 1);
assert.equal(onDisk.timeline.markers[0].label, "Intro");
});
it("setActiveComposition rebinds nick/channel while preserving timeline", async () => {
const {
createComposition,
addTrack,
setActiveComposition,
getActiveComposition,
getTimeline,
} = await storePromise;
const comp = createComposition("louis", "#old", "Rebind test");
const track = addTrack(comp.id, {
type: "music",
prompt: "slow pulse",
duration: 6,
volume: 55,
startMs: 1200,
});
assert.ok(track);
const rebound = setActiveComposition("marie", "#new", comp.id);
assert.ok(rebound);
assert.equal(rebound!.nick, "marie");
assert.equal(rebound!.channel, "#new");
const active = getActiveComposition("marie", "#new");
assert.ok(active);
assert.equal(active!.id, comp.id);
const timeline = getTimeline(comp.id);
assert.ok(timeline);
assert.equal(timeline!.tracks.length, 1);
assert.equal(timeline!.clips.length, 1);
assert.equal(timeline!.clips[0].trackId, track!.id);
});
it("clamps timeline settings and sorts markers by position", async () => {
const {
createComposition,
updateTimelineSettings,
addTimelineMarker,
listTimelineMarkers,
} = await storePromise;
const comp = createComposition("nina", "#sort", "Clamp test");
const timeline = updateTimelineSettings(comp.id, { bpm: 999, timeSignature: [0, 0] });
assert.ok(timeline);
assert.equal(timeline!.bpm, 300);
assert.deepEqual(timeline!.timeSignature, [1, 1]);
addTimelineMarker(comp.id, { label: "Outro", atMs: 9000 });
addTimelineMarker(comp.id, { label: "Intro", atMs: 1000 });
addTimelineMarker(comp.id, { label: "Middle", atMs: 5000 });
const markers = listTimelineMarkers(comp.id);
assert.deepEqual(markers.map((marker) => marker.label), ["Intro", "Middle", "Outro"]);
assert.deepEqual(markers.map((marker) => marker.atMs), [1000, 5000, 9000]);
});
});
+144 -10
View File
@@ -1,7 +1,10 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createCommandHandler } from "./ws-commands.js";
import { addTrack, createComposition } from "./composition-store.js";
import { addTrack, createComposition, getActiveComposition } from "./composition-store.js";
import type { ClientInfo, ChatPersona, OutboundMessage } from "./chat-types.js";
import type { WebSocket } from "ws";
@@ -22,6 +25,14 @@ function makeInfo(overrides?: Partial<ClientInfo>): ClientInfo {
};
}
function makeComposeInfo(prefix: string): ClientInfo {
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return makeInfo({
nick: `${prefix}-${suffix}`,
channel: `#${prefix.toLowerCase()}-${suffix}`,
});
}
function makeDeps(overrides?: Partial<Record<string, unknown>>) {
return {
send: mock.fn<(ws: WebSocket, msg: OutboundMessage) => void>(),
@@ -388,13 +399,94 @@ describe("/compose", () => {
});
});
/* ------------------------------------------------------------------ */
/* /comp and extracted composition commands */
/* ------------------------------------------------------------------ */
describe("/comp", () => {
it("creates a composition and emits the load payload", async () => {
const info = makeComposeInfo("ComposerNew");
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info, text: "/comp new Session test" });
assert.equal(deps.send.mock.callCount(), 2);
const createdMsg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
const loadedMsg = deps.send.mock.calls[1].arguments[1] as { type: string; text: string };
assert.equal(createdMsg.type, "system");
assert.ok(createdMsg.text.includes("Composition creee"));
assert.ok(createdMsg.text.includes("Session test"));
assert.ok(loadedMsg.text.startsWith("__comp_loaded__"));
const match = createdMsg.text.match(/\((comp_[^)]+)\)/);
assert.ok(match, "should expose created composition id");
rmSync(path.join(process.cwd(), "data", "compositions", match![1]), { recursive: true, force: true });
});
it("loads an existing composition by id", async () => {
const info = makeComposeInfo("ComposerLoad");
const comp = createComposition("OtherNick", "#other", "Existing comp");
const deps = makeDeps();
const handle = createCommandHandler(deps);
try {
await handle({ ws: fakeWs, info, text: `/comp load ${comp.id}` });
assert.equal(deps.send.mock.callCount(), 2);
const statusMsg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
const loadedMsg = deps.send.mock.calls[1].arguments[1] as { type: string; text: string };
assert.equal(statusMsg.type, "system");
assert.ok(statusMsg.text.includes("Composition chargee"));
assert.ok(statusMsg.text.includes("Existing comp"));
assert.ok(loadedMsg.text.startsWith("__comp_loaded__"));
} finally {
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
}
});
});
/* ------------------------------------------------------------------ */
/* composition management routing */
/* ------------------------------------------------------------------ */
describe("/mix", () => {
it("mixes a single available track and exposes download paths", async () => {
const info = makeComposeInfo("ComposerMix");
const comp = createComposition(info.nick, info.channel, "Mix test");
const tempDir = mkdtempSync(path.join(os.tmpdir(), "kxkm-mix-"));
const sourcePath = path.join(tempDir, "track.wav");
writeFileSync(sourcePath, Buffer.from("RIFFtest-wave"));
const track = addTrack(comp.id, { type: "music", prompt: "drone test", duration: 7, volume: 80, startMs: 0 });
assert.ok(track);
track!.filePath = sourcePath;
const deps = makeDeps();
const handle = createCommandHandler(deps);
try {
await handle({ ws: fakeWs, info, text: "/mix" });
const broadcastMusic = deps.broadcast.mock.calls.find((call) => {
const msg = call.arguments[1] as { type?: string };
return msg.type === "music";
});
assert.ok(broadcastMusic, "should broadcast mixed audio");
const systemMsg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
assert.equal(systemMsg.type, "system");
assert.ok(systemMsg.text.includes(`/api/v2/media/compositions/${comp.id}/mix`));
assert.ok(existsSync(path.join(process.cwd(), "data", "compositions", comp.id, "mix.wav")));
} finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
}
});
});
describe("/rename", () => {
it("renames the active composition through the extracted compose handler", async () => {
const info = makeInfo({ nick: "ComposerRename", channel: "#compose-rename" });
const info = makeComposeInfo("ComposerRename");
createComposition(info.nick, info.channel, "Ancien nom");
const deps = makeDeps();
@@ -409,7 +501,7 @@ describe("/rename", () => {
describe("/tracks", () => {
it("lists tracks through the extracted compose management handler", async () => {
const info = makeInfo({ nick: "ComposerTracks", channel: "#compose-tracks" });
const info = makeComposeInfo("ComposerTracks");
const comp = createComposition(info.nick, info.channel, "Track test");
addTrack(comp.id, { type: "music", prompt: "drone test", duration: 12, volume: 80, startMs: 0 });
@@ -426,10 +518,12 @@ describe("/tracks", () => {
describe("/delete", () => {
it("deletes a track through the extracted compose advanced handler", async () => {
const info = makeInfo({ nick: "ComposerDelete", channel: "#compose-delete" });
const comp = createComposition(info.nick, info.channel, "Delete test");
addTrack(comp.id, { type: "music", prompt: "first track", duration: 8, volume: 100, startMs: 0 });
addTrack(comp.id, { type: "music", prompt: "second track", duration: 9, volume: 100, startMs: 0 });
const info = makeComposeInfo("ComposerDelete");
createComposition(info.nick, info.channel, "Delete test");
let activeComp = getActiveComposition(info.nick, info.channel);
assert.ok(activeComp);
addTrack(activeComp!.id, { type: "music", prompt: "first track", duration: 8, volume: 100, startMs: 0 });
addTrack(activeComp!.id, { type: "music", prompt: "second track", duration: 9, volume: 100, startMs: 0 });
const deps = makeDeps();
const handle = createCommandHandler(deps);
@@ -438,14 +532,16 @@ describe("/delete", () => {
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes("supprimee"));
assert.equal(comp.tracks.length, 1);
assert.ok(comp.tracks[0].prompt.includes("second track"));
activeComp = getActiveComposition(info.nick, info.channel);
assert.ok(activeComp);
assert.equal(activeComp!.tracks.length, 1);
assert.ok(activeComp!.tracks[0].prompt.includes("second track"));
});
});
describe("/marker", () => {
it("adds and lists markers through the extracted compose advanced handler", async () => {
const info = makeInfo({ nick: "ComposerMarker", channel: "#compose-marker" });
const info = makeComposeInfo("ComposerMarker");
createComposition(info.nick, info.channel, "Marker test");
const deps = makeDeps();
@@ -459,3 +555,41 @@ describe("/marker", () => {
assert.ok(lastMsg.text.includes("12s"));
});
});
describe("/snapshot", () => {
it("writes a snapshot file for the active composition", async () => {
const info = makeComposeInfo("ComposerSnapshot");
const comp = createComposition(info.nick, info.channel, "Snapshot test");
addTrack(comp.id, { type: "music", prompt: "frozen pulse", duration: 11, volume: 70, startMs: 0 });
const deps = makeDeps();
const handle = createCommandHandler(deps);
const snapshotPath = path.join(process.cwd(), "data", "compositions", comp.id, "snapshots", "v1.json");
try {
await handle({ ws: fakeWs, info, text: "/snapshot v1" });
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes('Snapshot "v1" sauvegarde'));
assert.ok(existsSync(snapshotPath));
} finally {
rmSync(path.join(process.cwd(), "data", "compositions", comp.id), { recursive: true, force: true });
}
});
});
describe("/template", () => {
it("lists available templates when the requested one is unknown", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/template inconnu" });
const msg = deps.send.mock.calls.at(-1)?.arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes("Templates:"));
assert.ok(msg.text.includes("ambient-4"));
assert.ok(msg.text.includes("Usage: /template <nom>"));
});
});
-291
View File
@@ -1,291 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { VideotexPageHeader, VideotexSeparator } from "./VideotexMosaic";
interface Track {
id: number;
prompt: string;
style: string;
duration: number;
volume: number;
type: "music" | "voice" | "sfx";
audioData?: string;
audioMime?: string;
}
export default function ComposePage() {
const [prompt, setPrompt] = useState("");
const [style, setStyle] = useState("experimental");
const [duration, setDuration] = useState(30);
const [tracks, setTracks] = useState<Track[]>([]);
const [mixing, setMixing] = useState(false);
const [generating, setGenerating] = useState(false);
const [status, setStatus] = useState("");
const [compName, setCompName] = useState("Ma composition");
const wsRef = useRef<WebSocket | null>(null);
// Connect to WebSocket
useEffect(() => {
const nick = sessionStorage.getItem("kxkm-nick") || "composer";
const wsUrl = (location.protocol === "https:" ? "wss:" : "ws:") + "//" + location.host + "/ws?nick=" + encodeURIComponent(nick);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
try {
const m = JSON.parse(event.data);
// Capture music results (from /layer, /noise, /mix)
if (m.type === "music" && m.audioData) {
setTracks(prev => [...prev, {
id: Date.now() + Math.random(),
prompt: m.text || "Sans titre",
style,
duration,
volume: 100,
type: (m.text || "").includes("Noise") ? "sfx" : (m.text || "").includes("Mix") ? "music" : "music",
audioData: m.audioData,
audioMime: m.audioMime || "audio/wav",
}]);
setGenerating(false);
setStatus("");
}
// Capture voice results (from /voice)
if (m.type === "audio" && m.data) {
setTracks(prev => [...prev, {
id: Date.now() + Math.random(),
prompt: m.nick ? m.nick + " (voix)" : "Voix",
style: "voice",
duration: 10,
volume: 100,
type: "voice",
audioData: m.data,
audioMime: m.mimeType || "audio/wav",
}]);
setGenerating(false);
setStatus("");
}
// Status messages
if (m.type === "system" && m.text) {
const t = m.text;
if (t.includes("ajoute une piste") || t.includes("Generation") || t.includes("generation") || t.includes("Mixage") || t.includes("compose")) {
setStatus(t.slice(0, 100));
}
if (t.includes("Erreur") || t.includes("echouee")) {
setGenerating(false);
setStatus("Erreur: " + t.slice(0, 80));
}
if (t.includes("Mix termine")) {
setMixing(false);
setStatus("Mix termine!");
}
}
} catch {}
};
return () => { ws.close(); };
}, []);
function sendCmd(cmd: string) {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "command", text: cmd }));
}
}
function handleAddTrack(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || generating) return;
setGenerating(true);
setStatus("Generation en cours...");
sendCmd("/layer " + prompt.trim() + ", " + style + " style, " + duration + "s");
}
function handleAddVoice() {
if (!prompt.trim() || generating) return;
setGenerating(true);
setStatus("Voix en cours...");
sendCmd("/voice Pharmacius \"" + prompt.trim() + "\"");
}
function handleAddNoise(type: string) {
setGenerating(true);
setStatus("Noise " + type + "...");
sendCmd("/noise " + type + " " + duration);
}
function handleMix() {
setMixing(true);
setStatus("Mixage...");
sendCmd("/mix");
}
function handleNewComp() {
sendCmd("/comp new " + compName);
setTracks([]);
setStatus("Nouvelle composition");
}
const maxDuration = Math.max(30, ...tracks.map(t => t.duration));
return (
<div className="compose-page">
<VideotexPageHeader title="COMPOSE" subtitle="Studio multi-pistes" color="magenta" />
{/* Header */}
<div className="compose-comp-header">
<input type="text" value={compName} onChange={e => setCompName(e.target.value)} className="minitel-input" placeholder="Nom" style={{flex:1}} />
<button className="minitel-nav-btn" onClick={handleNewComp}>Nouvelle</button>
<span className="compose-track-count">{tracks.length} piste{tracks.length !== 1 ? "s" : ""}</span>
</div>
{status && <div className="compose-status">{status}</div>}
<VideotexSeparator color="magenta" />
{/* Generator */}
<form onSubmit={handleAddTrack} className="compose-form">
<div className="compose-header">{"> AJOUTER UNE PISTE"}</div>
<textarea value={prompt} onChange={e => setPrompt(e.target.value)} placeholder="dark ambient drone, musique concrete..." className="minitel-input compose-textarea" rows={2} maxLength={500} />
<div className="compose-options">
<select value={style} onChange={e => setStyle(e.target.value)} className="minitel-input" style={{flex:1}}>
<optgroup label="Electronique">
<option value="experimental">Experimental</option>
<option value="ambient">Ambient</option>
<option value="drone">Drone</option>
<option value="noise">Noise</option>
<option value="glitch">Glitch</option>
<option value="industrial">Industrial</option>
<option value="techno">Techno</option>
<option value="minimal">Minimal</option>
<option value="synthwave">Synthwave</option>
</optgroup>
<optgroup label="Concrete / Acoustique">
<option value="concrete">Musique concrete</option>
<option value="electroacoustique">Electroacoustique</option>
<option value="acousmatic">Acousmatique</option>
<option value="field-recording">Field recording</option>
</optgroup>
<optgroup label="Jazz / Classique">
<option value="jazz">Jazz</option>
<option value="free-jazz">Free jazz</option>
<option value="classical">Classique</option>
<option value="cinematic">Cinematique</option>
</optgroup>
<optgroup label="Rock / Urbain">
<option value="post-rock">Post-rock</option>
<option value="metal">Metal</option>
<option value="hip-hop">Hip-hop</option>
<option value="lo-fi">Lo-fi</option>
<option value="trap">Trap</option>
</optgroup>
<optgroup label="World / Dark">
<option value="folk">Folk</option>
<option value="world">World</option>
<option value="dark">Dark ambient</option>
</optgroup>
</select>
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="minitel-input" style={{width:70}}>
<option value={10}>10s</option>
<option value={30}>30s</option>
<option value={60}>60s</option>
<option value={120}>2min</option>
</select>
</div>
<div className="compose-btn-row">
<span className="compose-btn-label">Generer:</span>
<button type="submit" className="minitel-nav-btn" disabled={generating || !prompt.trim()}>Musique</button>
<button type="button" className="minitel-nav-btn" onClick={handleAddVoice} disabled={generating || !prompt.trim()}>Voix</button>
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("drone")} disabled={generating}>Drone</button>
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("pink")} disabled={generating}>Pink</button>
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("white")} disabled={generating}>White</button>
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("sine")} disabled={generating}>Sine</button>
<button type="button" className="minitel-nav-btn" onClick={() => handleAddNoise("brown")} disabled={generating}>Brown</button>
</div>
{tracks.length > 0 && (
<div className="compose-btn-row">
<span className="compose-btn-label">Edition:</span>
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/undo")}>Undo</button>
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/tracks")}>Tracks</button>
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} reverse`); }}>Reverse</button>
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} reverb`); }}>Reverb</button>
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} echo`); }}>Echo</button>
<button type="button" className="minitel-nav-btn" onClick={() => { const n = prompt.trim() || "1"; sendCmd(`/fx ${n} distortion`); }}>Distort</button>
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd(`/stutter ${tracks.length} 8`)}>Stutter</button>
</div>
)}
{tracks.length > 1 && (
<div className="compose-btn-row">
<span className="compose-btn-label">Sortie:</span>
<button type="button" className="minitel-login-btn" onClick={handleMix} disabled={mixing}>
{mixing ? "Mixage..." : `Mix ${tracks.length}p`}
</button>
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/master")}>Master</button>
<button type="button" className="minitel-nav-btn" onClick={() => sendCmd("/export")}>Export</button>
</div>
)}
</form>
{/* Timeline */}
{tracks.length > 0 && (
<>
<VideotexSeparator color="yellow" />
<div className="compose-header">{"> TIMELINE"}</div>
<div className="compose-timeline">
<div className="timeline-ruler">
{Array.from({ length: Math.ceil(maxDuration / 5) + 1 }, (_, i) => (
<span key={i} className="timeline-tick" style={{ left: (i * 5 / maxDuration) * 100 + "%" }}>{i * 5}s</span>
))}
</div>
{tracks.map((track, i) => {
const icon = track.type === "voice" ? "V" : track.type === "sfx" ? "N" : "M";
const colors = ["#c84c0c", "#2c6e49", "#7c3aed", "#0f766e", "#b45309", "#1d4ed8"];
return (
<div key={track.id} className="timeline-lane">
<span className="timeline-label">{icon}{i + 1}</span>
<div className="timeline-track-area">
<div className="timeline-block" style={{
width: Math.max((track.duration / maxDuration) * 100, 8) + "%",
backgroundColor: colors[i % colors.length],
opacity: track.volume / 100,
}} title={track.prompt + " (" + track.duration + "s)"}>
<span className="timeline-block-text">{track.prompt.slice(0, 25)}</span>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Tracks detail */}
{tracks.length > 0 && (
<>
<VideotexSeparator color="cyan" />
<div className="compose-header">{"> PISTES"}</div>
{tracks.map((track, i) => (
<div key={track.id} className="compose-track">
<span className="compose-track-num">#{i + 1}</span>
<span className="compose-track-prompt">{track.prompt.slice(0, 30)}</span>
<input type="range" min={0} max={100} value={track.volume} onChange={e => setTracks(prev => prev.map((t, j) => j === i ? { ...t, volume: +e.target.value } : t))} className="compose-volume" title={`Vol: ${track.volume}%`} />
<div className="compose-track-actions">
<button className="compose-track-btn" onClick={() => sendCmd(`/solo ${i+1}`)} title="Solo">S</button>
<button className="compose-track-btn" onClick={() => sendCmd(`/loop ${i+1} 2`)} title="Loop x2">{"\u27f3"}</button>
<button className="compose-track-btn" onClick={() => sendCmd(`/fx ${i+1} reverse`)} title="Reverse">{"\u21c6"}</button>
</div>
{track.audioData && <audio controls src={`data:${track.audioMime};base64,${track.audioData}`} className="compose-audio" />}
<button className="compose-track-del" onClick={() => setTracks(prev => prev.filter((_, j) => j !== i))}>X</button>
</div>
))}
</>
)}
{/* Status Bar */}
<div className="compose-status-bar">
<span>{compName} | {tracks.length}p | {tracks.reduce((s, t) => s + t.duration, 0)}s total</span>
{status && <span className="compose-status-msg">{status}</span>}
</div>
</div>
);
}
+75 -23
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import WaveSurfer from "wavesurfer.js";
import { VideotexPageHeader } from "./VideotexMosaic";
interface Track {
@@ -43,32 +44,80 @@ const NOISE_TYPES = ["drone", "pink", "white", "brown", "sine"];
const STYLES = ["experimental", "ambient", "drone", "noise", "glitch", "industrial", "techno", "minimal", "concrete", "jazz", "classical", "dark", "lo-fi", "post-rock"];
const PERSONAS = ["Pharmacius", "Docteur Maboul", "Gargantua", "Nostradamus", "Piaf"];
function drawWaveform(canvas: HTMLCanvasElement, audioData: string, _audioMime: string, color: string) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Decode base64 audio to get raw samples (simplified: use byte visualization from data)
const bytes = atob(audioData.slice(0, 2000)); // sample first 2KB
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < w; i++) {
const byteIdx = Math.floor((i / w) * bytes.length);
const val = (bytes.charCodeAt(byteIdx % bytes.length) / 255) * h;
if (i === 0) ctx.moveTo(i, val); else ctx.lineTo(i, val);
}
ctx.stroke();
function toAudioDataUrl(audioData: string, audioMime: string) {
return `data:${audioMime};base64,${audioData}`;
}
function WaveformCanvas({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
function WaveformPreview({
audioData,
audioMime,
color,
className,
height,
barWidth,
}: {
audioData: string;
audioMime: string;
color: string;
className: string;
height: number;
barWidth?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (canvasRef.current && audioData) {
drawWaveform(canvasRef.current, audioData, audioMime, color);
if (!containerRef.current || !audioData) return;
const wave = WaveSurfer.create({
container: containerRef.current,
waveColor: `${color}55`,
progressColor: color,
cursorColor: "rgba(255,255,255,0.18)",
height,
normalize: true,
interact: false,
hideScrollbar: true,
dragToSeek: false,
barWidth,
barGap: 1,
barRadius: 2,
cursorWidth: 0,
});
wave.load(toAudioDataUrl(audioData, audioMime));
return () => {
wave.destroy();
};
}, [audioData, audioMime, barWidth, color, height]);
return <div ref={containerRef} className={className} aria-hidden="true" />;
}
}, [audioData, audioMime, color]);
return <canvas ref={canvasRef} width={120} height={30} className="cmp-waveform" />;
function TrackWaveform({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
return (
<WaveformPreview
audioData={audioData}
audioMime={audioMime}
color={color}
className="cmp-waveform cmp-waveform-preview"
height={30}
barWidth={2}
/>
);
}
function TimelineWaveform({ audioData, audioMime, color }: { audioData: string; audioMime: string; color: string }) {
return (
<WaveformPreview
audioData={audioData}
audioMime={audioMime}
color={color}
className="cmp-tl-waveform"
height={18}
barWidth={1}
/>
);
}
export default function ComposePage() {
@@ -457,7 +506,7 @@ export default function ComposePage() {
{track.generating ? `${track.genElapsed || 0}s` : "\u25B6"}
</button>
{track.audioData && track.audioMime && (
<WaveformCanvas audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
<TrackWaveform audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
)}
</div>
@@ -518,6 +567,9 @@ export default function ComposePage() {
onPointerDown={e => startDrag(e, i)}
onContextMenu={e => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, trackIdx: i }); }}
>
{track.audioData && track.audioMime && (
<TimelineWaveform audioData={track.audioData} audioMime={track.audioMime} color={track.color} />
)}
<span className="cmp-tl-text">{track.prompt?.slice(0, 20) || track.label}</span>
<span className="cmp-tl-dur">{track.duration}s</span>
{/* Resize handle */}
+29 -3
View File
@@ -3258,7 +3258,19 @@ code {
}
.cmp-gen-btn:hover { filter: brightness(1.2); }
.cmp-gen-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.cmp-waveform { width: 120px; height: 30px; border-radius: 3px; background: rgba(0,0,0,0.3); }
.cmp-waveform {
width: 120px;
height: 30px;
border-radius: 3px;
background: linear-gradient(180deg, rgba(0,0,0,0.34), rgba(255,255,255,0.03));
border: 1px solid rgba(255,255,255,0.08);
overflow: hidden;
}
.cmp-waveform-preview { flex-shrink: 0; }
.cmp-waveform ::part(wrapper),
.cmp-tl-waveform ::part(wrapper) {
background: transparent;
}
.cmp-track-timeline {
padding: 4px 8px 6px;
@@ -3357,15 +3369,29 @@ code {
transition: box-shadow 0.15s;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
}
.cmp-tl-block:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.4); border-color: rgba(255,255,255,0.2); }
.cmp-tl-dragging { cursor: grabbing; opacity: 0.7 !important; box-shadow: 0 2px 8px rgba(0,0,0,0.6); z-index: 5; }
.cmp-tl-text { font-size: 8px; color: rgba(255,255,255,0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.cmp-tl-dur { font-size: 7px; color: rgba(255,255,255,0.5); flex-shrink: 0; margin-left: 4px; }
.cmp-tl-waveform {
position: absolute;
inset: 0;
opacity: 0.55;
pointer-events: none;
}
.cmp-tl-text,
.cmp-tl-dur {
position: relative;
z-index: 1;
text-shadow: 0 1px 2px rgba(0,0,0,0.7);
}
.cmp-tl-text { font-size: 8px; color: rgba(255,255,255,0.88); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.cmp-tl-dur { font-size: 7px; color: rgba(255,255,255,0.7); flex-shrink: 0; margin-left: 4px; }
.cmp-tl-resize {
position: absolute;
right: 0; top: 0; width: 5px; height: 100%;
cursor: ew-resize;
z-index: 2;
}
.cmp-tl-resize:hover { background: rgba(255,255,255,0.15); }
File diff suppressed because one or more lines are too long
+41 -3
View File
@@ -256,12 +256,50 @@ stateDiagram-v2
### Coordinateur
- [x] Audit SOTA 2026 intelligence + affectation agents par module
- [x] lot-552: cloture documentaire lot-192 + sync QA/veille OSS
### Backend API
- [x] lot-192: ws-commands modular extraction phases 1-2 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition)
- [ ] lot-192: phase 3 extraction edition avancee (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
- [x] lot-192: ws-commands modular extraction phases 1-3 (`/comp`, `/layer`, `/mix`, `/voice`, gestion composition, edition avancee)
- [ ] lot-549: renfort tests composition et export
## Delta Session 2026-03-24 — phase 3
### Backend API
- [x] lot-192: extraction complete du bloc compose avance (`/concat`, `/silence`, `/template`, `/marker`, `/metronome`, `/delete`, `/suggest`, `/snapshot`, `/randomize`)
- [ ] lot-193: renfort tests composition et export
- [x] lot-552: cloture documentaire et durcissement final lot-192
- [ ] lot-549: renfort tests composition et export
- [ ] lot-548: waveform timeline UI v1
## Affectations Session 2026-03-24 — suite immediate
| Lot | Agent pilote | Sous-agents / competences | Portee immediate |
|---|---|---|---|
| lot-549 | Backend API | Polyglot Test Agent, QA, context-map | Etendre `apps/api/src/ws-commands.test.ts` et `apps/api/src/composition-store.test.ts`, verifier non-regression composition |
| lot-548 | Frontend | Expert React Frontend Engineer, gem-browser-tester, context-map | Remplacer la waveform canvas par une timeline waveform plus lisible sans regression mobile |
| lot-552 | Coordinateur | Planner/Docs, gem-researcher, refactor-plan | Maintenir PLAN/TODO/AGENTS/QA/OSS alignes avec l'etat reel et tracer les prochains deltas |
## Skills privilegies
- context-map: cartographie des fichiers et dependances avant modification.
- refactor-plan: sequencing prudent des changements multi-fichiers.
- polyglot-test-agent: lot-549 pour la recherche, le plan et l'implementation de tests additionnels.
## Delta Session 2026-03-24 — validation compose + waveform
### Backend API
- [x] lot-549: renfort tests composition et export
- Validation ciblee: `tsx --test src/composition-store.test.ts src/ws-commands.test.ts` => 44/44 OK
### Frontend
- [x] lot-548: waveform timeline UI v1
- Implementation: `wavesurfer.js` branche dans `apps/web/src/components/ComposePage.tsx` pour apercu piste + bloc timeline
- Validation: `npm run check` dans `apps/web` OK
## Delta Session 2026-03-24 — cleanup compose
### Frontend
- [x] lot-553: suppression du doublon `apps/web/src/components/ComposePage 2.tsx`
- Validation: `npm run check` dans `apps/web` OK
- Impact: reduction dette technique UI compose et elimination d'un faux point d'entree potentiel
## Delta Session 2026-03-24 — QA visuelle compose
### Frontend
- [x] lot-554: validation Playwright ciblee compose timeline
- Commande: `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
- Resultat: 2/2 OK
+44
View File
@@ -109,3 +109,47 @@
| P1 | Prototyper CSS Minitel (Terminal CSS + VIDEOTEX colors + CRT effects) | Lot 16 |
| P2 | Tester Ollama web search natif vs SearXNG pipeline | RAG |
| P3 | Etudier AstrBot persona system pour inspiration | Personas |
## Addendum 2026-03-24 -- waveform, tests composition, orchestration agents
### Waveform / timeline UI
- waveform-playlist
- Usage cible: lot-548.
- Interet: vraie timeline multi-pistes, edition clips, waveform et transport plus proches d'un mini-DAW que notre canvas actuel.
- Licence: MIT.
- Recommandation: candidat principal si on accepte un changement UI plus structurant.
- wavesurfer.js
- Usage cible: lot-548.
- Interet: integration plus chirurgicale si on veut conserver la structure actuelle de ComposePage et remplacer seulement le rendu waveform/ruler/regions.
- Licence: BSD-3-Clause.
- Recommandation: meilleur plan B a faible delta.
### Tests composition
- fast-check
- Usage cible: lot-549.
- Interet: tests de proprietes sur les invariants du timeline store (offsets, durees, trims, serialisation).
- Licence: MIT.
- Recommandation: excellent levier pour durcir composition-store au-dela des seuls cas scenario.
- MSW
- Usage cible: lot-549.
- Interet: unifier les mocks HTTP front pour compose/export/suggest sans stub fragile de fetch.
- Licence: MIT.
- Recommandation: a introduire si les tests frontend composition deviennent prioritaires au lot suivant.
### Orchestration agents / continuite operationnelle
- XState
- Usage cible: orchestration Compose + suivi des lots.
- Interet: formaliser les workflows multi-etats (generation, mix, export, erreur, reprise) et documenter les transitions.
- Licence: MIT.
- Recommandation: meilleur candidat court terme pour rendre les flux agents/tests/docs plus explicites sans surdimensionner l'architecture.
- LangGraph.js
- Usage cible: orchestration agents stateful plus tardive.
- Interet: pertinent si le projet fait evoluer la coordination documentaire vers un vrai runtime de graphe agentique persistant.
- Licence: MIT.
- Recommandation: garder en reserve, trop lourd pour le prochain lot immediat.
+86
View File
@@ -0,0 +1,86 @@
# QA Webdesign 2026-03-24
## Portee
- Audit visuel et graphique du frontend public sur la cible de production.
- Validation du rendu desktop, mobile, theming, lisibilite, composants medias et timeline composition.
- Verification des regressions apres corrections CSS et commande /theme.
## Methode
- Suite Playwright visuelle dediee: 36 tests automatises.
- Captures d'ecran systematiques sur ecrans clefs et breakpoints mobiles.
- Relecture manuelle des captures pour validation du rendu, du contraste et de la densite UI.
## Couverture
- Connexion et ecran d'accueil.
- Chat principal et messages medias.
- Themes minitel, noir, matrix, amber, ocean, dark, light.
- Navigation Compose, DAW, Voice, pages principales.
- Responsive jusqu'a 320 px.
- Accessibilite graphique basique: contraste, debordements, zones tactiles.
## Resultats
- 36 tests passes.
- 0 echec.
- 30 captures d'ecran generees et verifiees.
- Rendu general valide sur desktop et mobile.
## Defauts identifies puis corriges
1. Commande /theme incomplete
Les themes dark et light existaient en CSS mais n'etaient pas exposes par le handler de commande cote API.
Impact:
Le theme demande par l'utilisateur etait refuse silencieusement, ce qui biaisait la validation visuelle.
Statut:
Corrige.
2. Barre fkeys trop compressee a 320 px
Ajout d'un breakpoint specifique <= 340 px avec defilement horizontal et protection contre le shrink excessif.
Impact:
Lisibilite faible sur tres petit viewport.
Statut:
Corrige.
## Observations de rendu
- L'identite CRT/Minitel reste forte et coherente.
- Le chat principal reste lisible a 375 px.
- La timeline composition est exploitable mais la waveform reste encore simplifiee par canvas.
- La page DAW presente deja une bonne hierarchie visuelle pour preparer lot-548.
## Validation technique associee
- Verification TypeScript API sans erreur apres correctifs.
- Verification des captures sur themes clairs et sombres.
- Confirmation visuelle que le fond n'est plus bloque sur un theme unique.
## Conclusion
- La QA webdesign est validee pour la production courante.
- Les correctifs de theming et de responsive ultra-compact sont integres.
- Le prochain chantier prioritaire cote rendu reste lot-548: waveform timeline UI v1.
## Addendum 2026-03-24 — Compose timeline ciblé
### Scope
- Re-validation visuelle post-lot-548/lot-553 sur la page Compose et la timeline multi-pistes.
### Commande
- `npx playwright test e2e/visual-qa.spec.ts --project=chromium --grep "ComposePage|composition timeline"`
### Resultat
- 2 tests executes, 2 passes.
- Captures regenerees:
- `10-compose-page.png`
- `18-composition-timeline.png`
### Conclusion
- Aucun regressif visuel detecte sur le rendu Compose/timeline apres integration wavesurfer et suppression du doublon ComposePage.