Skip to content
Open
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
87 changes: 59 additions & 28 deletions packages/studio/src/player/components/AudioWaveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,28 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
return peaks.map((p) => p / maxPeak);
}

/** Deterministic fake waveform as fallback (matches demo app). */
function fakePeaks(url: string, count: number): number[] {
let seed = 0;
for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0;
seed = Math.abs(seed) || 42;
const rand = () => {
seed = (seed * 16807) % 2147483647;
return (seed & 0x7fffffff) / 2147483647;
};
const peaks: number[] = [];
for (let i = 0; i < count; i++) {
const t = i / count;
const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1);
peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand()))));
}
return peaks;
}

// Module-level cache so decoded audio persists across re-renders and re-mounts
const peaksCache = new Map<string, number[]>();
const decodeInFlight = new Map<string, Promise<number[]>>();
const decodeInFlight = new Map<string, Promise<number[] | null>>();
// URLs whose fetch/decode recently failed — render the degraded state instead
// of refetch-looping (and never fabricate plausible-looking peaks the user
// might trim or beat-align against). Failures expire so a transient error
// (server restarting, file mid-import) doesn't pin "unavailable" all session.
const decodeFailed = new Map<string, number>();
const DECODE_RETRY_MS = 30_000;
function hasRecentDecodeFailure(key: string): boolean {
const failedAt = decodeFailed.get(key);
if (failedAt === undefined) return false;
if (Date.now() - failedAt > DECODE_RETRY_MS) {
decodeFailed.delete(key);
return false;
}
return true;
}

