Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ members = [
]

[workspace.package]
version = "0.2.84"
version = "0.2.85"
edition = "2024"
license = "MIT"
repository = "https://github.com/profullstack/c0mpute"
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@c0mpute/tui",
"version": "0.2.84",
"version": "0.2.85",
"private": true,
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "c0mpute",
"version": "0.2.84",
"version": "0.2.85",
"private": true,
"type": "module",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/v1/music/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isJobId } from "@/lib/chat/images";
import { AUDIO_TYPES } from "@/lib/chat/music";
import { AUDIO_TYPES, sanitizeNodeProgress } from "@/lib/chat/music";
import { currentUser, infernet, json, unauthorized } from "@/lib/chat/server";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -33,7 +33,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
// Infernet writes lyrics from a brief first; pass what it wrote along.
const pick = (k: string) => (typeof body[k] === "string" || typeof body[k] === "number" ? body[k] : undefined);
return json(
{ id, status: "pending", seconds: body.seconds ?? null, stage: pick("stage"), caption: pick("caption"), lyrics: pick("lyrics"), duration: pick("duration") },
{ id, status: "pending", seconds: body.seconds ?? null, stage: pick("stage"), caption: pick("caption"), lyrics: pick("lyrics"), duration: pick("duration"), node: sanitizeNodeProgress(body.node) },
202,
{ "cache-control": "no-store" },
);
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/app/chat/chat-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CodeBlock } from "@/components/CodeBlock";
import { parseImageCommand, textHistory } from "@/lib/chat/images";
import { audioExtension, parseMusicCommand, type MusicCommand } from "@/lib/chat/music";
import { audioExtension, describeNodeProgress, parseMusicCommand, sanitizeNodeProgress, type MusicCommand } from "@/lib/chat/music";

// An image turn keeps its picture only for this session (a blob: URL); what is
// saved to localStorage is the text placeholder, and the model never sees it.
Expand Down Expand Up @@ -280,12 +280,14 @@ export function ChatApp({ error }: { error: string | null }) {
const p = await poll.json().catch(() => ({}));
const secs = Math.round((Date.now() - started) / 1000);
if (typeof p.caption === "string") written = { caption: p.caption, lyrics: typeof p.lyrics === "string" ? p.lyrics : "" };
setLast({
content:
p.stage === "writing"
? `// writing lyrics and a style from your brief… ${secs}s`
: `// composing with ACE-Step 1.5 on a c0mpute worker… ${secs}s${written?.lyrics ? `\n\n${written.lyrics}` : ""}`,
});
const node = sanitizeNodeProgress(p.node);
const head =
p.stage === "writing"
? `// writing lyrics and a style from your brief… ${secs}s`
: `// ${node ? describeNodeProgress(node) : `composing with ACE-Step 1.5 on a c0mpute worker…`} · ${secs}s total${
typeof p.duration === "number" ? ` · ${p.duration}s song` : ""
}`;
setLast({ content: `${head}${written?.lyrics ? `\n\n${written.lyrics}` : ""}` });
continue;
}
if (!poll.ok) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/releases/latest.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const dynamic = "force-dynamic";
* this in sync with the workspace version in `Cargo.toml` on each release so a
* freshly built CLI reports "already latest" rather than a phantom upgrade.
*/
const FALLBACK_VERSION = "0.2.84";
const FALLBACK_VERSION = "0.2.85";
const FALLBACK_MIN_REQUIRED = "0.0.1";

type Channel = "stable" | "beta" | "nightly";
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/lib/chat/music.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { audioExtension, parseMusicCommand, parseMusicRequest } from "./music";
import { audioExtension, describeNodeProgress, parseMusicCommand, parseMusicRequest, sanitizeNodeProgress } from "./music";

