Spaces:
Runtime error
Runtime error
File size: 15,638 Bytes
fa8df4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | import { useEffect, useRef, useState, type RefObject } from "react";
import { artifactsApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
import type { Artifact, ChatSummary, Segment, VideoSavedVoice } from "../api/types";
import { useJob } from "../hooks/useJob";
import { Transcript } from "./ArtifactCard";
import { Dropdown } from "./Dropdown";
import { CheckIcon, ChevronIcon, HeadphonesIcon } from "./icons/Icons";
import { LANGUAGES, Pills, SourceFocusFields, type SourceSelection } from "./SourceFocusFields";
import { ConfirmDeleteModal, errorMessage, JobProgress, Modal, StatusPill } from "./ui";
/** The audio styles. The server describes each to the script writer (backend/app/pipeline/media.py STYLES). */
const STYLES = [
{ id: "deep_dive", name: "Deep Dive", blurb: "A lively conversation that unpacks the ideas in your sources and connects them." },
{ id: "brief", name: "Brief", blurb: "A bite sized overview to help you grasp the core ideas from your sources quickly." },
{ id: "critique", name: "Critique", blurb: "An expert review of your sources, with constructive feedback to help you improve your material." },
{ id: "debate", name: "Debate", blurb: "A thoughtful debate between two hosts, bringing out different perspectives on your sources." },
] as const;
type Style = (typeof STYLES)[number]["id"];
const LENGTHS = [
{ value: "3", label: "Short", hint: "about 3 min" },
{ value: "6", label: "Default", hint: "about 6 min" },
{ value: "12", label: "Long", hint: "about 12 min" },
];
export type AudioOverviewRequest = Omit<GenerateBody, "type">;
/** The workspace's voices, asked for once (the list takes a few seconds): the notebook asks ahead of time, so the
* pickers are ready when the settings open. A failure is forgotten, so the next open asks again. */
let voicesAsked: Promise<VideoSavedVoice[]> | null = null;
export function prefetchVoices(): Promise<VideoSavedVoice[]> {
voicesAsked ??= videoVoicesApi.list().then((r) => r.saved).catch((e) => {
voicesAsked = null;
throw e;
});
return voicesAsked;
}
/** The audio overview settings: a style, one or two hosts and their voices, length, language, the sources, and what
* the hosts should focus on. */
export function AudioOverviewDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
notebookId: string;
notebookTitle?: string;
chats: ChatSummary[];
currentChatId?: string | null;
onClose: () => void;
onCreate: (body: AudioOverviewRequest) => void;
}) {
const [sel, setSel] = useState<SourceSelection | null>(null);
const [style, setStyle] = useState<Style>("deep_dive");
const [hosts, setHosts] = useState<"1" | "2">("2");
const [minutes, setMinutes] = useState("6");
const [language, setLanguage] = useState("English");
const [focus, setFocus] = useState("");
// The hosts' voices, from the workspace's voices (none to pick when they cannot load: the default voices are used).
const [voices, setVoices] = useState<VideoSavedVoice[] | null>(null);
const [voice, setVoice] = useState<[string, string]>(["", ""]);
useEffect(() => {
let live = true;
prefetchVoices().then((saved) => {
if (!live) return;
setVoices(saved);
setVoice([saved[0]?.voice_id ?? "", (saved[1] ?? saved[0])?.voice_id ?? ""]);
}).catch(() => live && setVoices([]));
return () => {
live = false;
};
}, []);
const two = hosts === "2";
// A debate needs two hosts: with one, the style moves to Deep Dive.
useEffect(() => {
if (!two && style === "debate") setStyle("deep_dive");
}, [two, style]);
/** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
const pickVoice = (i: 0 | 1, voiceId: string) =>
setVoice((cur) => {
const next: [string, string] = [...cur];
next[i] = voiceId;
const other = i === 0 ? 1 : 0;
if (two && next[other] === voiceId) next[other] = voices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
return next;
});
const voiceOptions = (voices ?? []).map((v) => ({ value: v.voice_id, label: v.name }));
const create = () => {
if (!sel) return;
onCreate({
...sel.request, format: style, hosts: two ? 2 : 1, minutes: Number(minutes), language, instructions: focus.trim(),
...(voice[0] ? { host_a: voice[0], ...(two ? { host_b: voice[1] || voice[0] } : {}) } : {}),
});
};
return (
<Modal title="Customize audio overview" onClose={onClose} wide>
<div className="stack vw-in-modal ao-dialog">
<section className="field">
<span className="vw-label">Style</span>
<div className="ao-styles" role="radiogroup" aria-label="Style">
{STYLES.map((s) => {
const off = s.id === "debate" && !two;
return (
<button key={s.id} type="button" role="radio" aria-checked={style === s.id} disabled={off}
className={`ao-style${style === s.id ? " on" : ""}`} onClick={() => setStyle(s.id)}>
<span className="ao-style-head">
<strong>{s.name}</strong>
<span className="ao-radio" aria-hidden="true">{style === s.id && <CheckIcon size={12} />}</span>
</span>
<span className="ao-style-blurb">{off ? "Needs two hosts." : s.blurb}</span>
</button>
);
})}
</div>
</section>
<section className="sd-opts ao-opts">
<div className="field">
<span className="vw-label">Hosts</span>
<Pills<"1" | "2"> label="Hosts" value={hosts} onChange={setHosts}
options={[{ id: "1", label: "One host" }, { id: "2", label: "Two hosts" }]} />
</div>
<div className="field">
<span className="vw-label">Length</span>
<Dropdown<string> label="Length" value={minutes} onChange={setMinutes} options={LENGTHS} />
</div>
<div className="field">
<span className="vw-label">Language</span>
<Dropdown<string> label="Language" value={language} onChange={setLanguage}
options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
</div>
</section>
<section className="ao-voices">
{voices === null && <p className="muted small">Loading your voices...</p>}
{voices !== null && voices.length === 0 && (
<p className="muted small">Your voices could not load, so the default {two ? "voices are" : "voice is"} used.</p>
)}
{voices !== null && voices.length > 0 && ([0, 1] as const).filter((i) => i === 0 || two).map((i) => (
<div key={i} className="field">
<span className="vw-label">{two ? `Host ${i + 1} voice` : "Host voice"}</span>
<Dropdown<string> label={two ? `Host ${i + 1} voice` : "Host voice"} value={voice[i]} onChange={(v) => pickVoice(i, v)}
options={voiceOptions} />
</div>
))}
</section>
<SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
what="audio overview" noFocus onChange={setSel} />
<label className="field">
<span className="vw-label">What should the AI {two ? "hosts" : "host"} focus on in this episode? <span className="muted">(optional)</span></span>
<textarea className="input sd-prompt" rows={4} maxLength={4000} value={focus} onChange={(e) => setFocus(e.target.value)}
placeholder={"- List the core ideas worth remembering\n- Explain how the pieces connect"} />
</label>
{!sel?.ready && <p className="muted small">Select at least one post or chat to make an audio overview.</p>}
<div className="vw-nav">
<button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready} onClick={create}>
Generate audio overview
</button>
</div>
</div>
</Modal>
);
}
const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75];
function clock(seconds: number): string {
const s = Math.max(0, Math.floor(seconds || 0));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
/** The audio's own player: play or pause, where it is, a bar to move through it, and the playback speed. */
function AudioPlayer({ src, duration: known, audioRef }: { src: string; duration?: number; audioRef: RefObject<HTMLAudioElement> }) {
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(known ?? 0);
const [speed, setSpeed] = useState(1);
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const sync = () => {
setTime(a.currentTime);
if (Number.isFinite(a.duration) && a.duration > 0) setDuration(a.duration);
setPlaying(!a.paused && !a.ended);
};
const events = ["timeupdate", "loadedmetadata", "durationchange", "play", "pause", "ended"];
events.forEach((e) => a.addEventListener(e, sync));
return () => events.forEach((e) => a.removeEventListener(e, sync));
}, [audioRef]);
const toggle = () => {
const a = audioRef.current;
if (!a) return;
if (a.paused) void a.play();
else a.pause();
};
const nextSpeed = () => {
const next = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length];
setSpeed(next);
if (audioRef.current) audioRef.current.playbackRate = next;
};
const pct = duration ? Math.min(100, (time / duration) * 100) : 0;
return (
<div className="ao-player">
<audio ref={audioRef} src={src} preload="metadata" />
<button type="button" className="ao-play" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
{playing ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" /></svg>
)}
</button>
<span className="ao-time mono">{clock(time)} / {clock(duration)}</span>
<input className="ao-seek" type="range" min={0} max={duration || 0} step={0.1} value={Math.min(time, duration || 0)}
aria-label="Position" style={{ ["--ao-fill" as string]: `${pct}%` }}
onChange={(e) => {
const a = audioRef.current;
if (a) a.currentTime = Number(e.target.value);
setTime(Number(e.target.value));
}} />
<button type="button" className="ao-speed mono" onClick={nextSpeed} aria-label={`Playback speed ${speed}x`} title="Playback speed">
{speed}x
</button>
</div>
);
}
const STYLE_NAMES: Record<string, string> = Object.fromEntries(STYLES.map((s) => [s.id, s.name]));
/** An audio overview in the notebook's list: a row with its title and length that opens (chevron) to its player,
* transcript, Download and Delete. Shows its progress while it is being made. */
export function AudioOverviewCard({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
const [artifact, setArtifact] = useState(initial);
const [open, setOpen] = useState(false);
const [transcript, setTranscript] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const audio = useRef<HTMLAudioElement>(null);
const job = useJob(artifact.job, () => {
artifactsApi.get(artifact.id).then(setArtifact);
});
const running = job && (job.status === "queued" || job.status === "running");
const ready = artifact.status === "ready";
const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this audio." : null;
const c = artifact.content;
const segments = (c.segments as Segment[] | undefined) ?? [];
const meta = [
c.duration_s ? clock(c.duration_s as number) : "",
c.hosts === 1 ? "1 host" : c.hosts === 2 ? "2 hosts" : "",
STYLE_NAMES[c.format as string] ?? "",
].filter(Boolean).join(" · ") || (running ? "Making the audio..." : "");
const remove = async () => {
await artifactsApi.remove(artifact.id);
onRemoved(artifact.id);
};
return (
<article className={`ao-card${open ? " open" : ""}`}>
<div className="ao-card-head" role={ready ? "button" : undefined} tabIndex={ready ? 0 : undefined} aria-expanded={ready ? open : undefined}
onClick={() => ready && setOpen((o) => !o)}
onKeyDown={(e) => ready && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen((o) => !o))}>
<span className="ao-card-icon" aria-hidden="true">{running ? <span className="nbv-send-spinner" /> : <HeadphonesIcon size={20} />}</span>
<span className="ao-card-text">
<strong title={artifact.title}>{artifact.title}</strong>
<span className="ao-card-meta">{meta}</span>
</span>
{!ready && !failed && <StatusPill status={running ? (job!.status === "queued" ? "queued" : "working") : artifact.status} />}
{ready && (
<span className="ao-chevron" aria-hidden="true"><ChevronIcon size={18} className={`chevron${open ? " open" : ""}`} /></span>
)}
</div>
{running && job && <div className="ao-card-progress"><JobProgress job={job} compact /></div>}
{failed && (
<div className="ao-card-progress sd-item-failed">
<p className="error-text small">{failed}</p>
<button type="button" className="link-btn" onClick={async () => {
setError(null);
try {
setArtifact(await artifactsApi.retry(artifact.id));
} catch (e) {
setError(errorMessage(e));
}
}}>Retry</button>
<button type="button" className="link-btn" onClick={() => setConfirming(true)}>Remove</button>
</div>
)}
{ready && open && (
<div className="ao-card-body">
{artifact.url && <AudioPlayer src={artifact.url} duration={c.duration_s as number | undefined} audioRef={audio} />}
{segments.length > 0 && (
<button type="button" className="link-btn ao-transcript-toggle" aria-expanded={transcript} onClick={() => setTranscript((t) => !t)}>
{transcript ? "Hide transcript" : `Show transcript (${segments.length} lines)`}
</button>
)}
{transcript && (
<Transcript segments={segments} onSeek={(t) => {
const a = audio.current;
if (!a) return;
a.currentTime = t;
void a.play();
}} />
)}
<div className="row ao-card-actions">
{artifact.download_url && <a className="btn btn-small" href={artifact.download_url}>Download</a>}
<button type="button" className="btn btn-small ao-delete" onClick={() => setConfirming(true)}>Delete</button>
</div>
</div>
)}
{error && <p className="error-text small ao-card-progress">{error}</p>}
{confirming && <ConfirmDeleteModal heading="Delete audio overview?" name={artifact.title} onCancel={() => setConfirming(false)}
onConfirm={remove} />}
</article>
);
}
|