/**
* Audio waveform rendered from real PCM data via Web Audio API.
* Falls back to a deterministic fake pattern if decoding fails.
* Shows an explicit "waveform unavailable" degraded state if decoding fails.
* Bars grow from bottom to top, rendered as CSS divs for zoom resilience.
*/
export const AudioWaveform = memo(function AudioWaveform({
Expand All @@ -81,9 +78,17 @@ export const AudioWaveform = memo(function AudioWaveform({
const roRef = useRef<ResizeObserver | null>(null);
const cacheKey = waveformUrl ?? audioUrl;
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
const [decodeError, setDecodeError] = useState(() => hasRecentDecodeFailure(cacheKey));

// Re-sync when the clip's audio source is swapped on the same mounted
// component — a stale error (or stale peaks) must not block the new URL.
useEffect(() => {
if (peaks || !cacheKey) return;
setPeaks(peaksCache.get(cacheKey) ?? null);
setDecodeError(hasRecentDecodeFailure(cacheKey));
}, [cacheKey]);

useEffect(() => {
if (peaks || decodeError || !cacheKey) return;

let cancelled = false;

Expand All @@ -105,23 +110,28 @@ export const AudioWaveform = memo(function AudioWaveform({
})
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
)
.catch(() => fakePeaks(cacheKey, 4000))
.then((p) => {
.then((p: number[]) => {
peaksCache.set(cacheKey, p);
return p;
return p as number[] | null;
})
.catch(() => {
decodeFailed.set(cacheKey, Date.now());
return null;
})
.finally(() => decodeInFlight.delete(cacheKey));

decodeInFlight.set(cacheKey, promise);
}

promise.then((p) => {
if (!cancelled) setPeaks(p);
if (cancelled) return;
if (p) setPeaks(p);
else setDecodeError(true);
});
return () => {
cancelled = true;
};
}, [audioUrl, waveformUrl, cacheKey, peaks]);
}, [audioUrl, waveformUrl, cacheKey, peaks, decodeError]);

// Draw bars into the container using innerHTML (fast, zoom-resilient)
const draw = useCallback(() => {
Expand Down Expand Up @@ -185,16 +195,37 @@ export const AudioWaveform = memo(function AudioWaveform({
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
<div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
{/* Shimmer while decoding */}
{!peaks && (
{!peaks && !decodeError && (
<div
className="absolute left-0 right-0 bottom-0 animate-pulse"
className="absolute left-0 right-0 bottom-0 animate-pulse motion-reduce:animate-none"
style={{
top: 16,
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
{/* Degraded state — decode failed; render an explicit flat placeholder
instead of fabricated peaks the user might edit against. */}
{decodeError && (
<div
className="absolute left-0 right-0 flex items-center justify-center gap-1.5"
style={{ top: 16, bottom: 0 }}
>
<div
className="absolute left-0 right-0"
style={{
bottom: "20%",
height: 2,
background:
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
}}
/>
<span className="relative text-[8px] text-neutral-500 bg-black/50 px-1 rounded">
waveform unavailable
</span>
</div>
)}
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
<span
className="text-[9px] font-semibold truncate block leading-tight"
Expand Down
19 changes: 10 additions & 9 deletions packages/studio/src/player/components/BeatStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";

export const BEAT_BAND_H = 14; // dark band height at top of track
const BEAT_HIT_W = 12; // grab width per beat (px)
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target

/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
function beatsTooDense(beatTimes: number[], pps: number): boolean {
Expand Down Expand Up @@ -58,8 +58,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({

/**
* Green beat dots on the music track's row. Drag a dot to move its beat,
* double-click to delete; both scrub the audio. Dot size/brightness scale with
* beat loudness (gamma-curved for contrast).
* ⌥-click to delete (kept off double-click so a stuttered drag can't destroy
* a beat); both scrub the audio. Dot size/brightness scale with beat loudness
* (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
*/
export const BeatStrip = memo(function BeatStrip({
beatTimes,
Expand Down Expand Up @@ -93,7 +94,7 @@ export const BeatStrip = memo(function BeatStrip({
<div
key={`${t}-${i}`}
className="absolute select-none"
title="Drag to move · double-click to delete"
title="Drag to move · -click to delete"
draggable={false}
style={{
left: x - BEAT_HIT_W / 2,
Expand Down Expand Up @@ -138,13 +139,13 @@ export const BeatStrip = memo(function BeatStrip({
const newTime = Math.max(0, d.origTime + dx / pps);
moveBeatCompositionTime(d.origTime, newTime);
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
} else if (e.altKey) {
// ⌥-click deletes. Deliberately NOT double-click: a stuttered
// drag attempt reads as a double-click and would nuke the beat.
deleteBeatAtCompositionTime(t);
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
deleteBeatAtCompositionTime(t);
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
}}
>
<div
className="absolute"
Expand Down
10 changes: 8 additions & 2 deletions packages/studio/src/player/components/ClipContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import type { TimelineElement } from "../store/playerStore";
import { canSplitElement } from "../../utils/timelineElementSplit";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { useMenuKeyboardNav } from "./menuKeyboardNav";

interface ClipContextMenuProps {
x: number;
Expand All @@ -24,6 +25,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
onDelete,
}: ClipContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
useMenuKeyboardNav(menuRef);

const menuWidth = 200;
const menuHeight = 80;
Expand All @@ -44,14 +46,17 @@ export const ClipContextMenu = memo(function ClipContextMenu({
return createPortal(
<div
ref={menuRef}
role="menu"
aria-label="Clip actions"
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
{splitLabel && (
<>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
role="menuitem"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
Expand All @@ -73,7 +78,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({

<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
onClick={() => {
onDelete(element);
onClose();
Expand Down
55 changes: 48 additions & 7 deletions packages/studio/src/player/components/EditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,44 @@ interface EditPopoverProps {
onClose: () => void;
}

function draftKey(start: number, end: number): string {
return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
}

function readDraft(key: string): string {
try {
return sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
}

function writeDraft(key: string, value: string): void {
try {
if (value) sessionStorage.setItem(key, value);
else sessionStorage.removeItem(key);
} catch {
/* storage unavailable — draft persistence degrades gracefully */
}
}

export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
const elements = usePlayerStore((s) => s.elements);
const [prompt, setPrompt] = useState("");
const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);
// Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
const storageKey = draftKey(start, end);
const [prompt, setPromptState] = useState(() => readDraft(storageKey));
const setPrompt = (value: string) => {
setPromptState(value);
writeDraft(storageKey, value);
};
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
const [copyError, setCopyError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);

const elementsInRange = useMemo(() => {
return elements.filter((el) => {
const elEnd = el.start + el.duration;
Expand Down Expand Up @@ -64,19 +91,28 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:

const handleCopy = useCallback(async () => {
const copied = await copyTextToClipboard(buildClipboardText());
if (!copied) return;
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
writeDraft(storageKey, "");
setCopiedAgentPrompt(true);
setTimeout(() => {
setCopiedAgentPrompt(false);
onClose();
}, 800);
}, [buildClipboardText, onClose]);
}, [buildClipboardText, onClose, storageKey]);

const handleCopyPrompt = useCallback(async () => {
const promptText = buildPromptCopyText(prompt);
if (!promptText) return;
const copied = await copyTextToClipboard(promptText);
if (!copied) return;
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
setCopiedPromptOnly(true);
setTimeout(() => {
setCopiedPromptOnly(false);
Expand Down Expand Up @@ -137,6 +173,11 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
</div>

{/* Action */}
{copyError && (
<p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
Copy failed — check clipboard permissions and try again.
</p>
)}
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
<button
onClick={handleCopyPrompt}
Expand Down
Loading
Loading