chore: align local snapshot for kxkm-ai sync

This commit is contained in:
L'électron rare
2026-03-24 17:14:02 +01:00
parent afe70ba7a4
commit f015455b20
28 changed files with 7439 additions and 50 deletions
+21 -11
View File
@@ -67,7 +67,7 @@ flowchart TD
OSSAgent --> |hf| OSSHF[HuggingFace models]
```
## Todo agents (lot 17+ — mis a jour 2026-03-20)
## Todo agents (lot 17+ — mis a jour 2026-03-24)
### Coordinateur
@@ -75,8 +75,8 @@ flowchart TD
- [x] Synchroniser FEATURE_MAP.md matrice
- [x] Mettre a jour TODO.md avec backlog Phase session 2026-03-19/20
- [x] Documenter actions dans ops/v2/logs/
- [ ] lot-95: Coordonner E2E Playwright test plan
- [ ] lot-100: Design public demo mode access control
- [x] lot-95: Coordonner E2E Playwright test plan
- [x] lot-100: Design public demo mode access control
### Backend API
@@ -93,8 +93,8 @@ flowchart TD
- [x] /speed command for latency diagnostics
- [ ] lot-178: ACE-Step API direct integration (duration fix)
- [ ] lot-180: Timeline data model
- [ ] lot-97: Multi-channel support (create/join channels)
- [ ] lot-100: Public demo mode read-only routes
- [x] lot-97: Multi-channel support (create/join channels)
- [x] lot-100: Public demo mode read-only routes
### Node Engine
@@ -113,8 +113,18 @@ flowchart TD
- [ ] lot-181: TTS voiceover mix into timeline
- [ ] lot-182: Audio effects pipeline (reverb, delay, EQ, compression)
- [ ] lot-183: DAW export (stems, markers, project file)
- [ ] lot-187: Auto-mastering (loudness normalization, limiting)
- [ ] lot-188: Stem separation (vocals, drums, bass, other)
- [x] lot-184: Multi-track composition (/layer, composition-store)
- [x] lot-185: Composition UI (track lanes, play/pause/seek)
- [x] lot-186: Arrangement tools (/comp structure, section markers)
- [x] lot-187: Auto-mastering (/mix master, loudness normalization, limiter)
- [x] lot-188: /voice TTS voiceover injected into composition
- [x] lot-189: /noise 5 types (white, pink, brown, rain, wind)
- [x] lot-190: /fx 9 audio effects (reverb, delay, chorus, flanger, distortion, bitcrusher, EQ, compressor, tremolo)
- [x] lot-191: /ambient scene generator (forest, ocean, city, space, cave)
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [ ] lot-195: /remix re-generate specific track
- [ ] lot-199: Stem separation (Demucs v4 htdemucs, 6-stem, MIT)
- [x] lot-200: Full DAW export (WAV stems + JSON project)
### Personas
@@ -138,10 +148,10 @@ flowchart TD
- [x] 5 CSS themes (minitel, crt, hacker, synthwave, default)
- [x] Mobile responsive pass (touch, bottom nav, viewport units)
- [x] Guest mode read-only UI
- [ ] lot-185: Composition timeline UI (waveform view, track lanes)
- [ ] lot-194: FX rack UI (drag-drop effects chain)
- [ ] lot-95: E2E Playwright tests (login, chat, upload, admin)
- [ ] lot-98: File sharing UI (upload → gallery)
- [x] lot-185: Composition timeline UI (waveform view, track lanes)
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [x] lot-95: E2E Playwright tests (login, chat, upload, admin)
- [x] lot-98: File sharing UI (upload → gallery)
### Ops/TUI
+66 -13
View File
@@ -1,10 +1,10 @@
# PLAN.md — KXKM_Clown
Updated: 2026-03-21T12:00:00Z
Updated: 2026-03-24T19:30:00Z
## Summary
- **535+ lots executed** in sessions 2026-03-19/20/21/24
- **544+ lots executed** in sessions 2026-03-19/20/21/24
- **425 tests**, 0 fail
- **112+ commands**, 14 services, 9 spec docs, 4 machines deployed
- **All SEC-01-05 resolved**
@@ -496,37 +496,37 @@ Updated: 2026-03-20T12:00:00Z
- Description: health-check.sh bash TUI with 19 service checks
- Summary: Colored output, pass/fail per service, summary line.
## lot-95-e2e-playwright [planned]
## lot-95-e2e-playwright [done]
- Description: End-to-end tests with Playwright (login, chat, upload, admin)
- Owner: Frontend + Backend API
- Priority: P1
## lot-96-persona-dpo-automation [planned]
## lot-96-persona-dpo-automation [done]
- Description: Automated DPO pipeline (feedback collection, pair generation, training trigger)
- Owner: Training
- Priority: P2
## lot-97-multi-channel [planned]
## lot-97-multi-channel [done]
- Description: Multi-channel support (create/join custom channels, per-channel personas)
- Owner: Backend API + Frontend
- Priority: P2
## lot-98-file-sharing [planned]
## lot-98-file-sharing [done]
- Description: File sharing between users (upload to shared gallery, download links)
- Owner: Backend API + Frontend
- Priority: P3
## lot-99-mobile-responsive [planned]
## lot-99-mobile-responsive [done]
- Description: Mobile responsive deep pass (touch gestures, bottom nav, viewport units)
- Owner: Frontend
- Priority: P2
## lot-100-public-demo [planned]
## lot-100-public-demo [done]
- Description: Public demo mode (read-only guest access, rate-limited, no admin)
- Owner: Backend API + Frontend
@@ -916,7 +916,7 @@ Updated: 2026-03-20T12:00:00Z
### Objectif
- Executer un lot de fiabilisation en 4 commits atomiques, testables et rollbackables.
### lot-541-regex-intent-fix [planned]
### lot-541-regex-intent-fix [done]
- Description: Corriger l extraction des prompts auto image/musique dans le routeur conversation.
- Owner: Backend API
- Fichiers cibles: apps/api/src/ws-conversation-router.ts, apps/api/src/ws-conversation-router.test.ts
@@ -924,7 +924,7 @@ Updated: 2026-03-20T12:00:00Z
- Checks: npm run -w @kxkm/api test
- Done criteria: prompts correctement extraits pour /imagine et /compose depuis langage naturel.
### lot-542-audio-eventloop-unblock [planned]
### lot-542-audio-eventloop-unblock [done]
- Description: Sortir les traitements ffmpeg bloquants du chemin synchrone WS.
- Owner: Backend API + Multimodal
- Fichiers cibles: apps/api/src/ws-commands-generate.ts
@@ -932,7 +932,7 @@ Updated: 2026-03-20T12:00:00Z
- Checks: npm run -w @kxkm/api test, smoke WS compose/mix
- Done criteria: pas de blocage event-loop sur mix/export, latence WS stable sous charge.
### lot-543-worker-runtime-unification [planned]
### lot-543-worker-runtime-unification [done]
- Description: Unifier l execution worker autour de worker-runtime pour eviter la divergence index/runtime.
- Owner: Node Engine
- Fichiers cibles: apps/worker/src/index.ts, apps/worker/src/worker-runtime.ts, apps/worker/src/worker-runtime.test.ts
@@ -940,7 +940,7 @@ Updated: 2026-03-20T12:00:00Z
- Checks: npm run -w @kxkm/worker test
- Done criteria: une seule source de verite pour le cycle run/dequeue/execute.
### lot-544-chat-render-budget [planned]
### lot-544-chat-render-budget [done]
- Description: Reduire le cout render de Chat.tsx pour les longues conversations.
- Owner: Frontend
- Fichiers cibles: apps/web/src/components/Chat.tsx
@@ -964,4 +964,57 @@ Updated: 2026-03-20T12:00:00Z
- R-INTENT-REGEX: extraction de prompt incorrecte
- R-AUDIO-BLOCKING: blocage event-loop API
- R-WORKER-DRIFT: divergence entre runtime et index
- R-CHAT-RENDER: degradation perf UI sur historique long
- R-CHAT-RENDER: degradation perf UI sur historique long
## Phase Execution Next 2026-03-24 (P1/P2) [in-progress]
### Objectif
- Enchainer directement sur le prochain lot de coherence plan/docs puis sur le pipeline composition prioritaire.
### lot-545-plan-todo-sync [done]
- Description: Harmoniser PLAN.md, TODO.md et docs/AGENTS.md sur les statuts reels des lots 95-100 et 178-200.
- Owner: Coordinateur
- Fichiers cibles: PLAN.md, TODO.md, docs/AGENTS.md
- Checks: revue manuelle des statuts, coherence des priorites P1/P2
- Done criteria: plus de divergences de statut entre fichiers de pilotage.
### lot-546-compose-api-direct [done]
- Description: Demarrer lot-178 avec appel ACE-Step direct pour fiabiliser la duree /compose.
- Owner: Backend API + Multimodal
- Fichiers cibles: apps/api/src/ws-commands-generate.ts, scripts/ ou service ACE-Step associe
- Checks: test manuel /compose 5s, 30s, 120s
- Done criteria: duree demandee respectee sans drift observable.
- Status: implementation validee, fallback en place, lot cloture.
### lot-547-timeline-model-v1 [in-progress]
- Description: Demarrer lot-180 avec modele timeline v1 (tracks, clips, markers).
- Owner: Backend API
- Fichiers cibles: apps/api/src/*composition*, packages/*domain* si necessaire
- Checks: tests unitaires modele + validation schema
- Done criteria: structure timeline stable et serialisable.
### lot-548-waveform-ui-v1 [planned]
- Description: Demarrer lot-194 avec waveform timeline cote frontend.
- Owner: Frontend
- Fichiers cibles: apps/web/src/components/*compose* ou *timeline*
- Checks: npm run -w @kxkm/web test, npm run -w @kxkm/web build
- Done criteria: waveform lisible par piste sans regression mobile.
### lot-549-composition-tests [planned]
- Description: Renforcer les tests composition (store, remix, export, timeline path).
- Owner: Backend API + Frontend
- Fichiers cibles: apps/api/src/**/*.test.ts, apps/web/src/**/*.test.tsx
- Checks: npm run test:v2
- Done criteria: couverture non-regression sur le pipeline composition.
### Sequencement d execution
1. lot-545 [done]
2. lot-546 [done]
3. lot-547 [in-progress]
4. lot-548
5. lot-549
### Risques cibles
- R-DOC-DRIFT: statuts incoherents entre fichiers de pilotage
- R-COMPOSE-DURATION: duree audio non respectee en production
- R-TIMELINE-MODEL: format instable pour les futures features DAW
- R-WAVEFORM-UX: surcharge render sur mobile
+8
View File
@@ -400,3 +400,11 @@ Fait sur ce lot:
- [x] lot-542: Async ffmpeg (~35 calls unblocked from event loop)
- [x] lot-543: Worker index.ts unified on worker-runtime.ts (583→155 LOC)
- [x] lot-544: Chat render memoization (wordCount + searchMatches useMemo)
## Execution Queue 2026-03-24 (P1/P2) [in-progress]
- [x] lot-545: Sync statuts PLAN/TODO/docs/AGENTS (lots 95-100, 178-200)
- [x] lot-546: lot-178 ACE-Step direct API pour /compose duration fiable
- [ ] lot-547 (en cours): lot-180 timeline model v1 (tracks/clips/markers + schema)
- [ ] lot-548: lot-194 waveform timeline UI v1 (desktop + mobile)
- [ ] lot-549: composition tests renfort (store/remix/export/timeline)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"topics": {},
"pins": {},
"savedAt": "2026-03-22T09:47:09.318Z"
"savedAt": "2026-03-22T14:02:09.429Z"
}
+1 -1
View File
@@ -2,5 +2,5 @@
"nick": "Pharmacius",
"facts": [],
"summary": "",
"lastUpdated": "2026-03-22T03:54:14.976Z"
"lastUpdated": "2026-03-24T16:06:32.856Z"
}
+68
View File
@@ -161,4 +161,72 @@ describe("composition-store", () => {
assert.equal(sfxTrack!.type, "sfx");
assert.equal(sfxTrack!.filePath, undefined, "filePath should be undefined when not set");
});
it("createComposition initializes timeline model v1", async () => {
const { createComposition, getTimeline } = await storePromise;
const comp = createComposition("ivy", "#timeline", "Timeline V1");
const timeline = getTimeline(comp.id);
assert.ok(timeline, "timeline should exist");
assert.equal(timeline!.version, 1);
assert.equal(timeline!.bpm, 120);
assert.deepEqual(timeline!.timeSignature, [4, 4]);
assert.equal(timeline!.tracks.length, 0);
assert.equal(timeline!.clips.length, 0);
assert.equal(timeline!.markers.length, 0);
});
it("addTrack creates a default clip in timeline", async () => {
const { createComposition, addTrack, getTimeline } = await storePromise;
const comp = createComposition("jules", "#timeline", "Track Clip");
const track = addTrack(comp.id, {
type: "music",
prompt: "test clip",
duration: 12,
volume: 70,
startMs: 2500,
});
assert.ok(track);
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);
assert.equal(timeline!.clips[0].startMs, 2500);
assert.equal(timeline!.clips[0].durationMs, 12000);
assert.equal(timeline!.clips[0].gain, 70);
});
it("updates timeline settings and persists markers", async () => {
const {
createComposition,
updateTimelineSettings,
addTimelineMarker,
listTimelineMarkers,
} = await storePromise;
const comp = createComposition("kate", "#timeline", "Tempo + Markers");
const updated = updateTimelineSettings(comp.id, { bpm: 98, timeSignature: [3, 4] });
assert.ok(updated);
assert.equal(updated!.bpm, 98);
assert.deepEqual(updated!.timeSignature, [3, 4]);
const marker = addTimelineMarker(comp.id, { label: "Intro", atMs: 4000, color: "#ff66b2" });
assert.ok(marker);
assert.equal(marker!.label, "Intro");
const markers = listTimelineMarkers(comp.id);
assert.equal(markers.length, 1);
assert.equal(markers[0].label, "Intro");
assert.equal(markers[0].atMs, 4000);
const jsonPath = path.join(testDir, "data", "compositions", comp.id, "composition.json");
const onDisk = JSON.parse(readFileSync(jsonPath, "utf-8"));
assert.ok(onDisk.timeline, "timeline should be persisted");
assert.equal(onDisk.timeline.version, 1);
assert.equal(onDisk.timeline.bpm, 98);
assert.equal(onDisk.timeline.markers.length, 1);
assert.equal(onDisk.timeline.markers[0].label, "Intro");
});
});
+173 -10
View File
@@ -18,23 +18,115 @@ export interface Track {
createdAt: string;
}
export interface TimelineClip {
id: string;
trackId: string;
startMs: number;
durationMs: number;
trimStartMs: number;
trimEndMs: number;
gain: number; // 0-200
}
export interface TimelineMarker {
id: string;
label: string;
atMs: number;
color?: string;
createdAt: string;
}
export interface TimelineModelV1 {
version: 1;
bpm: number;
timeSignature: [number, number];
tracks: Track[];
clips: TimelineClip[];
markers: TimelineMarker[];
}
export interface Composition {
id: string;
name: string;
channel: string;
nick: string;
// Legacy alias kept for compatibility with existing command handlers.
tracks: Track[];
timeline: TimelineModelV1;
createdAt: string;
updatedAt: string;
}
const compositions = new Map<string, Composition>();
function toDurationMs(seconds: number): number {
return Math.max(1000, Math.round((seconds || 0) * 1000));
}
function makeDefaultClip(track: Track): TimelineClip {
const clipId = `clp_${Date.now().toString(36)}_${crypto.randomBytes(2).toString("hex")}`;
return {
id: clipId,
trackId: track.id,
startMs: Math.max(0, track.startMs || 0),
durationMs: toDurationMs(track.duration),
trimStartMs: 0,
trimEndMs: 0,
gain: Math.min(200, Math.max(0, track.volume ?? 100)),
};
}
function normalizeComposition(input: Composition): Composition {
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
const timeline: TimelineModelV1 = input.timeline
? {
version: 1,
bpm: Number.isFinite(input.timeline.bpm) ? input.timeline.bpm : 120,
timeSignature:
Array.isArray(input.timeline.timeSignature) && input.timeline.timeSignature.length === 2
? [input.timeline.timeSignature[0], input.timeline.timeSignature[1]]
: [4, 4],
tracks,
clips: Array.isArray(input.timeline.clips) ? input.timeline.clips : [],
markers: Array.isArray(input.timeline.markers) ? input.timeline.markers : [],
}
: {
version: 1,
bpm: 120,
timeSignature: [4, 4],
tracks,
clips: tracks.map(makeDefaultClip),
markers: [],
};
if (timeline.clips.length === 0 && tracks.length > 0) {
timeline.clips = tracks.map(makeDefaultClip);
}
return {
...input,
tracks,
timeline,
};
}
export function createComposition(nick: string, channel: string, name?: string): Composition {
const id = `comp_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`;
const tracks: Track[] = [];
const comp: Composition = {
id, name: name || `Composition ${id}`,
channel, nick, tracks: [],
channel,
nick,
tracks,
timeline: {
version: 1,
bpm: 120,
timeSignature: [4, 4],
tracks,
clips: [],
markers: [],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
@@ -57,24 +149,94 @@ export function getActiveComposition(nick: string, channel: string): Composition
export function setActiveComposition(nick: string, channel: string, compId: string): Composition | undefined {
const comp = compositions.get(compId);
if (!comp) return undefined;
const normalized = normalizeComposition(comp);
// Reassign nick/channel so getActiveComposition finds it for this user
comp.nick = nick;
comp.channel = channel;
comp.updatedAt = new Date().toISOString();
saveComposition(comp);
return comp;
normalized.nick = nick;
normalized.channel = channel;
normalized.updatedAt = new Date().toISOString();
compositions.set(compId, normalized);
saveComposition(normalized);
return normalized;
}
export function addTrack(compId: string, track: Omit<Track, "id" | "createdAt">): Track | null {
const comp = compositions.get(compId);
if (!comp) return null;
const normalized = normalizeComposition(comp);
const t: Track = { ...track, id: `trk_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`, createdAt: new Date().toISOString() };
comp.tracks.push(t);
comp.updatedAt = new Date().toISOString();
saveComposition(comp);
normalized.timeline.tracks.push(t);
normalized.tracks = normalized.timeline.tracks;
normalized.timeline.clips.push(makeDefaultClip(t));
normalized.updatedAt = new Date().toISOString();
compositions.set(compId, normalized);
saveComposition(normalized);
return t;
}
export function getTimeline(compId: string): TimelineModelV1 | undefined {
const comp = compositions.get(compId);
if (!comp) return undefined;
const normalized = normalizeComposition(comp);
compositions.set(compId, normalized);
return normalized.timeline;
}
export function updateTimelineSettings(
compId: string,
updates: { bpm?: number; timeSignature?: [number, number] },
): TimelineModelV1 | null {
const comp = compositions.get(compId);
if (!comp) return null;
const normalized = normalizeComposition(comp);
if (typeof updates.bpm === "number" && Number.isFinite(updates.bpm)) {
normalized.timeline.bpm = Math.min(300, Math.max(20, Math.round(updates.bpm)));
}
if (
Array.isArray(updates.timeSignature) &&
updates.timeSignature.length === 2 &&
Number.isFinite(updates.timeSignature[0]) &&
Number.isFinite(updates.timeSignature[1])
) {
normalized.timeline.timeSignature = [
Math.max(1, Math.round(updates.timeSignature[0])),
Math.max(1, Math.round(updates.timeSignature[1])),
];
}
normalized.updatedAt = new Date().toISOString();
compositions.set(compId, normalized);
saveComposition(normalized);
return normalized.timeline;
}
export function addTimelineMarker(
compId: string,
marker: { label: string; atMs: number; color?: string },
): TimelineMarker | null {
const comp = compositions.get(compId);
if (!comp) return null;
const normalized = normalizeComposition(comp);
const created: TimelineMarker = {
id: `mrk_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`,
label: marker.label.trim() || "Marker",
atMs: Math.max(0, Math.round(marker.atMs || 0)),
color: marker.color,
createdAt: new Date().toISOString(),
};
normalized.timeline.markers.push(created);
normalized.updatedAt = new Date().toISOString();
compositions.set(compId, normalized);
saveComposition(normalized);
return created;
}
export function listTimelineMarkers(compId: string): TimelineMarker[] {
const timeline = getTimeline(compId);
if (!timeline) return [];
return [...timeline.markers].sort((a, b) => a.atMs - b.atMs);
}
export function listCompositions(nick?: string): Composition[] {
const all = [...compositions.values()];
return nick ? all.filter(c => c.nick === nick) : all;
@@ -92,7 +254,8 @@ try {
const jsonPath = path.join(COMP_DIR, entry, "composition.json");
if (fs.existsSync(jsonPath)) {
const comp = JSON.parse(fs.readFileSync(jsonPath, "utf-8")) as Composition;
compositions.set(comp.id, comp);
const normalized = normalizeComposition(comp);
compositions.set(normalized.id, normalized);
}
}
if (compositions.size > 0) logger.info({ count: compositions.size }, "[composition] Loaded compositions");
+76
View File
@@ -0,0 +1,76 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
/**
* Integration tests — verify API endpoints respond correctly.
* Uses fetch against a test server or mocked HTTP.
*/
describe("API Integration", () => {
// These tests verify module imports and function signatures
// without requiring a running server
it("context-store parallel read works", async () => {
// Verify the parallel Promise.all pattern compiles and runs
const [a, b] = await Promise.all([
Promise.resolve("summary"),
Promise.resolve("content"),
]);
assert.equal(a, "summary");
assert.equal(b, "content");
});
it("perf module exports prometheusMetrics", async () => {
const { prometheusMetrics, recordLatency, getMetrics } = await import("./perf.js");
assert.equal(typeof prometheusMetrics, "function");
assert.equal(typeof recordLatency, "function");
assert.equal(typeof getMetrics, "function");
recordLatency("test_integration", 42);
const metrics = getMetrics();
assert.ok(metrics.test_integration);
assert.equal(metrics.test_integration.count, 1);
assert.equal(metrics.test_integration.avgMs, 42);
const prom = prometheusMetrics();
assert.ok(prom.includes("kxkm_memory_rss_bytes"));
assert.ok(prom.includes("kxkm_uptime_seconds"));
assert.ok(prom.includes("kxkm_test_integration_total 1"));
});
it("a2a-agent-card exports correct structure", async () => {
const { agentCardRoute } = await import("./a2a-agent-card.js");
assert.equal(typeof agentCardRoute, "function");
});
it("mcp-tools exports all 6 tools", async () => {
const { TOOLS, getToolsForPersona } = await import("./mcp-tools.js");
assert.ok(TOOLS.web_search);
assert.ok(TOOLS.image_generate);
assert.ok(TOOLS.rag_search);
assert.ok(TOOLS.music_generate);
assert.ok(TOOLS.voice_synthesize);
assert.ok(TOOLS.audio_analyze);
const schaefferTools = getToolsForPersona("schaeffer");
assert.ok(schaefferTools.some(t => t.function.name === "music_generate"));
});
it("ws-chat exports isChatPaused and setChatPaused", async () => {
const mod = await import("./ws-chat.js");
assert.equal(typeof mod.isChatPaused, "function");
assert.equal(typeof mod.setChatPaused, "function");
});
it("llm-client has mascarade circuit breaker", async () => {
const { checkMascaradeHealth } = await import("./llm-client.js");
assert.equal(typeof checkMascaradeHealth, "function");
});
it("rag has reranker circuit breaker fields", async () => {
const { LocalRAG } = await import("./rag.js");
const rag = new LocalRAG({ ollamaUrl: "http://localhost:11434" });
assert.ok(rag);
// Private fields exist (verified by TypeScript compilation)
});
});
+15 -3
View File
@@ -1888,6 +1888,7 @@ async function handleComposeCommand({
});
const ttsUrl = process.env.TTS_URL || "http://127.0.0.1:9100";
const aiBridgeUrl = process.env.AI_BRIDGE_URL || "http://127.0.0.1:8301";
const startTime = Date.now();
const progressInterval = setInterval(() => {
@@ -1901,12 +1902,23 @@ async function handleComposeCommand({
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 300_000);
const resp = await fetch(`${ttsUrl}/compose`, {
let resp = await fetch(`${aiBridgeUrl}/generate/music`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: musicPrompt, duration }),
body: JSON.stringify({ prompt: musicPrompt, duration, style: "experimental" }),
signal: controller.signal,
});
let source: "ai-bridge" | "tts-sidecar" = "ai-bridge";
if (!resp.ok) {
resp = await fetch(`${ttsUrl}/compose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: musicPrompt, duration }),
signal: controller.signal,
});
source = "tts-sidecar";
}
clearTimeout(timeout);
clearInterval(progressInterval);
@@ -1929,7 +1941,7 @@ async function handleComposeCommand({
broadcast(info.channel, {
type: "music",
nick: info.nick,
text: `[Musique: "${musicPrompt}" \u2014 ${elapsed}s]`,
text: `[Musique: "${musicPrompt}" \u2014 ${elapsed}s, source:${source}]`,
audioData: audioBase64,
audioMime: "audio/wav",
} as OutboundMessage);
+49
View File
@@ -336,4 +336,53 @@ describe("/compose", () => {
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("Usage"));
});
it("prefers AI Bridge direct endpoint", async () => {
const originalFetch = globalThis.fetch;
const fakeFetch = mock.fn<typeof fetch>(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/generate/music")) {
return new Response(Buffer.from("RIFFtest"), { status: 200, headers: { "Content-Type": "audio/wav" } });
}
return new Response(JSON.stringify({ error: "unexpected" }), { status: 500, headers: { "Content-Type": "application/json" } });
});
(globalThis as any).fetch = fakeFetch;
try {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/compose ambient drone, 10s" });
assert.ok(fakeFetch.mock.calls.some((c) => String(c.arguments[0]).includes("/generate/music")));
assert.ok(!fakeFetch.mock.calls.some((c) => String(c.arguments[0]).includes("/compose")));
} finally {
(globalThis as any).fetch = originalFetch;
}
});
it("falls back to TTS /compose when AI Bridge fails", async () => {
const originalFetch = globalThis.fetch;
const fakeFetch = mock.fn<typeof fetch>(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/generate/music")) {
return new Response(JSON.stringify({ error: "down" }), { status: 503, headers: { "Content-Type": "application/json" } });
}
if (url.includes("/compose")) {
return new Response(Buffer.from("RIFFfallback"), { status: 200, headers: { "Content-Type": "audio/wav" } });
}
return new Response(JSON.stringify({ error: "unexpected" }), { status: 500, headers: { "Content-Type": "application/json" } });
});
(globalThis as any).fetch = fakeFetch;
try {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/compose dark noise, 12s" });
assert.ok(fakeFetch.mock.calls.some((c) => String(c.arguments[0]).includes("/generate/music")));
assert.ok(fakeFetch.mock.calls.some((c) => String(c.arguments[0]).includes("/compose")));
} finally {
(globalThis as any).fetch = originalFetch;
}
});
});
+192
View File
@@ -0,0 +1,192 @@
import { test, expect } from "@playwright/test";
// Point directly to the live DAW server
const DAW_URL = process.env.DAW_URL || "http://kxkm-ai:3333/daw/";
test.describe("openDIAW.be Instruments Debug", () => {
test.setTimeout(60_000);
test("DAW loads and shows openDIAW.be title", async ({ page }) => {
await page.goto(DAW_URL);
await expect(page).toHaveTitle(/openDIAW/);
// Wait for app to initialize
await page.waitForTimeout(3000);
// Take screenshot of initial state
await page.screenshot({ path: "/tmp/daw-01-loaded.png", fullPage: true });
});
test("processor bundle contains all 9 instrument visitors", async ({ page }) => {
// Fetch the HTML to find the processor bundle filename
const resp = await page.request.get(DAW_URL);
const html = await resp.text();
const procMatch = html.match(/processors\.[a-f0-9-]+\.js/);
expect(procMatch).toBeTruthy();
const procResp = await page.request.get(`${DAW_URL}${procMatch![0]}`);
expect(procResp.ok()).toBeTruthy();
const procJs = await procResp.text();
const instruments = [
"visitDroneDeviceBox",
"visitGrainDeviceBox",
"visitGlitchDeviceBox",
"visitCircusDeviceBox",
"visitHonkDeviceBox",
"visitMagentaDeviceBox",
"visitAceStepDeviceBox",
"visitKokoroTtsDeviceBox",
"visitPiperDeviceBox",
];
const missing: string[] = [];
for (const name of instruments) {
if (!procJs.includes(name)) {
missing.push(name);
}
}
expect(missing).toEqual([]);
});
test("main bundle contains all 9 instrument visitors", async ({ page }) => {
const resp = await page.request.get(DAW_URL);
const html = await resp.text();
const mainMatch = html.match(/main\.[a-f0-9-]+\.js/);
expect(mainMatch).toBeTruthy();
const mainResp = await page.request.get(`${DAW_URL}${mainMatch![0]}`);
const mainJs = await mainResp.text();
const instruments = [
"visitDroneDeviceBox",
"visitGrainDeviceBox",
"visitGlitchDeviceBox",
"visitCircusDeviceBox",
"visitHonkDeviceBox",
"visitMagentaDeviceBox",
"visitAceStepDeviceBox",
"visitKokoroTtsDeviceBox",
"visitPiperDeviceBox",
];
const missing: string[] = [];
for (const name of instruments) {
if (!mainJs.includes(name)) {
missing.push(name);
}
}
expect(missing).toEqual([]);
});
test("main bundle contains BoxAdapters for all 9 instruments", async ({ page }) => {
const resp = await page.request.get(DAW_URL);
const html = await resp.text();
const mainMatch = html.match(/main\.[a-f0-9-]+\.js/);
expect(mainMatch).toBeTruthy();
const mainResp = await page.request.get(`${DAW_URL}${mainMatch![0]}`);
const mainJs = await mainResp.text();
// Check that BoxAdapters factory code contains each instrument's class name string
const boxNames = [
'"DroneDeviceBox"',
'"GrainDeviceBox"',
'"GlitchDeviceBox"',
'"CircusDeviceBox"',
'"HonkDeviceBox"',
'"MagentaDeviceBox"',
'"AceStepDeviceBox"',
'"KokoroTtsDeviceBox"',
'"PiperDeviceBox"',
];
const missing: string[] = [];
for (const name of boxNames) {
if (!mainJs.includes(name)) {
missing.push(name);
}
}
if (missing.length > 0) {
console.log(`Missing box class names in main bundle: ${missing.join(", ")}`);
}
expect(missing).toEqual([]);
});
test("InstrumentFactories.Named contains all 9 custom instruments", async ({ page }) => {
const resp = await page.request.get(DAW_URL);
const html = await resp.text();
const mainMatch = html.match(/main\.[a-f0-9-]+\.js/);
expect(mainMatch).toBeTruthy();
const mainResp = await page.request.get(`${DAW_URL}${mainMatch![0]}`);
const mainJs = await mainResp.text();
// Check factory descriptions appear in the bundle
const descriptions = [
"Pad/drone synthesizer",
"Granular synthesizer",
"Glitch texture generator",
"Barrel organ",
"Klaxon",
"AI MIDI generator",
"AI music generator",
"Kokoro TTS",
"Piper TTS",
];
const found: string[] = [];
const missing: string[] = [];
for (const desc of descriptions) {
if (mainJs.includes(desc)) {
found.push(desc);
} else {
missing.push(desc);
}
}
console.log(`Found: ${found.length}/9 instrument descriptions`);
if (missing.length > 0) {
console.log(`Missing: ${missing.join(", ")}`);
}
expect(missing).toEqual([]);
});
test("DAW can open and navigate (smoke)", async ({ page }) => {
page.on("console", (msg) => {
if (msg.type() === "error") {
console.log(`[DAW Console Error] ${msg.text()}`);
}
});
page.on("pageerror", (err) => {
console.log(`[DAW Page Error] ${err.message}`);
});
await page.goto(DAW_URL);
await page.waitForTimeout(5000);
// Collect all console errors
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
await page.waitForTimeout(2000);
await page.screenshot({ path: "/tmp/daw-02-ready.png", fullPage: true });
// Check no critical errors about factories or instruments
const factoryErrors = errors.filter(e =>
e.includes("factory") || e.includes("Factory") ||
e.includes("Could not find") || e.includes("DeviceBox")
);
if (factoryErrors.length > 0) {
console.log("Factory-related errors found:");
factoryErrors.forEach(e => console.log(` - ${e}`));
}
// This is informational — we log errors but don't fail on them
// because the DAW may show errors from other causes (network, audio context, etc.)
console.log(`Total console errors: ${errors.length}, factory errors: ${factoryErrors.length}`);
});
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@kxkm/web",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "V2 web shell scaffold for React/Vite migration",
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@xyflow/react": "^12.10.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7"
},
"scripts": {
"status": "node ../../scripts/workspace-package.js web status",
"dev": "vite",
"build": "vite build",
"check": "tsc -p tsconfig.json --noEmit --pretty false",
"test": "vitest run"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"jsdom": "^29.0.0",
"vitest": "^4.1.0"
}
}
+291
View File
@@ -0,0 +1,291 @@
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>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useState } from "react";
import { api, type PersonaData } from "../api";
interface PersonaListProps {
onSelect: (id: string) => void;
}
// Group personas by model for tree view
function groupByModel(personas: PersonaData[]): Map<string, PersonaData[]> {
const groups = new Map<string, PersonaData[]>();
for (const p of personas) {
const model = p.model || "inconnu";
if (!groups.has(model)) groups.set(model, []);
groups.get(model)!.push(p);
}
// Sort groups by model name, sort personas by name within groups
const sorted = new Map(
[...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => [k, v.sort((a, b) => a.name.localeCompare(b.name))])
);
return sorted;
}
export default function PersonaList({ onSelect }: PersonaListProps) {
const [personas, setPersonas] = useState<PersonaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [toggling, setToggling] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newModel, setNewModel] = useState("qwen3:8b");
const [newSummary, setNewSummary] = useState("");
const [newEnabled, setNewEnabled] = useState(true);
// All collapsed by default — user opens what they need
const [collapsed, setCollapsed] = useState<Set<string> | "all">("all");
useEffect(() => {
loadPersonas();
}, []);
async function loadPersonas() {
setLoading(true);
try {
const list = await api.listPersonas();
setPersonas(list);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "load_failed");
} finally {
setLoading(false);
}
}
async function handleToggle(e: React.MouseEvent, persona: PersonaData) {
e.stopPropagation();
setToggling(persona.id);
try {
const updated = await api.togglePersona(persona.id, persona.enabled === false);
setPersonas((prev) =>
prev.map((p) => (p.id === updated.id ? updated : p))
);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "toggle_failed");
} finally {
setToggling(null);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
const created = await api.createPersona({
name: newName.trim(),
model: newModel,
summary: newSummary.trim(),
enabled: newEnabled,
});
setPersonas((prev) => [...prev, created]);
setNewName("");
setNewModel("qwen3:8b");
setNewSummary("");
setNewEnabled(true);
setShowCreate(false);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "create_failed");
} finally {
setCreating(false);
}
}
function toggleCollapse(model: string) {
setCollapsed((prev) => {
if (prev === "all") {
// First click: open only this model
const allModels = new Set([...groupByModel(personas).keys()]);
allModels.delete(model);
return allModels;
}
const next = new Set(prev);
if (next.has(model)) next.delete(model);
else next.add(model);
return next;
});
}
if (loading) return <div className="minitel-loading">Chargement des personas...</div>;
const groups = groupByModel(personas);
const enabledCount = personas.filter((p) => p.enabled !== false).length;
return (
<div className="minitel-tree-view">
<div className="minitel-tree-header">
{">>> PERSONAS <<<"}
<span className="minitel-tree-stats">
{enabledCount}/{personas.length} actives
</span>
</div>
{error && <div className="minitel-error">ERREUR: {error}</div>}
<div className="minitel-tree-actions">
<button className="minitel-tree-btn" onClick={() => setShowCreate((v) => !v)}>
{showCreate ? "Annuler" : "+ Nouvelle"}
</button>
<button className="minitel-tree-btn" onClick={loadPersonas}>Rafraichir</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="minitel-create-form">
<div className="minitel-field">
<label>Nom _</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
placeholder="nom de la persona"
className="minitel-input"
autoFocus
/>
</div>
<div className="minitel-field">
<label>Modele _</label>
<select
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
className="minitel-input"
>
<option value="qwen3:8b">qwen3:8b</option>
<option value="qwen3.5:9b">qwen3.5:9b</option>
<option value="qwen3:4b">qwen3:4b</option>
<option value="mistral:7b">mistral:7b</option>
<option value="gemma3:4b">gemma3:4b</option>
</select>
</div>
<div className="minitel-field">
<label>Prompt _</label>
<textarea
value={newSummary}
onChange={(e) => setNewSummary(e.target.value)}
placeholder="system prompt"
rows={2}
className="minitel-input"
/>
</div>
<label className="minitel-checkbox">
<input
type="checkbox"
checked={newEnabled}
onChange={(e) => setNewEnabled(e.target.checked)}
/>
Active
</label>
<button type="submit" className="minitel-login-btn" disabled={creating || !newName.trim()}>
{creating ? "Creation..." : ">>> Creer <<<"}
</button>
</form>
)}
{/* Tree view grouped by model */}
<div className="minitel-tree">
{[...groups.entries()].map(([model, group]) => {
const isCollapsed = collapsed === "all" || collapsed.has(model);
const activeInGroup = group.filter((p) => p.enabled !== false).length;
return (
<div key={model} className="minitel-tree-branch">
<div
className="minitel-tree-model"
onClick={() => toggleCollapse(model)}
>
<span className="minitel-tree-toggle">{isCollapsed ? "+" : "-"}</span>
<span className="minitel-tree-model-name">{model}</span>
<span className="minitel-tree-model-count">
({activeInGroup}/{group.length})
</span>
</div>
{!isCollapsed && (
<div className="minitel-tree-leaves">
{group.map((persona) => {
const isEnabled = persona.enabled !== false;
return (
<div
key={persona.id}
className={`minitel-tree-leaf${!isEnabled ? " minitel-tree-leaf-off" : ""}`}
onClick={() => onSelect(persona.id)}
>
<span className="minitel-tree-pipe"></span>
<span className={`minitel-tree-dot ${isEnabled ? "dot-on" : "dot-off"}`}>
{isEnabled ? "●" : "○"}
</span>
<span className="minitel-tree-name">{persona.name}</span>
<button
className="minitel-tree-toggle-btn"
onClick={(e) => handleToggle(e, persona)}
disabled={toggling === persona.id}
title={isEnabled ? "Desactiver" : "Activer"}
>
{toggling === persona.id ? "..." : isEnabled ? "ON" : "OFF"}
</button>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
{personas.length === 0 && (
<div className="minitel-loading">Aucune persona configuree.</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -67,7 +67,7 @@ flowchart TD
OSSAgent --> |hf| OSSHF[HuggingFace models]
```
## Todo agents (lot 17+ — mis a jour 2026-03-20)
## Todo agents (lot 17+ — mis a jour 2026-03-24)
### Coordinateur
@@ -75,8 +75,8 @@ flowchart TD
- [x] Synchroniser FEATURE_MAP.md matrice
- [x] Mettre a jour TODO.md avec backlog Phase session 2026-03-19/20
- [x] Documenter actions dans ops/v2/logs/
- [ ] lot-95: Coordonner E2E Playwright test plan
- [ ] lot-100: Design public demo mode access control
- [x] lot-95: Coordonner E2E Playwright test plan
- [x] lot-100: Design public demo mode access control
### Backend API
@@ -93,8 +93,8 @@ flowchart TD
- [x] /speed command for latency diagnostics
- [ ] lot-178: ACE-Step API direct integration (duration fix)
- [ ] lot-180: Timeline data model
- [ ] lot-97: Multi-channel support (create/join channels)
- [ ] lot-100: Public demo mode read-only routes
- [x] lot-97: Multi-channel support (create/join channels)
- [x] lot-100: Public demo mode read-only routes
### Node Engine
@@ -120,7 +120,7 @@ flowchart TD
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [ ] lot-195: /remix re-generate specific track
- [ ] lot-199: Stem separation (Demucs v4 htdemucs, 6-stem, MIT)
- [ ] lot-200: Full DAW export (WAV stems + JSON project)
- [x] lot-200: Full DAW export (WAV stems + JSON project)
### Personas
@@ -144,10 +144,10 @@ flowchart TD
- [x] 5 CSS themes (minitel, crt, hacker, synthwave, default)
- [x] Mobile responsive pass (touch, bottom nav, viewport units)
- [x] Guest mode read-only UI
- [ ] lot-185: Composition timeline UI (waveform view, track lanes)
- [ ] lot-194: FX rack UI (drag-drop effects chain)
- [ ] lot-95: E2E Playwright tests (login, chat, upload, admin)
- [ ] lot-98: File sharing UI (upload → gallery)
- [x] lot-185: Composition timeline UI (waveform view, track lanes)
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [x] lot-95: E2E Playwright tests (login, chat, upload, admin)
- [x] lot-98: File sharing UI (upload → gallery)
### Ops/TUI
+240
View File
@@ -0,0 +1,240 @@
# DAW & Audio Editing — Web OSS Research
> Date: 2026-03-21
> Context: Improve kxkm_clown Compose page (currently single-track generation + `<audio>` playback)
> Goal: Multi-track timeline, waveform display, mix with offsets, export
---
## 1. Full Web-Based DAWs (Open Source)
### openDAW
- **URL:** https://github.com/andremichelle/openDAW
- **Stars:** ~1.4k
- **License:** AGPL v3 / commercial dual
- **Stack:** TypeScript, Turbo monorepo, ffmpeg.wasm, Sass
- **Key features:** 18+ built-in audio devices (synths, drum machine, effects), AI stem splitting, no signup/tracking/ads philosophy
- **Relevance:** HIGH — same monorepo architecture as kxkm_clown (Turbo). The ffmpeg.wasm integration is directly relevant for server-side-free audio mixing. AGPL license is compatible with our stack but would require careful isolation if we borrow code.
- **Integration recommendation:** Study their audio routing graph and ffmpeg.wasm export pipeline. Do NOT embed the full DAW — too heavy and AGPL-viral. Cherry-pick patterns for track scheduling and canvas waveform rendering.
### GridSound
- **URL:** https://github.com/gridsound/daw
- **Stars:** ~1.8k
- **License:** AGPL-3.0
- **Stack:** Vanilla JS, CSS, HTML5, Web Audio API
- **Key features:** Browser DAW with synths, drum machine, LFO, cloud save, service worker offline
- **Relevance:** MEDIUM — good reference architecture for a timeline UI grid, but vanilla JS (no React) and AGPL make direct reuse impractical.
- **Integration recommendation:** Use as visual/UX reference only. Their grid-based timeline layout is a solid design pattern for our Compose page multi-track view.
---
## 2. Waveform Display Libraries
### wavesurfer.js
- **URL:** https://github.com/katspaugh/wavesurfer.js
- **Stars:** ~10.2k
- **License:** BSD-3-Clause
- **Stack:** TypeScript, Web Audio API, Shadow DOM
- **Key features:** Interactive waveform rendering, official React wrapper (`@wavesurfer/react`), plugins (Regions, Timeline, Minimap, Envelope, Spectrogram, Record), pre-decoded peaks support
- **Latest:** v7.12.4 (March 2026), very actively maintained, used by 15.6k projects
- **Relevance:** VERY HIGH — most popular, most maintained, BSD license, official React hooks, plugin ecosystem covers all our needs (timeline ruler, regions for clips, minimap for overview).
- **Integration recommendation:** PRIMARY CHOICE for waveform display. Use `@wavesurfer/react` with Timeline plugin for the ruler and Regions plugin for clip boundaries. One wavesurfer instance per track in the multi-track view. Lightweight enough to instantiate 5-10 instances on a page.
### peaks.js (BBC)
- **URL:** https://github.com/bbc/peaks.js (moved to https://codeberg.org/chrisn/peaks.js)
- **Stars:** ~3.4k
- **License:** LGPL-2.0
- **Stack:** JavaScript, HTML Canvas, Konva.js dependency, waveform-data.js
- **Key features:** Zoom/scroll waveform, point/segment markers, multi-channel, pre-computed waveform data (server-side via `audiowaveform` CLI), keyboard/mouse/touch interaction
- **Relevance:** HIGH — BBC-grade, designed for long-form audio editing (radio), excellent marker/segment system. Heavier than wavesurfer due to Konva dependency. Pre-computed waveform data is great for large files.
- **Integration recommendation:** ALTERNATIVE to wavesurfer if we need server-side waveform pre-computation (useful for long 120s generated tracks). The `audiowaveform` CLI tool could run on kxkm-ai server post-generation to create waveform data files, avoiding client-side decoding. Consider if performance becomes an issue with wavesurfer on mobile.
---
## 3. Web Audio Frameworks
### Tone.js
- **URL:** https://github.com/Tonejs/Tone.js
- **Stars:** ~14.7k
- **License:** MIT
- **Stack:** TypeScript, Web Audio API
- **Key features:** Global Transport (like a DAW arrangement view), sample-accurate scheduling, synths, effects, signal automation, time notation ("4n", "8t", "1m"), loop/sequence/part abstractions
- **Relevance:** VERY HIGH — the de facto standard for Web Audio scheduling. The Transport abstraction is exactly what we need for multi-track playback with offsets. MIT license, massive community.
- **Integration recommendation:** USE as the audio engine layer. `Tone.Transport` manages master timeline. Each generated track becomes a `Tone.Player` with a `.start(offset)` call to position it on the timeline. Effects (reverb, EQ) can be applied per-track via Tone's effect chain. Already a peer dependency of waveform-playlist.
### Reactronica
- **URL:** https://reactronica.com/
- **Stars:** ~800 (est.)
- **License:** MIT
- **Stack:** React, Tone.js wrapper
- **Key features:** Declarative React components for audio (`<Song>`, `<Track>`, `<Instrument>`, `<Effect>`), built on Tone.js
- **Relevance:** MEDIUM — nice declarative API but focused on MIDI/synth composition, not audio file playback and mixing. May be too opinionated for our use case.
- **Integration recommendation:** Reference for API design inspiration (declarative audio in React), but use Tone.js directly for more control over audio file scheduling.
---
## 4. Multi-Track Editors (Ready-Made)
### waveform-playlist
- **URL:** https://github.com/naomiaro/waveform-playlist
- **Stars:** ~1.6k
- **License:** MIT
- **Stack:** React, TypeScript, Tone.js, @dnd-kit, Canvas, Web Audio API
- **Key features:** Multi-track drag-to-move and trim, canvas waveform rendering with zoom, 20+ audio effects (reverb, delay, filters), AudioWorklet recording, WAV export with effects, time-synced annotations, MIDI piano roll, dark/light themes
- **Latest:** v10.2.0 (March 2026), actively maintained, modular monorepo (7 core + 6 optional packages)
- **Relevance:** EXTREMELY HIGH — this is essentially what we want to build. React + Tone.js + Canvas waveforms + drag-and-drop + WAV export. MIT license. Same tech stack as kxkm_clown.
- **Integration recommendation:** STRONGEST CANDIDATE for direct integration. Install `waveform-playlist` as a dependency and wrap it in our Minitel-themed ComposePage. The modular package structure means we can import only what we need (core + effects + export). This would give us: multi-track timeline, drag clips with offsets, per-track effects, and WAV mix export — all out of the box. Customize the CSS to match our phosphor/VIDEOTEX aesthetic.
### react-video-editor-timeline
- **URL:** https://github.com/akshay-092/react-video-editor-timeline
- **Stars:** ~50 (est.)
- **License:** Unknown
- **Key features:** Customizable timeline component for video/audio, playback control
- **Relevance:** LOW — video-focused, small community, unclear maintenance.
### react-audio-timeline
- **URL:** https://github.com/uetchy/react-audio-timeline
- **Stars:** 1
- **License:** MIT
- **Key features:** React Hooks + Web Audio API
- **Relevance:** VERY LOW — essentially abandoned, 1 star.
---
## 5. Server-Side Mixing (ffmpeg)
### ffmpeg adelay + amix pattern
- **Key technique:** Apply `adelay` filter per input to set time offset, then `amix` to combine
- **Syntax:**
```bash
ffmpeg -i track1.wav -i track2.wav -i track3.wav \
-filter_complex \
"[0]adelay=0|0[a0]; \
[1]adelay=5000|5000[a1]; \
[2]adelay=12000|12000[a2]; \
[a0][a1][a2]amix=inputs=3:duration=longest" \
output.wav
```
- **Delay values:** milliseconds, pipe-separated for L|R channels
- **Volume control:** Add `volume` filter before amix: `[0]adelay=0|0,volume=0.8[a0]`
- **Gotcha:** Use `asetpts=PTS-STARTPTS` after `adelay` if sync drifts
- **Relevance:** VERY HIGH — this is how we export the final mix server-side. The API already has access to generated WAV files in `data/audio/`. A `/compose/mix` endpoint could accept track IDs + offsets and run this ffmpeg command.
- **Integration recommendation:** Add a `mixTracks()` function in the API that takes `Array<{trackId, offsetMs, volume}>`, resolves WAV paths from media-store, builds the ffmpeg filter_complex string, and returns the mixed WAV. Use either ffmpeg CLI (already likely available on kxkm-ai) or ffmpeg.wasm for client-side preview mixing.
**Sources:**
- [FFmpeg Filters Documentation](https://ffmpeg.org/ffmpeg-filters.html)
- [adelay + amix example](https://gist.github.com/adrienjoly/e5b2db9c9a61f454ed08f56c32999f17)
- [VideoHelp Forum: merge with time offset](https://forum.videohelp.com/threads/387710-how-to-merge-multiple-audio-with-time-offset-to-one-audio-file)
---
## 6. Web Audio API Architecture Notes
### AudioWorklet for performance
- Custom DSP runs in a dedicated real-time audio thread (not main thread)
- WebAssembly in AudioWorklet eliminates GC pauses — critical for glitch-free playback
- All major browsers support AudioWorklet since 2022
### Web Audio Modules (WAM) 2.0
- **URL:** https://www.webaudiomodules.com/
- Plugin standard for Web Audio (like VST for browsers)
- Enables DAW <-> plugin communication in the audio thread
- Relevance: LOW for now (we don't need plugins), but interesting for future if we add real-time effects UI
### Performance tips
- Use typed arrays (Float32Array), reuse buffers
- Keep audio thread code monomorphic (no polymorphic dispatch)
- Pre-decode audio files with `decodeAudioData()` before playback
- For waveform display: compute peaks server-side if files are large
**Source:** [HdM Stuttgart — Web Audio API Performance Tips](https://blog.mi.hdm-stuttgart.de/index.php/2021/02/24/web-audio-api-tips-for-performance/)
---
## 7. Commercial Web DAW Reference
### BandLab
- Cloud-based social music platform, acquired Cakewalk
- "Pass the ball" collaboration model (not simultaneous editing)
- Tight integration with hardware audio interfaces
- Relevance: Their UX for simple music creation (non-pro users) is a good reference for our target audience (artists/performers, not audio engineers)
### Soundtrap (Spotify)
- Freemium, runs on Chromebooks to Mac Pro
- Real-time simultaneous collaboration
- Built-in Antares Auto-Tune
- Relevance: Their simple, responsive interface philosophy matches our Minitel aesthetic goal — keep it minimal
**Source:** [macprovideo — Top 4 Browser Based DAWs](https://macprovideo.com/article/audio-software/the-top-4-browser-based-daws)
---
## 8. Recommended Architecture for kxkm_clown Compose v2
### Option A: waveform-playlist integration (recommended)
```
┌─────────────────────────────────────────────────────┐
│ ComposePage.tsx (Minitel theme) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ waveform-playlist (React) │ │
│ │ ├─ Track 1: [====ambient drone========] │ │
│ │ ├─ Track 2: [===bass pulse===] │ │
│ │ ├─ Track 3: [==vocal chop==] │ │
│ │ └─ Timeline ruler (seconds) │ │
│ └─────────────────────────────────────────────────┘ │
│ [Generate] [Add to timeline] [Mix & Export] │
└─────────────────────────────────────────────────────┘
│ │
│ WS /compose │ POST /compose/mix
▼ ▼
ws-commands.ts mix-endpoint.ts
(existing) (new: ffmpeg adelay+amix)
```
**Pros:** Out-of-the-box multi-track, drag, trim, effects, export. MIT license. Same stack (React + Tone.js). Active maintenance.
**Cons:** CSS customization needed for Minitel theme. Bundle size (~200KB gzipped est.).
**Effort:** ~3-5 lots
### Option B: Custom build with wavesurfer.js + Tone.js
```
wavesurfer.js → waveform display per track (BSD-3)
Tone.js → audio scheduling + effects (MIT)
Custom React → timeline grid, drag/drop, transport controls
ffmpeg (server) → final mix export
```
**Pros:** Full control, lighter bundle, exact Minitel aesthetic.
**Cons:** Significantly more code to write (drag, trim, zoom, export).
**Effort:** ~8-12 lots
### Recommendation
**Go with Option A (waveform-playlist)** for the initial multi-track Compose v2. It covers 90% of needs out of the box. Layer the Minitel CSS on top. Add the server-side ffmpeg mix endpoint for final WAV export. If waveform-playlist proves too rigid or heavy, fall back to Option B using wavesurfer.js + Tone.js as building blocks.
### Immediate next steps
1. `npm i waveform-playlist tone @dnd-kit/react` in apps/web
2. Prototype a `<MultiTrackCompose>` component wrapping waveform-playlist
3. Add `POST /api/compose/mix` endpoint with ffmpeg adelay+amix
4. Theme the waveform-playlist CSS to match Minitel phosphor aesthetic
5. Wire generated tracks from existing `/compose` flow into the timeline
---
## Sources
- [openDAW](https://github.com/andremichelle/openDAW)
- [GridSound](https://github.com/gridsound/daw)
- [wavesurfer.js](https://github.com/katspaugh/wavesurfer.js) — [React wrapper](https://www.npmjs.com/package/@wavesurfer/react)
- [peaks.js (BBC)](https://github.com/bbc/peaks.js)
- [Tone.js](https://github.com/Tonejs/Tone.js)
- [Reactronica](https://reactronica.com/)
- [waveform-playlist](https://github.com/naomiaro/waveform-playlist) — [Demo](https://naomiaro.github.io/waveform-playlist/)
- [react-video-editor-timeline](https://github.com/akshay-092/react-video-editor-timeline)
- [FFmpeg Filters Documentation](https://ffmpeg.org/ffmpeg-filters.html)
- [ffmpeg adelay gist](https://gist.github.com/adrienjoly/e5b2db9c9a61f454ed08f56c32999f17)
- [Web Audio API Performance Tips](https://blog.mi.hdm-stuttgart.de/index.php/2021/02/24/web-audio-api-tips-for-performance/)
- [Web Audio Modules 2.0](https://www.webaudiomodules.com/)
- [MDN Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
- [MDN AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet)
- [WAM-studio paper (INRIA)](https://inria.hal.science/hal-04335612/file/3587987_authorVersion.pdf)
- [macprovideo — Browser DAWs](https://macprovideo.com/article/audio-software/the-top-4-browser-based-daws)
+149
View File
@@ -0,0 +1,149 @@
# openDAW Research Notes — 2026-03-21
Reference: https://github.com/andremichelle/openDAW
## Project Overview
- **Author**: Andre Michelle (creator of Audiotool)
- **Stars**: ~1360, 98 forks, very active (last update: 2026-03-21)
- **Language**: TypeScript (100%)
- **License**: AGPL v3 (README) / LGPL-3.0-or-later (SDK packages)
- **Node**: >= 23 required
- **Build**: Turborepo + Lerna monorepo, Vite, Sass, Vitest
## Architecture — Key Patterns
### Monorepo Structure (packages/)
```
packages/
app/
studio/ — Main DAW web app (Vite + custom JSX)
lab/ — Audio experiments/playground
nam-test/ — Neural amp modeling test
lib/
box/ — Reactive data model (graph, pointers, sync, serialization)
box-forge/ — Schema-based box code generation
dsp/ — Pure DSP library (FFT, biquad, ADSR, convolver, resampler, tempo, PPQN...)
fusion/ — Live-stream reactive system (flags, streams)
dom/ — DOM utilities
jsx/ — Custom JSX runtime (no React, no framework)
midi/ — MIDI parsing/generation
runtime/ — Runtime utilities
std/ — Standard library (collections, math, etc.)
xml/ — XML parser (for DAWproject import/export)
dawproject/ — DAWproject format import/export
studio/
core/ — Engine: AudioWorklet processors, offline engine, FFmpeg workers
core-processors/ — AudioWorklet processor implementations
core-workers/ — Web Worker implementations
adapters/ — Box-to-engine adapter layer (device adapters, parameter adapters)
boxes/ — Data model definitions (box schemas for all studio entities)
enums/ — Shared enumerations
forge-boxes/ — Generated box code from schemas
sdk/ — Public SDK for external use
scripting/ — User scripting support
server/
yjs-server/ — Y.js WebSocket server for real-time collaboration
config/
eslint/, typescript/ — Shared configs
```
### Audio Engine
- **Web Audio API** with **AudioWorklet** processors (not ScriptProcessorNode)
- Custom DSP in `@opendaw/lib-dsp`: FFT, biquad filters, convolver, resampler, ADSR, LFO, waveshaper, noise, limiter, compressor (CTAGDRC port), delay, reverb (Dattorro)
- Offline rendering engine (`offline-engine.js`) for bouncing/export
- FFmpeg WASM for audio decoding/encoding
- Neural Amp Modeling via `@opendaw/nam-wasm`
- PPQN-based timing (pulses per quarter note) for precise MIDI/audio scheduling
- Tempo automation via `VaryingTempoMap` and `ConstantTempoMap`
### Multi-Track Timeline
- **Box model**: All project data stored in a reactive graph (`@opendaw/lib-box`). Boxes are typed nodes with fields, pointers, and graph edges.
- **Adapters layer**: `@opendaw/studio-adapters` maps box data to engine state. Each device/track has a BoxAdapter that manages its lifecycle and parameter sync.
- Key adapters: `AudioUnitBoxAdapter`, `AudioBusBoxAdapter`, `AudioUnitTracks`, `ClipSequencing`, `ClipNotifications`
- Audio regions with pitch/time-stretch, warp markers, transient detection
- Recording support with loop takes
- Automation tracks (tempo, signature, parameters)
- Clip-based sequencing on timeline
### UI Framework
- **Custom JSX runtime** (`@opendaw/lib-jsx`) — no React, no Vue, no framework
- Depends only on `@opendaw/lib-dom` and `@opendaw/lib-std`
- Sass for styling
- Vite for dev/build
- Zero framework dependencies — radical minimalism
### Collaboration
- **Y.js** for CRDT-based real-time sync
- `y-websocket` for transport
- Box model has `sync-source.ts` / `sync-target.ts` for Y.js integration
- Dropbox SDK for cloud storage
### External Dependencies (minimal)
- jszip (project bundles)
- markdown-it (help pages)
- d3-force (graph debug viz)
- soundfont2 (SoundFont loading)
- zod (schema validation)
- ffmpeg.wasm (audio codec)
- yjs + y-websocket (collab)
- dropbox SDK (cloud storage)
## Stock Plugins/Devices
**Instruments**: Vaporisateur (subtractive synth), Playfield (drum machine), Nano (sampler), Tape (audio regions), Soundfont player, MIDI Output, Apparat
**Audio FX**: Stereo Tool, Delay, Crusher, Reverb (Cheap + Dattorro), Revamp (EQ+spectrum), Fold (waveshaper), Tidal (rhythm shaper), Compressor (CTAGDRC), Gate, Maximizer, Neural Amp, Werkstatt
**MIDI FX**: Arpeggio, Pitch, Velocity, Zeitgeist (time transform)
## License Analysis
- **AGPL v3** on the repository overall
- **LGPL-3.0-or-later** on SDK/library packages (dsp, box, fusion, jsx, etc.)
- Ported plugins excluded from commercial license
- **For kxkm_clown**: We CANNOT copy code (AGPL copyleft). We CAN study architecture patterns, naming conventions, and DSP algorithms as reference. LGPL libs could theoretically be used as dependencies if we comply with LGPL terms, but safer to just reference patterns.
## Patterns Applicable to kxkm_clown DAW
### 1. Reactive Box Model (data layer)
The `box` library is a typed reactive graph for all project data. Each entity is a "box" with typed fields, pointers to other boxes, and graph edges. This is similar to an ECS but graph-oriented. We could adopt a similar pattern with our own implementation for the DAW project state.
### 2. Adapter Pattern (data <-> engine bridge)
BoxAdapters bridge the data model to the audio engine. Each device/track type has its own adapter. This clean separation means the data model is independent of the audio engine. Worth replicating.
### 3. Custom JSX without React
Their `lib-jsx` proves you can build a DAW UI with a minimal custom JSX runtime. For our Minitel-themed TUI/web hybrid, this validates avoiding heavy frameworks.
### 4. DSP as Pure Library
`lib-dsp` is framework-agnostic pure TypeScript DSP. We could build our own equivalent or reference their implementations for filters, FFT, ADSR, etc.
### 5. AudioWorklet-First Architecture
All audio processing runs in AudioWorklet processors, not on the main thread. Essential for low-latency DAW work. Our DAW should follow the same pattern.
### 6. PPQN Timing
PPQN-based scheduling is the standard for precise musical timing. Their implementation in `lib-dsp/ppqn.ts` and tempo maps is a good reference.
### 7. Monorepo with Clean Package Boundaries
Turborepo + npm workspaces with clear package boundaries (lib/ vs studio/ vs app/). Good model for our own package structure.
### 8. DAWproject Import/Export
They implement the open DAWproject format for interop with other DAWs. Worth considering for our export pipeline.
## Roadmap Insights
- 2026/Q3 target for 1.0 launch
- Future: modular synth system, more synths/effects
- Active areas needing help: Y.js collab, Tauri desktop wrapper, PWA, timeline track management
## Sources
- [GitHub repo](https://github.com/andremichelle/openDAW)
- [openDAW headless SDK](https://github.com/andremichelle/opendaw-headless)
- [VJ Union article](https://vjun.io/vdmo/opendaw-theres-a-new-free-daw-in-town-31k4)
- [Polarity Music / Patreon](https://www.patreon.com/posts/introducing-121729983)
- [Gearspace discussion](https://gearspace.com/board/new-products-coming-soon/1442716-opendaw-new-open-source-daw.html)
+111
View File
@@ -0,0 +1,111 @@
# OSS Notes -- kxkm_clown / 3615-KXKM
**Date**: 2026-03-20 (complement de OSS_VEILLE_2026-03-20.md)
---
## 1. Ollama v0.18 -- details supplementaires
- **URL**: https://github.com/ollama/ollama/releases/tag/v0.18.0
- **Structured outputs**: JSON Schema complet supporte depuis v0.5, passer `format` param avec schema Pydantic ou dict. Sous le capot: grammar llama.cpp generee depuis le schema.
- **Web search/fetch**: Plugin OpenClaw integre, plus besoin de SearXNG comme proxy pour le search agent.
- **Cloud models**: Tag `:cloud` pour MiniMax-M2.5 et Qwen3.5 sans `ollama pull`. Fast TTFT pour Amerique du Nord.
- **`--yes` flag**: `ollama launch --yes` skip les prompts -- utile pour scripts CI et agents non-interactifs.
- **Nemotron-3-Super 122B**: Nouveau modele NVIDIA pour reasoning/tool calling (trop gros pour RTX 4090 local).
- **Recommandation**: Mettre a jour sur kxkm-ai. Tester structured outputs pour les reponses persona JSON. Le web search natif pourrait simplifier le pipeline RAG.
## 2. Qwen3.5-9B -- benchmarks confirmes
- **URL HF**: https://huggingface.co/Qwen/Qwen3.5-9B
- **URL review**: https://venturebeat.com/technology/alibabas-small-open-source-qwen3-5-9b-beats-openais-gpt-oss-120b-and-can-run
- **Release**: 2 mars 2026 (small series). Famille: 0.8B, 2B, 4B, 9B.
- **Benchmarks cles**:
- MMLU-Pro: 82.5 (vs GPT-OSS-120B: 80.8)
- GPQA Diamond: 81.7 (vs GPT-OSS-120B: 80.1)
- IFBench: 76.5 (bat GPT-5.2: 75.4)
- Video-MME: 84.5 (vs Gemini 2.5 Flash-Lite: 74.6)
- **Architecture**: Gated Delta Networks (linear attention) + sparse MoE. Multimodal natif (texte, images, video). 201 langues. 256K context.
- **Attention**: Benchmarks impressionnants mais XDA note un decalage bench vs usage reel.
- **Recommandation**: `ollama pull qwen3.5:9b` sur kxkm-ai. Candidat ideal pour remplacer les modeles persona actuels. Tester en conditions reelles avec les prompts 3615-KXKM avant migration.
## 3. ComfyUI -- audio/musique nodes
- **Stable Audio 2.5**: Integre nativement dans ComfyUI. Tracks de 3 min en < 2 secondes.
- URL: https://blog.comfy.org/p/stable-audio-25-is-now-in-comfyui
- **ACE-Step v1 natif**: Workflow guide officiel dispo.
- URL: https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1
- **ComfyUI-audio**: Outils generatifs audio custom nodes.
- URL: https://github.com/eigenpunk/ComfyUI-audio
- **InspireMusic (Alibaba)**: Framework unifie musique/chant/audio, open source.
- URL: https://comfyui-wiki.com/en/news/2025-02-11-alibaba-open-source-inspiremusic
- **Recommandation**: Prioriser ACE-Step 1.5 (voir #8). Stable Audio 2.5 comme fallback pour jingles courts.
## 4. react-window v2 et alternatives
- **react-window v2**: Breaking change confirme -- `FixedSizeList` renomme en `List`. Pas de v2 "stable" clairement annoncee, mais releases actives sur GitHub.
- URL: https://github.com/bvaughn/react-window/releases
- **TanStack Virtual v3**: v3.13.23 (publie il y a quelques jours). Stable, framework-agnostique (React, Vue, Svelte, Solid, Angular). Headless, 60 FPS, support grille/horizontal.
- URL: https://tanstack.com/virtual/v3
- npm: `@tanstack/react-virtual` (987 dependants)
- **React Virtuoso**: Meilleur pour hauteurs dynamiques et cas complexes.
- URL: https://medium.com/@stuthineal/infinite-scrolling-made-easy-react-window-vs-react-virtuso-1fd786058a73
- **Recommandation**: **Migrer vers TanStack Virtual v3** plutot que react-window v2. Plus actif, headless (pas de markup impose), meilleur pour notre use case chat/logs. Migration straightforward.
## 5. Minitel CSS / Retro Terminal CSS
- **Minitel Bootstrap**: Theme Bootstrap style Minitel par bob6664569.
- URL: https://github.com/bob6664569/minitel
- **CodePen 3611**: CSS Minitel avec VIDEOTEX colors (bleu, jaune, vert, cyan).
- URL: https://codepen.io/paganaye/pen/JdqxvX
- **Terminal CSS**: Framework CSS leger, variables CSS customisables, 11 composants retro.
- URL: https://terminalcss.xyz/
- **AnderShell 3000**: Terminal retro complet en CSS (CRT effect, scanlines).
- URL: https://github.com/andersevenrud/retro-css-shell-demo
- **CSS-Tricks Old Timey Terminal**: Techniques pour glow, scanlines, phosphore.
- URL: https://css-tricks.com/old-timey-terminal-styling/
- **Retro CRT terminal**: CSS + JS pour effet CRT.
- URL: https://dev.to/ekeijl/retro-crt-terminal-screen-in-css-js-4afh
- **Recommandation**: Pour le lot 16 (UI Minitel 3615-KXKM), combiner: Terminal CSS comme base + CodePen 3611 pour les couleurs VIDEOTEX + AnderShell 3000 pour effets CRT/phosphore. Ne PAS utiliser Minitel Bootstrap (trop date, pas maintenu).
## 6. WebSocket chat open source
- **awesome-websockets**: Liste curated de libs WS.
- URL: https://github.com/facundofarias/awesome-websockets
- **OneUpTime guide**: Tuto recent (jan 2026) pour chat WS avec rooms, DM, typing indicators, persistence.
- URL: https://oneuptime.com/blog/post/2026-01-26-websocket-chat-application/view
- **Gorilla WebSocket (Go)**: Client/server RFC 6455, reference pour Go.
- **AnyCable (Rails)**: Perf Go-level pour Rails WS.
- **Recommandation**: Notre stack WS actuelle est solide. Rien a migrer. Le guide OneUpTime peut etre utile comme reference pour typing indicators si on les ajoute.
## 7. Local AI multi-persona
- **AstrBot**: Plateforme AI Agent open source Python, systeme Persona integre, multi-plateforme (QQ, Telegram, Feishu).
- URL: https://openclawapi.org/en/blog/2026-03-14-astrbot-deployment-guide
- **CrewAI**: Multi-agent collaboration, role-based teams, orchestrateur supervisor.
- **Tendances 2026**: Multi-agent orchestration + knowledge bases auto-update + privacy-preserving LLMs. Ollama + vLLM pour local.
- **Recommandation**: AstrBot a un systeme persona interessant a etudier pour s'inspirer. Notre architecture mascarade est deja plus avancee (15 providers, P2P, MCP). Pas de migration, mais surveiller CrewAI pour les patterns multi-agent.
## 8. ACE-Step 1.5 ComfyUI -- details
- **URL officielle**: https://github.com/ace-step/ACE-Step-1.5
- **URL ComfyUI guide**: https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1-5
- **URL blog**: https://blog.comfy.org/p/ace-step-15-is-now-available-in-comfyui
- **Architecture**: Hybrid LM (structure planning) + DiT (audio synthesis). Apache 2.0.
- **Perf**: Chanson 4 min en ~1s sur RTX 5090, < 10s sur RTX 3090. Sur **RTX 4090** (kxkm-ai): ~1.74s/min audio (34.48x RTF).
- **Features**: LoRA fine-tuning (quelques chansons suffisent), ControlNet, 50+ langues, nodes ComfyUI natifs (pas de custom nodes).
- **Qualite**: Score 4.72 en coherence musicale, depasse la plupart des alternatives commerciales.
- **AMD blog**: Supporte aussi AMD Ryzen AI + Radeon.
- URL: https://www.amd.com/en/blogs/2026/commercial-grade-ai-music-generation-on-amd-ryzen-ai-and-radeon-ace-step-1-5.html
- **Recommandation**: **PRIORITAIRE**. Installer ACE-Step 1.5 via ComfyUI sur kxkm-ai. Creer des LoRA par personnage/ambiance pour la generation musicale des clowns. Performance excellente sur RTX 4090.
---
## Actions prioritaires
| Priorite | Action | Lot lie |
|----------|--------|---------|
| P0 | `ollama pull qwen3.5:9b` + tester structured outputs | Personas |
| P0 | Installer ACE-Step 1.5 dans ComfyUI, tester sur RTX 4090 | Audio/musique |
| P1 | Migrer react-window -> TanStack Virtual v3 | UI chat |
| 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 |
+1552
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# Simplified Dockerfile for Kind/tower deployment
# Does NOT require pre-built dist/ — builds everything inside the container
FROM node:22-bookworm-slim
WORKDIR /app
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
tini ca-certificates ffmpeg python3 \
&& rm -rf /var/lib/apt/lists/*
# Copy entire source
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/ packages/
# Install deps
RUN npm ci --ignore-scripts 2>/dev/null || npm install --ignore-scripts
# Copy tsconfig files (needed for tsc --build)
COPY tsconfig.base.json ./
COPY tsconfig*.json ./
# Copy source
COPY apps/api/ apps/api/
COPY apps/web/ apps/web/
COPY scripts/ scripts/
# Build all packages + API (TypeScript → ESM via project references)
RUN npx tsc --build apps/api/tsconfig.json 2>/dev/null || true
# Build Web (Vite)
RUN npm run build --workspace=apps/web 2>/dev/null || true
# Create data dirs
RUN mkdir -p data/logs data/sessions data/context data/persona-memory \
data/media/images data/media/audio data/daw-samples data/feedback \
data/compositions data/exports data/chat-logs
ENV NODE_ENV=production
ENV PORT=3333
EXPOSE 3333
ENTRYPOINT ["tini", "--"]
CMD ["node", "apps/api/dist/server.js"]
+115
View File
@@ -0,0 +1,115 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kxkm-api
namespace: kxkm
labels:
app: kxkm-api
spec:
replicas: 1
selector:
matchLabels:
app: kxkm-api
template:
metadata:
labels:
app: kxkm-api
spec:
containers:
- name: api
image: kxkm-api:latest
imagePullPolicy: Never # Use local image (built on tower)
ports:
- containerPort: 3333
env:
- name: PORT
value: "3333"
- name: V2_API_PORT
value: "3333"
- name: NODE_ENV
value: "production"
- name: TTS_ENABLED
value: "1"
- name: MASCARADE_URL
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: MASCARADE_URL
- name: MASCARADE_API_KEY
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: MASCARADE_API_KEY
- name: OLLAMA_URL
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: OLLAMA_URL
- name: KOKORO_URL
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: KOKORO_URL
- name: AI_BRIDGE_URL
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: AI_BRIDGE_URL
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: kxkm-secrets
key: ADMIN_TOKEN
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /api/v2/health
port: 3333
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/v2/health
port: 3333
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
persistentVolumeClaim:
claimName: kxkm-data
---
apiVersion: v1
kind: Service
metadata:
name: kxkm-api
namespace: kxkm
spec:
type: NodePort
selector:
app: kxkm-api
ports:
- port: 3333
targetPort: 3333
nodePort: 30333
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: kxkm-data
namespace: kxkm
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: kxkm
labels:
app: kxkm-clown
+66
View File
@@ -0,0 +1,66 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: kxkm
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: kxkm
- name: POSTGRES_USER
value: kxkm
- name: POSTGRES_PASSWORD
value: kxkm
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: kxkm
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: kxkm
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# =============================================================================
# KXKM Clown — Kind/Minikube Setup for Tower
# Run on tower: bash k8s/setup-kind.sh
# =============================================================================
set -e
echo "╔══════════════════════════════════════╗"
echo "║ KXKM K8s Setup (Kind) ║"
echo "╚══════════════════════════════════════╝"
# --- 1. Install Kind ---
if ! command -v kind &>/dev/null; then
echo "▸ Installing Kind..."
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
fi
echo " Kind: $(kind version)"
# --- 2. Install kubectl ---
if ! command -v kubectl &>/dev/null; then
echo "▸ Installing kubectl..."
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
fi
echo " kubectl: $(kubectl version --client --short 2>/dev/null)"
# --- 3. Create cluster ---
echo "▸ Creating Kind cluster 'kxkm'..."
cat <<EOF | kind create cluster --name kxkm --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30333
hostPort: 3333
protocol: TCP
- containerPort: 30080
hostPort: 80
protocol: TCP
EOF
echo "▸ Cluster ready:"
kubectl cluster-info --context kind-kxkm
# --- 4. Create namespace ---
kubectl create namespace kxkm 2>/dev/null || true
kubectl config set-context --current --namespace=kxkm
# --- 5. Create secrets ---
echo "▸ Creating secrets..."
kubectl create secret generic kxkm-secrets \
--from-literal=ADMIN_TOKEN=kxkm \
--from-literal=MASCARADE_API_KEY=5bd6e38378b371c6b627ba5e78821eefd971abf10b515f34b840f00c2f156eb9 \
--from-literal=MASCARADE_URL=http://kxkm-ai:8100 \
--from-literal=OLLAMA_URL=http://kxkm-ai:11434 \
--from-literal=KOKORO_URL=http://kxkm-ai:9201 \
--from-literal=AI_BRIDGE_URL=http://kxkm-ai:8301 \
-n kxkm 2>/dev/null || true
echo ""
echo "▸ Apply manifests:"
echo " kubectl apply -f k8s/manifests/"
echo ""
echo "Done. Cluster 'kxkm' ready."
+5
View File
@@ -39,6 +39,11 @@
"smoke:spikes": "bash ops/v2/run-spike-checks.sh all --yes",
"v2:autoresearch": "node scripts/v2-autoresearch-loop.js --config ops/v2/autoresearch.example.json",
"v2:finetune": "bash scripts/finetune-persona.sh",
"infra:537-540": "bash scripts/infra-lots-537-540.sh --all",
"infra:537": "bash scripts/infra-lot-537-mascarade-systemd.sh",
"infra:538": "bash scripts/infra-lot-538-firewall-persistent.sh",
"infra:539": "bash scripts/infra-lot-539-tailscale-tower.sh",
"infra:540": "bash scripts/infra-lot-540-nginx-https.sh",
"turbo:build": "turbo run build",
"turbo:check": "turbo run check",
"turbo:test": "turbo run test"
+17
View File
@@ -12,6 +12,16 @@ echo ""
echo "--- Systemd User ---"
systemctl --user status kxkm-tts kxkm-lightrag kxkm-reranker kxkm-qwen3-tts --no-pager -l 2>/dev/null | grep -E 'Active:|●'
echo ""
echo "--- Systemd System ---"
sudo systemctl status mascarade nginx netfilter-persistent --no-pager -l 2>/dev/null | grep -E 'Active:|●' || true
echo ""
echo "--- Tailscale ---"
if command -v tailscale >/dev/null 2>&1; then
sudo tailscale status 2>/dev/null | head -20 || true
else
echo " tailscale: not installed"
fi
echo ""
echo "--- Journal ---"
journalctl --user --disk-usage 2>/dev/null || echo " (no user journal)"
echo ""
@@ -22,3 +32,10 @@ for svc in "API:http://localhost:3333/api/v2/health" "TTS:http://localhost:9100/
status=$(curl -sf -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
[ "$status" = "200" ] && echo " $name: OK" || echo " $name: FAIL ($status)"
done
for svc in "Mascarade:http://localhost:8100/health" "Nginx:http://localhost/"; do
name="${svc%%:*}"
url="${svc#*:}"
status=$(curl -sf -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
[ "$status" = "200" -o "$status" = "301" -o "$status" = "302" ] && echo " $name: OK" || echo " $name: FAIL ($status)"
done