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
18 changes: 18 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1544,3 +1544,21 @@ Decided:
getSchema() and the published schema files are unchanged. Not measured with the LLM eval. checkScore of a
score that sings now also checks NEUTRINO and its voice (an error on the sung track), so a model
can find out whether it can sing before rendering instead of guessing.
- 2026-10-27 — The pending card (#49, #50, #51). User: the rendering note should be at the top in
both views; NEUTRINO takes the longest, so show how far it is; the sheet music cannot be
scrolled by hand before playback. NEUTRINO prints `progress = N %` with `-t`; checked on the
real one that it arrives during the run through a pipe (8 s song: 8 % at 1.1 s … 100 % at
13.7 s). It now runs with `-t` and its output in a file in the work folder; the worker thread
is blocked while it runs, so it sends the file's path and the pool reads it every 500 ms.
`render()` takes `onProgress` (singing 0…1, then mixing; in place only the two stages); it is
only reported and never touches the audio. `-t` itself: NEUTRINO's output differs run to run
by about as much as the signal (that is why it is cached), and with `-t` it differs by the same,
so no effect could be seen. manage's deferred render keeps the latest progress
(`deferredRenderProgress`); the plugin's view asks for it once a second (a `renderProgress`
request beside `collectRenderedAudio`) and shows the status, or the failure, under the title.
The sheet music would not scroll: MulmoChat's box is `overflow: hidden` and the card did not
scroll itself (during playback OSMD's `scrollIntoView` moved the clipped box, which looked like
scrolling). The card is now `height: 100%; overflow-y: auto`. Browser check with the demo's
stand-in runtime: status right under the title, 0 % → 84 % → audio; sheet music in a 300 px
box scrolled 400 px with the wheel; no console errors. Both packages 1.1.0 (new `onProgress`
and `deferredRenderProgress`; the plugin's peer ^1.1.0).
5 changes: 3 additions & 2 deletions gui-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ the data also carries the score that was rendered. Every other action answers th
to minutes. In a host that gives the view a runtime (`dispatch`) and tells `execute` so with
`deferredAudio: true` in its context, `renderScore` waits at most a second for the audio: a short
jingle comes back finished as before; a longer one comes back at once as a `pending` card that
shows the score, its lanes, cues, lyrics and sheet music, says "Rendering the audio…", and keeps
play disabled. The view collects the finished audio through the host's dispatch (a request that
shows the score, its lanes, cues, lyrics and sheet music, says at the top that the audio is
rendering, and keeps play disabled. While NEUTRINO sings, it says how far it is ("Singing the
vocal… 52 %", then "Mixing…"), asked once a second through the same dispatch. The view collects the finished audio through the host's dispatch (a request that
reaches the same `execute`), saves it into the result with `onUpdateResult` (so the chat keeps it),
and only then plays. `autoplay` waits for the audio in the same way, and counts as fresh from when
it was ready; the voice rules above still apply. If the audio cannot be made after all, the card
Expand Down
29 changes: 20 additions & 9 deletions gui-plugin/demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from "zod";
// The schema modules alone: the library's index needs Node (ffmpeg), which a page cannot load.
import { ScoreBaseSchema } from "../../src/score-schema.ts";
import { TimingSchema } from "../../src/timing.ts";
import { PROGRESS_REQUEST } from "../src/audio-request.ts";
import Preview from "../src/vue/Preview.vue";
import View from "../src/vue/View.vue";

Expand Down Expand Up @@ -62,21 +63,31 @@ const collectedById = new Map(
parsed.data.flatMap((sample) => (sample.data.pending && sample.collected ? [[sample.data.pending, sample.collected] as const] : [])),
);

/** A stand-in for the host's runtime: its dispatch answers a pending card's request for its audio. */
const loadedAt = Date.now();

/** How far the stand-in render is: NEUTRINO singing for most of COLLECT_MS, then the mix. */
function standInProgress(): object {
const done = (Date.now() - loadedAt) / (0.8 * COLLECT_MS);
return done < 1 ? { progress: { stage: "singing", done }, message: "" } : { progress: { stage: "mixing" }, message: "" };
}

/** A stand-in for the host's runtime: its dispatch answers a pending card's requests for its audio and its progress. */
const runtime: BrowserPluginRuntime = {
pubsub: { subscribe: () => () => undefined },
locale: ref("en"),
log: { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined },
openUrl: (url: string) => window.open(url, "_blank", "noopener,noreferrer"),
dispatch: (args: object) =>
new Promise((resolve) => {
const id: unknown = Reflect.get(args, "id");
const data = typeof id === "string" ? collectedById.get(id) : undefined;
setTimeout(
() => resolve(data ? { outcome: "ready", data, message: "The audio is ready." } : { outcome: "gone", message: "Not in this demo." }),
COLLECT_MS,
);
}),
Reflect.get(args, "kind") === PROGRESS_REQUEST
? Promise.resolve(standInProgress())
: new Promise((resolve) => {
const id: unknown = Reflect.get(args, "id");
const data = typeof id === "string" ? collectedById.get(id) : undefined;
setTimeout(
() => resolve(data ? { outcome: "ready", data, message: "The audio is ready." } : { outcome: "gone", message: "Not in this demo." }),
COLLECT_MS,
);
}),
};

/** What the views would send the LLM, listed on the page. */
Expand Down
4 changes: 2 additions & 2 deletions gui-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gui-chat-plugin/jinglescript",
"version": "1.0.3",
"version": "1.1.0",
"description": "GUI Chat Protocol plugin for JingleScript: the manageJingleScript tool, with a player view for rendered jingles (waveform, cues, beats, notes).",
"keywords": [
"gui-chat-protocol",
Expand Down Expand Up @@ -55,7 +55,7 @@
},
"peerDependencies": {
"gui-chat-protocol": "^2.3.0",
"jinglescript": "^1.0.3",
"jinglescript": "^1.1.0",
"vue": "^3.5.43"
},
"devDependencies": {
Expand Down
33 changes: 31 additions & 2 deletions gui-plugin/src/audio-request.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// The view's request for audio that was still rendering when its card appeared (a `pending`
// PlayerData), and the answer. The view sends it through the host's runtime dispatch, which reaches
// PlayerData), and the answer; and its request for how far that render is, asked while it waits. The view sends it through the host's runtime dispatch, which reaches
// the same execute as the LLM's tool calls; execute answers it before reading the call as a tool
// call. Shared by both entries, so the two sides cannot drift apart.
import type { PlayerData } from "jinglescript";
import type { PlayerData, RenderProgress } from "jinglescript";

/** `kind` of the view's request; not a manageJingleScript action, so the LLM never sends it. */
export const AUDIO_REQUEST = "collectRenderedAudio";
Expand All @@ -16,6 +16,21 @@ export interface AudioRequest {
/** What execute answers: the finished PlayerData, why the render failed, or that it is not on the server (any more). */
export type AudioReply = { outcome: "ready"; data: PlayerData; message: string } | { outcome: "failed" | "gone"; message: string };

/** `kind` of the view's request for how far a pending render is; answered at once. */
export const PROGRESS_REQUEST = "renderProgress";

export interface ProgressRequest {
kind: typeof PROGRESS_REQUEST;
/** The pending PlayerData's `pending` id. */
id: string;
}

/** What execute answers: how far the render is, when it has said (nothing for a render without a sung track, or one that is done or gone). */
export interface ProgressReply {
progress?: RenderProgress;
message: string;
}

const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;

export function isAudioRequest(args: unknown): args is AudioRequest {
Expand All @@ -42,3 +57,17 @@ export function parseAudioReply(raw: unknown): AudioReply | undefined {
if (raw["outcome"] === "failed" || raw["outcome"] === "gone") return { outcome: raw["outcome"], message };
return undefined;
}

export function isProgressRequest(args: unknown): args is ProgressRequest {
return isRecord(args) && args["kind"] === PROGRESS_REQUEST && typeof args["id"] === "string";
}

/** The progress in a reply as the view receives it, checked; undefined when there is none. */
export function parseProgressReply(raw: unknown): RenderProgress | undefined {
const progress = isRecord(raw) ? raw["progress"] : undefined;
if (!isRecord(progress)) return undefined;
if (progress["stage"] === "mixing") return { stage: "mixing" };
const done = progress["done"];
if (progress["stage"] === "singing" && typeof done === "number" && done >= 0 && done <= 1) return { stage: "singing", done };
return undefined;
}
16 changes: 11 additions & 5 deletions gui-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
// A score `path` is read through the host's `files.byPath` (as presentDocument's is): the host
// decides what a path may reach and what a relative one means. Without it, `path` is refused.
// A host whose view can call back (`deferredAudio`) gets the card before a long render's audio:
// the view collects it with an AudioRequest, answered here.
// the view collects it with an AudioRequest, answered here, and asks how far it is meanwhile.
import type { FileOps, ToolContext, ToolPluginCore, ToolResult } from "gui-chat-protocol";
import { awaitDeferredPlayer, MANAGE_TOOL, ManageInputSchema, manage, type PlayerData } from "jinglescript";
import { isAudioRequest, type AudioReply, type AudioRequest } from "./audio-request.ts";
import { awaitDeferredPlayer, deferredRenderProgress, MANAGE_TOOL, ManageInputSchema, manage, type PlayerData } from "jinglescript";
import { isAudioRequest, isProgressRequest, type AudioReply, type AudioRequest, type ProgressReply, type ProgressRequest } from "./audio-request.ts";
import { TOOL_DEFINITION } from "./definition.ts";

export { TOOL_DEFINITION };
Expand Down Expand Up @@ -38,6 +38,12 @@ export async function collectAudio(request: AudioRequest): Promise<AudioReply> {
}
}

/** The answer to a view's ProgressRequest: how far the render it names is, at once. */
export function renderProgress(request: ProgressRequest): Promise<ProgressReply> {
const progress = deferredRenderProgress(request.id);
return Promise.resolve(progress === undefined ? { message: "No progress to tell." } : { progress, message: `Rendering: ${progress.stage}.` });
}

export async function executeManage(context: ManageContext, args: object): Promise<ToolResult<PlayerData, never>> {
const input = ManageInputSchema.safeParse(args);
if (!input.success) {
Expand Down Expand Up @@ -77,8 +83,8 @@ export function instructionsFor(player: PlayerData): string {

export const pluginCore: ToolPluginCore<PlayerData, never, object> = {
toolDefinition: TOOL_DEFINITION,
// The view's AudioRequest comes through the same execute (the host's dispatch route) as the tool calls.
execute: (context, args) => (isAudioRequest(args) ? collectAudio(args) : executeManage(context, args)),
// The view's requests come through the same execute (the host's dispatch route) as the tool calls.
execute: (context, args) => (isAudioRequest(args) ? collectAudio(args) : isProgressRequest(args) ? renderProgress(args) : executeManage(context, args)),
generatingMessage: "Rendering the jingle…",
isEnabled: () => true,
};
56 changes: 48 additions & 8 deletions gui-plugin/src/vue/View.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@
// host (`isAudioPlaying`), what plays by itself waits until the voice has been quiet a moment, and
// pauses while it speaks again. A card shown while its audio still renders (`pending`) shows the
// score at once, collects the audio through the host's runtime dispatch, keeps it in the result, and
// only then can play (by itself too, when that was asked for).
// only then can play (by itself too, when that was asked for); meanwhile it asks how far the render
// is and says so at the top. The card scrolls itself: a host may give it a box that does not.
import type { ToolResult, ToolResultComplete } from "gui-chat-protocol";
import { PLUGIN_RUNTIME_KEY } from "gui-chat-protocol/vue";
import type { PlayerData, PlayerView } from "jinglescript";
import type { PlayerData, PlayerView, RenderProgress } from "jinglescript";
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AUDIO_REQUEST, parseAudioReply } from "../audio-request.ts";
import { AUDIO_REQUEST, parseAudioReply, parseProgressReply, PROGRESS_REQUEST } from "../audio-request.ts";
import { formatTime, karaoke, lanes, waveformPath, WIDTH, xOf } from "./layout.ts";
import { failedMessage, finishedMessage, memoryOf, quietWait, shouldAutoplay, VIEW_LABELS, type PlayerMemory, type VoiceState } from "./playback.ts";
import {
failedMessage,
finishedMessage,
memoryOf,
PROGRESS_POLL_MS,
quietWait,
renderingLabel,
shouldAutoplay,
VIEW_LABELS,
type PlayerMemory,
type VoiceState,
} from "./playback.ts";

// The default keeps a host that never passes isAudioPlaying apart from one whose voice is quiet:
// Vue would otherwise turn a missing boolean prop into false.
Expand All @@ -39,6 +51,9 @@ const collected = ref<PlayerData | undefined>(undefined);
const failure = ref<string | undefined>(undefined);
const data = computed(() => collected.value ?? props.selectedResult.data);
const pending = computed(() => data.value?.pending !== undefined);
/** How far a pending card's render is, as the server last said. */
const renderProgress = ref<RenderProgress | undefined>(undefined);
let progressTimer: ReturnType<typeof setTimeout> | undefined;
const memory = ref<PlayerMemory>(memoryOf(props.selectedResult.viewState));
/** The view a result opens in: as last shown, else as the LLM asked; the waveform when there is no sheet music. */
const firstView = (): PlayerView => (data.value?.musicxml === undefined ? "waveform" : (memory.value.view ?? data.value.presentation?.view ?? "waveform"));
Expand Down Expand Up @@ -178,6 +193,20 @@ async function collect(): Promise<void> {
if (!disposed) autoplay();
}

/** Asks how far the pending render is, again and again until the card has its audio or failed. */
async function followProgress(): Promise<void> {
clearTimeout(progressTimer);
const id = data.value?.pending;
if (id === undefined || runtime === null || disposed || failure.value !== undefined) return;
try {
const progress = parseProgressReply(await runtime.dispatch({ kind: PROGRESS_REQUEST, id }));
if (data.value?.pending === id) renderProgress.value = progress;
} catch {
// only the label is missing: the audio still comes through collect()
}
if (!disposed && data.value?.pending === id && failure.value === undefined) progressTimer = setTimeout(() => void followProgress(), PROGRESS_POLL_MS);
}

/** No audio after all: shown in the card, and told to the LLM once when it expected it to play. */
function fail(reason: string): void {
failure.value = reason;
Expand All @@ -191,8 +220,11 @@ function fail(reason: string): void {
/** What a card does when it appears: collect its audio, or play by itself when asked. */
function start(): void {
appeared();
if (pending.value) void collect();
else autoplay();
renderProgress.value = undefined;
if (pending.value) {
void collect();
void followProgress();
} else autoplay();
}

/** Plays what plays by itself once the voice has been quiet long enough; pauses it while the voice speaks. */
Expand Down Expand Up @@ -266,6 +298,7 @@ onBeforeUnmount(() => {
disposed = true;
cancelAnimationFrame(frame);
clearTimeout(quietTimer);
clearTimeout(progressTimer);
});
</script>

Expand All @@ -289,6 +322,9 @@ onBeforeUnmount(() => {
</span>
</header>

<p v-if="failure" class="status failure" role="alert">{{ failure }}</p>
<p v-else-if="pending" class="status rendering" role="status">{{ renderingLabel(renderProgress) }}</p>

<template v-if="view === 'notation' && data.musicxml !== undefined">
<Notation :musicxml="data.musicxml" :tempo="data.timing.tempo" :now="now" />
<div
Expand Down Expand Up @@ -360,8 +396,6 @@ onBeforeUnmount(() => {
<svg v-else viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2 L14 8 L4 14 Z" /></svg>
</button>
<span class="time">{{ formatTime(now) }} / {{ formatTime(data.duration) }}</span>
<span v-if="failure" class="failure">{{ failure }}</span>
<span v-else-if="pending" class="rendering" role="status">Rendering the audio…</span>
<span v-if="blocked" class="blocked">Press play to listen</span>
<span class="downloads">
<a v-if="!pending" :href="data.audio" :download="`${data.title}.${extension}`">Download {{ extension.toUpperCase() }}</a>
Expand Down Expand Up @@ -397,6 +431,9 @@ onBeforeUnmount(() => {
padding: 16px;
box-sizing: border-box;
width: 100%;
/* A host's box may clip (overflow: hidden): long sheet music scrolls here. */
height: 100%;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.jingle {
Expand Down Expand Up @@ -593,6 +630,9 @@ h2 {
height: 100%;
background: var(--wave);
}
.status {
margin: 0 0 10px;
}
.blocked,
.failure {
color: var(--cue);
Expand Down
12 changes: 11 additions & 1 deletion gui-plugin/src/vue/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// heard, what it remembers, and where the sheet-music cursor stands. Hosts mount every card of a
// chat and remount them often (a reload, another session, the pane reopened), so what happened
// once is kept in the result's viewState, and only a fresh render plays by itself.
import type { Presentation } from "jinglescript";
import type { Presentation, RenderProgress } from "jinglescript";

/** A render older than this is history: shown again, never autoplayed. */
export const FRESH_MS = 120_000;
Expand Down Expand Up @@ -108,3 +108,13 @@ export function cursorIndex(positions: readonly number[], seconds: number): numb
}
return found;
}

/** How often a pending card asks how far its render is, ms. */
export const PROGRESS_POLL_MS = 1000;

/** What a pending card says while its audio renders, with how far it is when the render has said. */
export function renderingLabel(progress: RenderProgress | undefined): string {
if (progress?.stage === "singing") return `Singing the vocal… ${Math.floor(progress.done * 100)} %`;
if (progress?.stage === "mixing") return "Mixing the vocal with the accompaniment…";
return "Rendering the audio…";
}
9 changes: 9 additions & 0 deletions gui-plugin/tests/playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
QUIET_MS,
quietWait,
REPLY_MS,
renderingLabel,
shouldAutoplay,
type VoiceState,
} from "../src/vue/playback.ts";
Expand Down Expand Up @@ -109,6 +110,14 @@ describe("the message when a pending card's audio could not be made", () => {
});
});

describe("what a pending card says", () => {
it("says how far NEUTRINO is, then that it mixes; without word from the render, only that it renders", () => {
expect(renderingLabel({ stage: "singing", done: 0.526 })).toBe("Singing the vocal… 52 %");
expect(renderingLabel({ stage: "mixing" })).toBe("Mixing the vocal with the accompaniment…");
expect(renderingLabel(undefined)).toBe("Rendering the audio…");
});
});

describe("memoryOf", () => {
it("reads only what it wrote, and ignores anything else in viewState", () => {
expect(memoryOf({ autoplayed: true, notified: "yes", view: "notation", other: 1 })).toEqual({
Expand Down
Loading
Loading