describe("chat music", () => {
test("parses /music and /song, with optional lyrics", () => {
Expand All @@ -12,12 +12,12 @@ describe("chat music", () => {
expect(parseMusicCommand("play music")).toBeNull();
});
test("validates the request", () => {
expect(parseMusicRequest({ prompt: " jazz " })).toEqual({ prompt: "jazz", lyrics: "", duration: 30 });
expect(parseMusicRequest({ prompt: " jazz " })).toEqual({ prompt: "jazz", lyrics: "" });
expect(parseMusicRequest({ prompt: "x", duration: 60, lyrics: "hi" })).toEqual({ prompt: "x", lyrics: "hi", duration: 60 });
expect(typeof parseMusicRequest({ prompt: "x", duration: 5 })).toBe("string");
expect(typeof parseMusicRequest({ prompt: "x", duration: 200 })).toBe("string");
expect(typeof parseMusicRequest({ prompt: "" })).toBe("string");
expect(parseMusicRequest({ prompt: "x".repeat(100_000), lyrics: "y".repeat(100_000) })).toMatchObject({ duration: 30 });
expect(parseMusicRequest({ prompt: "x".repeat(100_000), lyrics: "y".repeat(100_000) })).toMatchObject({ lyrics: "y".repeat(100_000) });
expect(typeof parseMusicRequest({ prompt: "x".repeat(100_001) })).toBe("string");
expect(typeof parseMusicRequest(null)).toBe("string");
});
Expand All @@ -26,4 +26,13 @@ describe("chat music", () => {
expect(audioExtension("audio/wav")).toBe("wav");
expect(audioExtension("")).toBe("mp3");
});
test("node progress is sanitized and described", () => {
expect(sanitizeNodeProgress({ stage: "evil" })).toBeUndefined();
expect(sanitizeNodeProgress(null)).toBeUndefined();
const p = sanitizeNodeProgress({ stage: "rendering", worker: "12D3KooWG9TmqCHN63oFoxb2bwTGRYqwW8e1C2Ua9e14XtPja8R9", eta_s: 600, stage_elapsed_s: 125, attempt: 1, junk: 1 });
expect(p).toMatchObject({ stage: "rendering", eta_s: 600 });
expect(describeNodeProgress(p!)).toBe("rendering on node …XtPja8R9 · 2:05 in · ~7:55 left (node's estimate)");
expect(describeNodeProgress({ stage: "queued", stage_elapsed_s: 61 })).toBe("every music worker is busy; queued 1:01");
expect(describeNodeProgress({ stage: "retrying", worker: "abcdefgh12345678", detail: "worker failed" })).toContain("trying another worker");
});
});
67 changes: 65 additions & 2 deletions apps/web/src/lib/chat/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,83 @@ export const MAX_SECONDS = 180;
/** Validate a POST body; returns the body to forward or an error string. */
export function parseMusicRequest(
body: unknown,
): { prompt: string; lyrics: string; duration: number } | string {
): { prompt: string; lyrics: string; duration?: number } | string {
const b = (body ?? {}) as Record<string, unknown>;
const prompt = typeof b.prompt === "string" ? b.prompt.trim() : "";
// Long briefs and whole songs are fine; Infernet clips what the model reads.
if (!prompt || prompt.length > MAX_TEXT_CHARS) return `prompt must contain 1 to ${MAX_TEXT_CHARS} characters`;
const lyrics = typeof b.lyrics === "string" ? b.lyrics.trim() : "";
if (lyrics.length > MAX_TEXT_CHARS) return `lyrics must be at most ${MAX_TEXT_CHARS} characters`;
const duration = b.duration === undefined || b.duration === null ? 30 : Number(b.duration);
// No duration means "whatever the brief asks for": Infernet reads a length
// from the prompt ("150-second song") or lets its writer pick. Defaulting
// to 30 here overrode that and every c0mpute.com song came out 30 s.
if (b.duration === undefined || b.duration === null) return { prompt, lyrics };
const duration = Number(b.duration);
if (!Number.isInteger(duration) || duration < MIN_SECONDS || duration > MAX_SECONDS) {
return `duration must be a whole number of seconds from ${MIN_SECONDS} to ${MAX_SECONDS}`;
}
return { prompt, lyrics, duration };
}

/** What the c0mpute node doing a song reports, relayed by Infernet. */
export type NodeProgress = {
stage: string;
worker?: string;
eta_s?: number;
elapsed_s?: number;
stage_elapsed_s?: number;
attempt?: number;
detail?: string;
};

const STAGES = ["starting", "finding_worker", "queued", "rendering", "fetching_result", "retrying", "done", "failed"];

/** Keep only known, well-typed fields of an upstream progress object. */
export function sanitizeNodeProgress(v: unknown): NodeProgress | undefined {
if (!v || typeof v !== "object") return undefined;
const o = v as Record<string, unknown>;
if (typeof o.stage !== "string" || !STAGES.includes(o.stage)) return undefined;
const num = (k: string) => (typeof o[k] === "number" && Number.isFinite(o[k]) && (o[k] as number) >= 0 ? (o[k] as number) : undefined);
const str = (k: string, max: number) => (typeof o[k] === "string" ? (o[k] as string).slice(0, max) : undefined);
return {
stage: o.stage,
worker: str("worker", 80),
eta_s: num("eta_s"),
elapsed_s: num("elapsed_s"),
stage_elapsed_s: num("stage_elapsed_s"),
attempt: num("attempt"),
detail: str("detail", 200),
};
}

const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

/** One line for the chat: what the network is doing with this song now. */
export function describeNodeProgress(p: NodeProgress): string {
const node = p.worker ? `node …${p.worker.slice(-8)}` : "a node";
const retry = (p.attempt ?? 1) > 1 ? ` (attempt ${p.attempt})` : "";
switch (p.stage) {
case "starting":
case "finding_worker":
return `finding a c0mpute music worker${retry}`;
case "queued":
return `every music worker is busy; queued ${mmss(p.stage_elapsed_s ?? 0)}`;
case "rendering": {
const left = p.eta_s !== undefined ? Math.max(0, p.eta_s - (p.stage_elapsed_s ?? 0)) : undefined;
const eta = left === undefined ? "" : left > 0 ? ` · ~${mmss(left)} left (node's estimate)` : " · past the node's estimate, still working";
return `rendering on ${node}${retry} · ${mmss(p.stage_elapsed_s ?? 0)} in${eta}`;
}
case "fetching_result":
return `${node} finished; fetching the song`;
case "retrying":
return `${node} failed${p.detail ? ` (${p.detail})` : ""}; trying another worker`;
case "done":
return "song ready";
default:
return "song failed";
}
}

export const AUDIO_TYPES = ["audio/mpeg", "audio/wav", "audio/flac", "audio/ogg"];

export function audioExtension(type: string): string {
Expand Down
Loading
Loading