yue2-studio / src /components /reference-drop.tsx
RalphThings's picture
Upload folder using huggingface_hub
76528e0 verified
Raw History Blame Contribute Delete
12.8 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { MusicNotes, Pause, Play, Trash, UploadSimple, Warning } from "@phosphor-icons/react";
import { Panel, Switch } from "@/components/ui";
import { Waveform, useAudioPeaks } from "@/components/waveform";
import { formatClock } from "@/lib/presets";
export interface ReferenceState {
file: File | null;
filename: string | null;
status: "empty" | "uploading" | "ready" | "failed";
error: string | null;
/** Seconds into the file to start transcribing. */
start: number;
/** Seconds to transcribe, or null for the whole thing. */
duration: number | null;
}
export const EMPTY_REFERENCE: ReferenceState = {
file: null,
filename: null,
status: "empty",
error: null,
start: 0,
duration: null,
};
const ACCEPTED = ".wav,.mp3,.flac,.ogg,.m4a,.aac,.opus,.webm";
export function ReferenceDrop({
state,
onChange,
}: {
state: ReferenceState;
onChange: (next: ReferenceState) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const { peaks, duration, failed } = useAudioPeaks(state.file, 420);
const preview = useLocalPlayback(state.file);
const accept = useCallback(
async (file: File) => {
onChange({ file, filename: null, status: "uploading", error: null, start: 0, duration: null });
const form = new FormData();
form.append("file", file);
try {
const res = await fetch("/api/upload", { method: "POST", body: form });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Upload failed");
onChange({ file, filename: body.filename, status: "ready", error: null, start: 0, duration: null });
} catch (error) {
onChange({
file,
filename: null,
status: "failed",
error: error instanceof Error ? error.message : "Upload failed",
start: 0,
duration: null,
});
}
},
[onChange],
);
if (state.file) {
return (
<Panel
title="Reference recording"
hint="Transcribed to a melody score, then re-sung."
aside={
<button
type="button"
onClick={() => onChange(EMPTY_REFERENCE)}
className="flex items-center gap-1.5 text-[12px] text-ink-faint transition-colors hover:text-alarm"
>
<Trash size={13} weight="bold" />
Remove
</button>
}
>
<div className="flex items-start gap-3">
<MusicNotes size={18} weight="fill" className="mt-0.5 shrink-0 text-citron" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] text-ink">{state.file.name}</p>
<p className="mt-0.5 font-mono text-[11.5px] text-ink-faint">
{(state.file.size / 1024 / 1024).toFixed(1)} MB
{duration > 0 ? ` ${formatClock(duration)}` : ""}
</p>
</div>
<StatusBadge status={state.status} />
</div>
<div className="mt-3 rounded-console border border-line bg-void px-3 py-2.5">
{failed ? (
<p className="py-3 text-[12px] text-ink-faint">
This browser could not decode the file for preview. ComfyUI will still read it.
</p>
) : (
<div className="flex items-center gap-3">
{/*
The second of the two circular play buttons, deliberately
smaller than the dock's so the transport stays the primary one.
*/}
<button
type="button"
onClick={preview.toggle}
aria-label={preview.playing ? "Pause the reference" : "Play the reference"}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-citron text-void transition-transform duration-150 active:scale-95"
>
{preview.playing ? (
<Pause size={12} weight="fill" />
) : (
<Play size={12} weight="fill" />
)}
</button>
<div className="min-w-0 flex-1">
<Waveform
peaks={peaks}
progress={preview.progress}
onSeek={preview.seek}
height={40}
/>
</div>
<span className="shrink-0 font-mono text-[11px] text-ink-faint">
{formatClock(preview.position)} / {formatClock(preview.length || duration)}
</span>
</div>
)}
</div>
{preview.error ? (
<p className="mt-2 text-[12px] text-alarm">{preview.error}</p>
) : null}
<div className="mt-3 border-t border-line pt-3">
<Switch
checked={state.duration !== null || state.start > 0}
onChange={(on) =>
onChange({
...state,
start: 0,
duration: on ? Math.min(120, Math.round(duration) || 120) : null,
})
}
label="Transcribe only part of it"
/>
<p className="mt-1 text-[11.5px] leading-snug text-ink-faint">
SheetSage2 reads the whole file by default. A long or busy recording can
contain a note it cannot place on its grid, which fails the render.
</p>
{state.duration !== null || state.start > 0 ? (
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] tracking-[0.06em] text-ink-dim uppercase">From</span>
<input
type="number"
min={0}
max={Math.max(0, Math.floor(duration))}
value={state.start}
onChange={(event) => {
const limit = duration > 0 ? Math.floor(duration) - 1 : Infinity;
const start = Math.min(limit, Math.max(0, Number(event.target.value)));
onChange({ ...state, start });
}}
className="h-8 w-24 rounded-console border border-line-strong bg-void px-2 font-mono text-[12px] text-ink"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] tracking-[0.06em] text-ink-dim uppercase">
Seconds
</span>
<input
type="number"
min={1}
max={duration > 0 ? Math.ceil(duration) : undefined}
value={state.duration ?? 60}
onChange={(event) => {
const remaining = duration > 0 ? Math.ceil(duration) - state.start : Infinity;
const seconds = Math.min(remaining, Math.max(1, Number(event.target.value)));
onChange({ ...state, duration: seconds });
}}
className="h-8 w-24 rounded-console border border-line-strong bg-void px-2 font-mono text-[12px] text-ink"
/>
</label>
<p className="pb-1.5 font-mono text-[11.5px] text-ink-faint">
{formatClock(state.start)} to{" "}
{formatClock(state.start + (state.duration ?? 0))}
{duration > 0 ? ` of ${formatClock(duration)}` : ""}
</p>
</div>
) : null}
</div>
{preview.url ? (
<audio
ref={preview.ref}
src={preview.url}
preload="metadata"
onPlay={() => preview.setPlaying(true)}
onPause={() => preview.setPlaying(false)}
onTimeUpdate={(event) => preview.setPosition(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => {
const value = event.currentTarget.duration;
if (Number.isFinite(value) && value > 0) preview.setLength(value);
}}
onEnded={() => {
preview.setPlaying(false);
preview.setPosition(0);
}}
onError={() => preview.setError("This browser will not play that file.")}
/>
) : null}
{state.error ? (
<p className="mt-2 flex items-start gap-1.5 text-[12px] text-alarm">
<Warning size={13} weight="fill" className="mt-0.5 shrink-0" />
{state.error}
</p>
) : null}
</Panel>
);
}
return (
<Panel title="Reference recording" hint="Optional. Keeps the tune, changes everything else.">
<div
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files?.[0];
if (file) void accept(file);
}}
className={`flex flex-col items-center justify-center rounded-console border border-dashed px-6 py-9 text-center transition-colors duration-150 ${
dragging ? "border-citron bg-citron/5" : "border-line-strong bg-void"
}`}
>
<UploadSimple size={20} weight="bold" className={dragging ? "text-citron" : "text-ink-faint"} />
<p className="mt-3 text-[13px] text-ink">Drop a song here</p>
<p className="mt-1 max-w-[42ch] text-[12px] leading-snug text-ink-faint">
SheetSage2 reads the melody into a score. YuE2 then performs your lyrics over that melody
in the style you wrote.
</p>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="mt-4 h-8 rounded-console border border-line-strong px-3 text-[12.5px] text-ink transition-colors hover:border-ink-faint hover:bg-raised"
>
Choose a file
</button>
<input
ref={inputRef}
type="file"
accept={ACCEPTED}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void accept(file);
event.target.value = "";
}}
/>
</div>
</Panel>
);
}
function StatusBadge({ status }: { status: ReferenceState["status"] }) {
if (status === "uploading") {
return (
<span className="shrink-0 rounded-console border border-line-strong px-2 py-0.5 font-mono text-[11px] text-ink-dim">
sending
</span>
);
}
if (status === "ready") {
return (
<span className="shrink-0 rounded-console border border-citron/40 bg-citron/10 px-2 py-0.5 font-mono text-[11px] text-citron">
on the box
</span>
);
}
if (status === "failed") {
return (
<span className="shrink-0 rounded-console border border-alarm/40 bg-alarm/10 px-2 py-0.5 font-mono text-[11px] text-alarm">
failed
</span>
);
}
return null;
}
/**
* Plays the picked file straight from memory, before it ever leaves the
* browser. Hearing the reference matters: it is what decides the melody the
* model will sing, so uploading one blind is a bad way to work.
*/
function useLocalPlayback(file: File | null) {
const ref = useRef<HTMLAudioElement>(null);
const [url, setUrl] = useState<string | null>(null);
const [playing, setPlaying] = useState(false);
const [position, setPosition] = useState(0);
const [length, setLength] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!file) {
setUrl(null);
return;
}
const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl);
setPlaying(false);
setPosition(0);
setLength(0);
setError(null);
// An object URL pins the file in memory until it is released.
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
const toggle = () => {
const element = ref.current;
if (!element) return;
if (!element.paused) {
element.pause();
return;
}
element.play().catch(() => setError("This browser will not play that file."));
};
const seek = (ratio: number) => {
const element = ref.current;
if (!element || !length) return;
element.currentTime = ratio * length;
setPosition(ratio * length);
};
return {
ref,
url,
playing,
position,
length,
error,
progress: length > 0 ? Math.min(1, position / length) : 0,
toggle,
seek,
setPlaying,
setPosition,
setLength,
setError,
};
}