-
- This project's folder moved or is unavailable. History stays safe;
- locate the project to restore file actions without creating a new
- history.
+ ) : (
+
+
+
+
+ {project.ideas.length > 1 ? (
+
+ ) : (
+ Checkpoints
+ )}
+ {previewCount > 0 && (
+ setShowPreviewsOnly((v) => !v)}
+ size="xs"
+ type="button"
+ variant={showPreviewsOnly ? "secondary" : "ghost"}
+ >
+
+ With audio only ({previewCount})
+
+ )}
+
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setQuery("");
+ }
+ }}
+ placeholder="Find a change — try “bass”, “tempo”, “louder”"
+ value={query}
+ />
+ {searching && (
+
+ {visibleSections.reduce(
+ (sum, s) => sum + s.items.length,
+ 0
+ )}{" "}
+ found
+
+ )}
+
+
-
-
-
- )}
-
- {previewCount > 0 && (
-
- setShowPreviewsOnly((v) => !v)}
- size="sm"
- type="button"
- variant={showPreviewsOnly ? "outline" : "ghost"}
- >
-
- Previews
-
- {previewCount}
-
-
-
- )}
-
-
- {activeSetSaves.length === 0 && (
-
- No checkpoints for this Ableton set yet.
-
- )}
- {activeSetSaves.length > 0 && (
-
-
-
- Recent activity
-
-
+ {visibleSections.length === 0 ? (
+
+ {searching
+ ? `No saves in this set mention “${query.trim()}”.`
+ : "No checkpoints for this set yet."}
+
+ ) : (
+
+ {visibleSections.map((section) => (
+
+ ))}
-
- {visibleItems.map((item) => {
- if (item.type === "group") {
- return (
-
- toggleGroup(item.key)}
- saves={item.saves}
- />
-
- );
- }
-
- const save = item.save;
- const idea = item.idea;
- const isHead = idea.headSaveId === save.id;
- const isSelected = save.id === selectedSaveId;
+ )}
+
- return (
-
toggleSave(save.id)}
- project={project}
- save={save}
- />
- );
- })}
-
-
+ {selectedSave && (
+
+ toggleSave(selectedSave.id)}
+ project={project}
+ save={selectedSave}
+ />
+
)}
-
- {selectedSave && (
-
- toggleSave(selectedSave.id)}
- project={project}
- save={selectedSave}
- />
-
- )}
-
+ )}
);
}
diff --git a/apps/desktop/src/components/track-thumbnail.tsx b/apps/desktop/src/components/track-thumbnail.tsx
deleted file mode 100644
index e32b403..0000000
--- a/apps/desktop/src/components/track-thumbnail.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import type { TrackSummaryItem } from "@/lib/types";
-
-// Ableton Live color palette (indices 0–69).
-// Extracted from Ableton's XML schema — these are the sRGB hex values
-// for the 70 clip/track colors in Live 10/11/12.
-const ABLETON_COLORS: string[] = [
- "#FF94A6",
- "#FFA529",
- "#CC9927",
- "#F7F47C",
- "#BFFB00",
- "#1AFF2F",
- "#25FFA8",
- "#5CFFE8",
- "#8BC5FF",
- "#5480E4",
- "#92A7FF",
- "#D86CE4",
- "#E553A0",
- "#FFFFFF",
- "#FF3636",
- "#F66C03",
- "#99724B",
- "#FFF034",
- "#87FF67",
- "#3DC300",
- "#00BFAF",
- "#19E9FF",
- "#10A4EE",
- "#007DC0",
- "#886CE4",
- "#B677C6",
- "#FF39D4",
- "#D0D0D0",
- "#E2675A",
- "#FFA374",
- "#D4AD71",
- "#E8E55C",
- "#98B954",
- "#56C733",
- "#00A279",
- "#3FC0D2",
- "#82B3F2",
- "#4668C4",
- "#8E69CF",
- "#A34FA5",
- "#EB57A3",
- "#A0A0A0",
- "#CC3B3C",
- "#D47B35",
- "#A07843",
- "#C4B946",
- "#84A131",
- "#539F31",
- "#0F7B3F",
- "#2FA09E",
- "#4A7CAD",
- "#3B55A1",
- "#6847A4",
- "#A04F8D",
- "#BE478E",
- "#707070",
- "#AF3333",
- "#A95131",
- "#724F41",
- "#9F9B27",
- "#6E8C22",
- "#53742F",
- "#274F2D",
- "#254D52",
- "#3B5E7E",
- "#254475",
- "#4D3478",
- "#6B3662",
- "#A33E64",
- "#535353",
-];
-
-// Fallback colors by track type when the Ableton index is missing or -1
-const TYPE_FALLBACK: Record
= {
- audio: "#FFA529",
- group: "#886CE4",
- midi: "#5480E4",
- return: "#1AFF2F",
-};
-
-function trackColor(track: TrackSummaryItem): string {
- if (track.color >= 0 && track.color < ABLETON_COLORS.length) {
- return ABLETON_COLORS[track.color]!;
- }
- return TYPE_FALLBACK[track.type] ?? "#707070";
-}
-
-function flattenTracks(
- tracks: TrackSummaryItem[],
- parentKey = "root",
- depth = 0
-): Array<{ key: string; track: TrackSummaryItem; depth: number }> {
- return tracks.flatMap((track, index) => {
- const key = `${parentKey}/${track.type}:${track.name}:${index}`;
- return [
- { depth, key, track },
- ...flattenTracks(track.children ?? [], key, depth + 1),
- ];
- });
-}
-
-function trackTitle(track: TrackSummaryItem): string {
- if (track.type !== "group") {
- return `${track.name} (${track.type}, ${track.clipCount} clips)`;
- }
-
- const nestedTracks = Math.max(0, track.trackCount - 1);
- return `${track.name} (${nestedTracks} nested tracks, ${track.clipCount} clips)`;
-}
-
-/**
- * Flatten tracks to at most `maxDepth` levels deep.
- * Keeps group structure visible without exploding height for deeply nested sets.
- */
-function flattenTracksShallow(
- tracks: TrackSummaryItem[],
- maxDepth: number,
- parentKey = "root",
- depth = 0
-): Array<{ key: string; track: TrackSummaryItem; depth: number }> {
- return tracks.flatMap((track, index) => {
- const key = `${parentKey}/${track.type}:${track.name}:${index}`;
- return [
- { depth, key, track },
- ...(depth < maxDepth
- ? flattenTracksShallow(track.children ?? [], maxDepth, key, depth + 1)
- : []),
- ];
- });
-}
-
-/**
- * Compact visual thumbnail showing track layout of a save.
- * Multi-line with indentation so groups are visible, but kept tight:
- * - compact: 2px rows, max 1 nesting level, capped row count
- * - detail: 3px rows, full nesting, higher cap
- */
-export function TrackThumbnail({
- tracks,
- className,
- variant = "compact",
-}: {
- tracks: TrackSummaryItem[];
- className?: string;
- /** "compact" = tight rows for collapsed cards, "detail" = fuller view for expanded cards */
- variant?: "compact" | "detail";
-}) {
- if (tracks.length === 0) {
- return null;
- }
-
- const isCompact = variant === "compact";
- const maxDepth = isCompact ? 1 : 4;
- const maxRows = isCompact ? 8 : 20;
- const indentPx = isCompact ? 4 : 6;
-
- const rows = isCompact
- ? flattenTracksShallow(tracks, maxDepth)
- : flattenTracks(tracks);
- const visible = rows.slice(0, maxRows);
- const overflow = rows.length - maxRows;
-
- if (isCompact) {
- return (
-
-
- {visible.map(({ key, track, depth }) => {
- const indent = Math.min(depth, 4) * 4;
- return (
-
- );
- })}
-
-
- );
- }
-
- return (
-
-
- {visible.map(({ key, track, depth }) => {
- const indent = Math.min(depth, 4) * indentPx;
- const guideOffset = Math.max(indent - 3, 0);
- return (
-
- {depth > 0 && (
-
- )}
-
-
- );
- })}
- {overflow > 0 && (
-
- +{overflow} more
-
- )}
-
-
- );
-}
diff --git a/apps/desktop/src/components/ui/checkbox.tsx b/apps/desktop/src/components/ui/checkbox.tsx
deleted file mode 100644
index c817de3..0000000
--- a/apps/desktop/src/components/ui/checkbox.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { CheckIcon } from "@phosphor-icons/react";
-import { Checkbox as CheckboxPrimitive } from "radix-ui";
-import type * as React from "react";
-import { cn } from "@/lib/utils";
-
-function Checkbox({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
- );
-}
-
-export { Checkbox };
diff --git a/apps/desktop/src/components/ui/scroll-area.tsx b/apps/desktop/src/components/ui/scroll-area.tsx
deleted file mode 100644
index a0d8997..0000000
--- a/apps/desktop/src/components/ui/scroll-area.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function ScrollArea({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
-
- );
-}
-
-function ScrollBar({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-export { ScrollArea };
diff --git a/apps/desktop/src/components/ui/sonner.tsx b/apps/desktop/src/components/ui/sonner.tsx
index 3fc546f..660f738 100644
--- a/apps/desktop/src/components/ui/sonner.tsx
+++ b/apps/desktop/src/components/ui/sonner.tsx
@@ -5,39 +5,35 @@ import {
WarningIcon,
XCircleIcon,
} from "@phosphor-icons/react";
-import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme();
-
- return (
- ,
- info: ,
- loading: ,
- success: ,
- warning: ,
- }}
- style={
- {
- "--border-radius": "var(--radius)",
- "--normal-bg": "var(--popover)",
- "--normal-border": "var(--border)",
- "--normal-text": "var(--popover-foreground)",
- } as React.CSSProperties
- }
- theme={theme as ToasterProps["theme"]}
- toastOptions={{
- classNames: {
- toast: "cn-toast",
- },
- }}
- {...props}
- />
- );
-};
+const Toaster = ({ ...props }: ToasterProps) => (
+ ,
+ info: ,
+ loading: ,
+ success: ,
+ warning: ,
+ }}
+ position="bottom-right"
+ style={
+ {
+ "--border-radius": "var(--radius)",
+ "--normal-bg": "var(--popover)",
+ "--normal-border": "var(--border)",
+ "--normal-text": "var(--popover-foreground)",
+ } as React.CSSProperties
+ }
+ theme="dark"
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+);
export { Toaster };
diff --git a/apps/desktop/src/components/welcome-onboarding.tsx b/apps/desktop/src/components/welcome-onboarding.tsx
index 452de61..6ad77d6 100644
--- a/apps/desktop/src/components/welcome-onboarding.tsx
+++ b/apps/desktop/src/components/welcome-onboarding.tsx
@@ -1,13 +1,13 @@
import {
ArrowRight,
- CaretUpDown,
- Check,
CheckCircle,
- Copy,
+ ClockCounterClockwise,
+ FloppyDisk,
FolderSimple,
- FolderSimplePlus,
+ GitBranch,
+ ListMagnifyingGlass,
} from "@phosphor-icons/react";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
@@ -15,656 +15,225 @@ import { sendDaemonCommand } from "@/lib/daemon-client";
import { useOnboardingStore } from "@/lib/onboarding-store";
import { posthog } from "@/lib/posthog";
import { useStore } from "@/lib/store";
-import { cn } from "@/lib/utils";
+import { cn, plural, shortenPath } from "@/lib/utils";
-// ── Helpers ─────────────────────────────────────────────────────────
-
-function shortenPath(p: string): string {
- let s = p;
- const parts = s.split("/");
- if (parts.length >= 3 && parts[1] === "Users") {
- const home = `/Users/${parts[2]}`;
- if (s.startsWith(`${home}/`)) {
- s = `~${s.slice(home.length)}`;
- }
- }
- s = s.replace("~/Library/Mobile Documents/com~apple~CloudDocs", "~/iCloud");
- return s;
-}
-
-// ── Step 1: Welcome ─────────────────────────────────────────────────
+const PROMISES = [
+ {
+ icon: FloppyDisk,
+ text: "Every time you save in Ableton, Echoform stores a full copy of the project, samples included, outside your project folder.",
+ },
+ {
+ icon: ListMagnifyingGlass,
+ text: "See what changed between saves: tracks, devices, clips, tempo.",
+ },
+ {
+ icon: GitBranch,
+ text: "Continue from any earlier checkpoint as a separate copy. Your current project is never overwritten.",
+ },
+];
function WelcomeStep({ onNext }: { onNext: () => void }) {
return (
-
-
-
-
- Welcome to Echoform
+
+
+
+ Every Ableton save, kept.
-
- Automatic checkpoints for your Ableton projects. Every saved change is
- kept outside your working folder.
+
+ Echoform quietly records a checkpoint each time you save, so you can
+ experiment freely and always get back to an earlier version.
-
- {[
- {
- desc: "Echoform finds every Ableton project inside.",
- title: "Point at your folders",
- },
- {
- desc: "Changes are captured silently in the background.",
- title: "Work like you always do",
- },
- {
- desc: "Browse checkpoints, compare changes, or recover an earlier moment.",
- title: "Go back anytime",
- },
- ].map((item, i) => (
-
-
- {i + 1}
-
-
-
- {item.title}
-
-
- {item.desc}
-
-
-
+
+ {PROMISES.map(({ icon: Icon, text }) => (
+
+
+ {text}
+
))}
-
+
-
- Get started
-
+
+
+ Everything stays on this Mac. Echoform is version history, not a backup:
+ keep backing up your drive as usual.
+
+
+
+ Choose project folders
+
);
}
-// ── Step 2: Pick a folder ───────────────────────────────────────────
-
-function PickFolderStep() {
+function PickFolderStep({ onDone }: { onDone: () => void }) {
const rootSuggestions = useStore((s) => s.rootSuggestions);
const rootSuggestionsLoaded = useStore((s) => s.rootSuggestionsLoaded);
const roots = useStore((s) => s.roots);
- const fetchTimerRef = useRef | null>(null);
- const [addedPath, setAddedPath] = useState(null);
- const suggestionsLoading = !rootSuggestionsLoaded;
+ const projects = useStore((s) => s.projects);
+ const [pendingPaths, setPendingPaths] = useState([]);
- // Trigger suggestion discovery on mount
useEffect(() => {
- if (rootSuggestionsLoaded) {
- return;
- }
- fetchTimerRef.current = setTimeout(() => {
+ if (!rootSuggestionsLoaded) {
sendDaemonCommand({ type: "discover-root-suggestions" });
- }, 150);
- return () => {
- if (fetchTimerRef.current) {
- clearTimeout(fetchTimerRef.current);
- fetchTimerRef.current = null;
- }
- };
+ }
}, [rootSuggestionsLoaded]);
+ const isWatched = (path: string) => roots.some((root) => root.path === path);
+
const watchPath = (path: string, source: "picker" | "suggestion") => {
const trimmed = path.trim();
if (!trimmed) {
return;
}
- setAddedPath(trimmed);
+ setPendingPaths((paths) => [...paths, trimmed]);
posthog.capture("root_added", { context: "onboarding", source });
- sendDaemonCommand({ path: trimmed, type: "add-root" });
- sendDaemonCommand({ type: "discover-root-suggestions" });
+ sendDaemonCommand({ path: trimmed, type: "add-root" }).finally(() => {
+ setPendingPaths((paths) => paths.filter((p) => p !== trimmed));
+ });
};
const handlePickFolder = async () => {
if (!window.echoform?.pickFolder) {
- toast.error("Folder picker is only available in the desktop app.");
+ toast.error("The folder picker is only available in the desktop app.");
return;
}
try {
const selectedPath = await window.echoform.pickFolder();
- if (!selectedPath) {
- return;
+ if (selectedPath) {
+ watchPath(selectedPath, "picker");
}
- watchPath(selectedPath, "picker");
} catch (error) {
- const message =
- error instanceof Error ? error.message : "Failed to open folder picker";
- toast.error(message);
+ toast.error(
+ error instanceof Error ? error.message : "Couldn't open folder picker"
+ );
}
};
- const hasAddedRoot = roots.length > 0;
-
- return (
-
-
-
-
-
- Choose a folder to watch
-
-
- Pick a folder that contains your Ableton projects.
-
- Echoform finds all .als files inside automatically.
-
- {/* Primary action: folder picker */}
-
void handlePickFolder()}
- size="lg"
- type="button"
- >
-
- Browse for folder
-
- {/* Suggested folders — always rendered to avoid layout shift */}
-
-
- Suggested folders
-
-
- {suggestionsLoading && rootSuggestions.length === 0 ? (
-
- Scanning for music folders...
-
- ) : rootSuggestions.length === 0 ? (
-
- No Ableton folders found — use Browse above.
-
- ) : (
- rootSuggestions.map((suggestion) => {
- const isAdded =
- addedPath === suggestion.path ||
- roots.some((r) => r.path === suggestion.path);
-
- return (
-
-
-
- {shortenPath(suggestion.path)}
-
-
- {suggestion.projectCount} project
- {suggestion.projectCount === 1 ? "" : "s"}
-
-
- {isAdded ? (
-
-
- Added
-
- ) : (
-
watchPath(suggestion.path, "suggestion")}
- size="sm"
- type="button"
- variant="secondary"
- >
- Watch
-
- )}
-
- );
- })
- )}
-
-
-
- {/* Confirmation that a folder was added */}
- {hasAddedRoot && (
-
-
- Folder added — Echoform is now scanning your projects.
-
- )}
-
+ const scanning = pendingPaths.length > 0;
+ const otherRoots = roots.filter(
+ (root) => !rootSuggestions.some((s) => s.path === root.path)
);
-}
-
-// ── Step 3: How it works ────────────────────────────────────────────
-
-const TIMELINE_ENTRIES = [
- {
- auto: false,
- chips: [
- { color: "emerald" as const, text: "+Bass" },
- { color: "amber" as const, text: "~Mixer" },
- ],
- head: true,
- label: "Added bass track",
- time: "2:41 PM",
- },
- {
- auto: false,
- chips: [{ color: "amber" as const, text: "~Drums" }],
- head: false,
- label: "Adjusted EQ on drums",
- time: "2:38 PM",
- },
- {
- auto: true,
- chips: [],
- head: false,
- label: "Auto-snapshot",
- time: "2:30 PM",
- },
- {
- auto: false,
- chips: [
- { color: "emerald" as const, text: "+Vocals" },
- { color: "red" as const, text: "-Scratch" },
- ],
- head: false,
- label: "New vocal take",
- time: "2:12 PM",
- },
-];
-
-const CHIP_STYLES = {
- amber: "text-amber-400/80 bg-amber-400/10 border-amber-400/15",
- emerald: "text-emerald-400/80 bg-emerald-400/10 border-emerald-400/15",
- red: "text-red-400/80 bg-red-400/10 border-red-400/15",
-};
-function MockTimeline() {
return (
-
- {TIMELINE_ENTRIES.map((entry, i) => (
-
- {/* Timeline guide */}
-
-
- {/* Dot */}
-
-
- {/* Content */}
-
-
-
- {entry.label}
-
- {!entry.auto && (
-
- saved
-
- )}
-
- {entry.chips.length > 0 && (
-
- {entry.chips.map((chip) => (
-
- {chip.text}
-
- ))}
-
- )}
-
-
- {/* Time */}
-
- {entry.time}
-
-
- ))}
-
- );
-}
-
-function MockTabs() {
- const tabs = [
- { active: true, current: true, name: "Summer Beat.als", saves: 24 },
- {
- active: false,
- current: false,
- name: "Summer Beat (vocal mix).als",
- saves: 8,
- },
- ];
+
+
+ Where are your Ableton projects?
+
+
+ Pick the folder you keep projects in. Echoform finds every project
+ inside it, including ones you create later. Nothing in these folders is
+ moved or changed.
+
- return (
-
- {/* Tab bar */}
-
- {tabs.map((tab) => (
-
- {tab.current && (
-
- )}
-
{tab.name}
-
- {tab.saves}
-
- {tab.active && (
-
- )}
+
+ {!rootSuggestionsLoaded && rootSuggestions.length === 0 && (
+
+ Looking for folders with Ableton projects…
- ))}
-
-
- {/* Fake timeline entries beneath */}
-
+ )}
{[
- { dim: false, label: "Latest checkpoint" },
- { dim: true, label: "Earlier today" },
- { dim: true, label: "Yesterday" },
- ].map((row) => (
-
+ ...rootSuggestions,
+ ...otherRoots.map((root) => ({ path: root.path, projectCount: -1 })),
+ ].map((suggestion) => {
+ const watched = isWatched(suggestion.path);
+ const pending = pendingPaths.includes(suggestion.path);
+ return (
-
- {row.label}
-
-
- ))}
+
+
+ {shortenPath(suggestion.path)}
+
+ {suggestion.projectCount >= 0 && (
+
+ {plural(suggestion.projectCount, "project")} found
+
+ )}
+
+ {watched ? (
+
+
+ Watching
+
+ ) : (
+
watchPath(suggestion.path, "suggestion")}
+ size="sm"
+ variant="secondary"
+ >
+ {pending ? "Adding…" : "Watch"}
+
+ )}
+
+ );
+ })}
+
void handlePickFolder()}
+ variant="outline"
+ >
+
+ Choose another folder…
+
-
- );
-}
-
-function MockRecovery() {
- const projectCopies = [
- { active: true, checkpoints: 24, current: true, name: "Working project" },
- {
- active: false,
- checkpoints: 1,
- current: false,
- name: "Recovered checkpoint",
- },
- ];
- return (
-
- {/* Version selector header */}
-
+
+
+ {scanning
+ ? "Scanning for projects…"
+ : roots.length > 0
+ ? `${plural(projects.length, "project")} found in ${plural(roots.length, "folder")}`
+ : ""}
+
-
-
-
-
-
- Main
-
-
- current
-
-
-
-
-
-
- {/* Project-copy list */}
-
-
- Project copies
+ {roots.length === 0 && (
+
+ Skip for now
+
+ )}
+
+ Continue
+
+
- {projectCopies.map((projectCopy) => (
-
-
{projectCopy.name}
-
- {projectCopy.checkpoints}
-
- {projectCopy.current && (
-
- )}
- {projectCopy.active && (
-
- )}
-
- ))}
);
}
-const HOW_IT_WORKS_STEPS = [
- {
- desc: "Every save in Ableton creates a checkpoint. Select one to inspect what changed.",
- mockup: MockTimeline,
- title: "Checkpoints",
- },
- {
- desc: "Multiple .als files in one project each get their own tab.",
- mockup: MockTabs,
- title: "Tabs",
- },
- {
- desc: "Continue from any checkpoint in a separate working copy. Your current project stays untouched.",
- mockup: MockRecovery,
- title: "Branch safely",
- },
-];
-
-function HowItWorksStep({ onNext }: { onNext: () => void }) {
- const [subStep, setSubStep] = useState(0);
- const current = HOW_IT_WORKS_STEPS[subStep];
- const isLast = subStep === HOW_IT_WORKS_STEPS.length - 1;
- const Mockup = current.mockup;
-
- return (
-
- {/* Sub-step indicator */}
-
- {HOW_IT_WORKS_STEPS.map((s) => (
-
- ))}
-
-
- {/* Title + description */}
-
-
- {current.title}
-
-
- {current.desc}
-
-
-
- {/* Mockup */}
-
-
-
-
- {/* Navigation */}
-
{
- if (isLast) {
- posthog.capture("onboarding_completed");
- onNext();
- } else {
- setSubStep((s) => s + 1);
- }
- }}
- size="lg"
- type="button"
- >
- {isLast ? "Let's go" : "Next"}
-
-
-
- );
-}
-
-// ── Main Onboarding Container ───────────────────────────────────────
-
export function WelcomeOnboarding() {
const step = useOnboardingStore((s) => s.step);
const setStep = useOnboardingStore((s) => s.setStep);
const complete = useOnboardingStore((s) => s.complete);
- const roots = useStore((s) => s.roots);
- const projects = useStore((s) => s.projects);
- const [transitioning, setTransitioning] = useState(false);
useEffect(() => {
posthog.capture("onboarding_started");
}, []);
- // Auto-advance to "how-it-works" once a root is added and projects are discovered
- useEffect(() => {
- if (step !== "pick-folder") {
- return;
- }
- if (roots.length === 0) {
- return;
- }
- if (projects.length === 0) {
- return;
- }
- const timer = setTimeout(() => {
- setTransitioning(true);
- setTimeout(() => {
- setTransitioning(false);
- setStep("how-it-works");
- }, 400);
- }, 1200);
- return () => clearTimeout(timer);
- }, [step, roots.length, projects.length, setStep]);
-
return (
-
+
- {step === "welcome" && (
+ {step === "welcome" ? (
setStep("pick-folder")} />
- )}
- {step === "pick-folder" && }
- {step === "how-it-works" && }
-
-
- {/* Step indicator */}
-
- {(["welcome", "pick-folder", "how-it-works"] as const).map((s) => (
-
{
+ posthog.capture("onboarding_completed");
+ complete();
+ }}
/>
- ))}
+ )}
);
diff --git a/apps/desktop/src/hooks/use-daemon-sync.test.tsx b/apps/desktop/src/hooks/use-daemon-sync.test.tsx
index acb0b0e..d456cf8 100644
--- a/apps/desktop/src/hooks/use-daemon-sync.test.tsx
+++ b/apps/desktop/src/hooks/use-daemon-sync.test.tsx
@@ -4,7 +4,8 @@ import type { WsEvent } from "@/lib/types";
import { useDaemonSync } from "./use-daemon-sync";
let daemonEventListener: ((event: WsEvent) => void) | null = null;
-const { toastSuccess, toastWarning } = vi.hoisted(() => ({
+const { selectProject, toastSuccess, toastWarning } = vi.hoisted(() => ({
+ selectProject: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}));
@@ -43,6 +44,7 @@ vi.mock("@/lib/store", () => ({
applySnapshot: vi.fn(),
projects: [],
selectedProjectId: null,
+ selectProject,
setDiscoveredProjects: vi.fn(),
setRootSuggestions: vi.fn(),
}),
@@ -57,6 +59,7 @@ function HookHarness() {
describe("useDaemonSync recovery feedback", () => {
beforeEach(() => {
daemonEventListener = null;
+ selectProject.mockReset();
toastSuccess.mockReset();
toastWarning.mockReset();
window.echoform = {
@@ -122,4 +125,44 @@ describe("useDaemonSync recovery feedback", () => {
);
expect(toastSuccess).not.toHaveBeenCalled();
});
+
+ it("switches to the new branch once it arrives in the next snapshot", async () => {
+ render(
);
+ await waitFor(() => expect(daemonEventListener).not.toBeNull());
+
+ daemonEventListener?.({
+ recovery: {
+ activeSetPath: "/Music/Echoform Recoveries/Demo/song.als",
+ openError: null,
+ recoveredPath: "/Music/Echoform Recoveries/Demo",
+ recoveredProjectId: "branch-1",
+ sourceProjectId: "project-1",
+ sourceSaveId: "save-1",
+ },
+ type: "recovery-created",
+ });
+ expect(selectProject).not.toHaveBeenCalled();
+
+ daemonEventListener?.({
+ activity: [],
+ projects: [
+ { id: "project-1", saves: [] },
+ { id: "branch-1", saves: [] },
+ ] as never,
+ roots: [],
+ type: "snapshot",
+ });
+ expect(selectProject).toHaveBeenCalledWith("branch-1");
+
+ daemonEventListener?.({
+ activity: [],
+ projects: [
+ { id: "project-1", saves: [] },
+ { id: "branch-1", saves: [] },
+ ] as never,
+ roots: [],
+ type: "snapshot",
+ });
+ expect(selectProject).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/apps/desktop/src/hooks/use-daemon-sync.ts b/apps/desktop/src/hooks/use-daemon-sync.ts
index d23855e..6dadef2 100644
--- a/apps/desktop/src/hooks/use-daemon-sync.ts
+++ b/apps/desktop/src/hooks/use-daemon-sync.ts
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { useConnectionStore } from "@/lib/connection-store";
import {
@@ -13,6 +13,10 @@ import { usePreviewStore } from "@/lib/preview-store";
import { useStore } from "@/lib/store";
export function useDaemonSync() {
+ // A new branch arrives in the snapshot that follows "recovery-created";
+ // remember it so we can switch to it as soon as it exists.
+ const pendingBranchIdRef = useRef
(null);
+
useEffect(() => {
const unsubscribeCommandFailures = subscribeCommandFailures(({ error }) => {
toast.error(error.message);
@@ -23,6 +27,15 @@ export function useDaemonSync() {
switch (event.type) {
case "snapshot":
store.applySnapshot(event.projects, event.roots, event.activity);
+ if (
+ pendingBranchIdRef.current &&
+ event.projects.some(
+ (project) => project.id === pendingBranchIdRef.current
+ )
+ ) {
+ store.selectProject(pendingBranchIdRef.current);
+ pendingBranchIdRef.current = null;
+ }
syncAppProfile({
project_count: event.projects.length,
root_count: event.roots.length,
@@ -35,14 +48,20 @@ export function useDaemonSync() {
case "project-updated":
store.applyProjectUpdate(event.project);
break;
- case "auto-saved":
+ case "auto-saved": {
posthog.capture("save_created", {
auto: true,
});
- toast.success(`Checkpoint created for ${event.save.label}`);
+ const projectName = store.projects.find(
+ (project) => project.id === event.projectId
+ )?.name;
+ toast.success("Checkpoint recorded", {
+ description: projectName,
+ });
return;
+ }
case "change-detected":
- toast.info(`Changes detected in ${event.projectName}`);
+ // The checkpoint itself (or an error) follows; one toast per save.
return;
case "discovered-projects":
store.setDiscoveredProjects(event.paths);
@@ -51,7 +70,9 @@ export function useDaemonSync() {
store.setRootSuggestions(event.suggestions);
return;
case "recovery-created": {
- const { openError, recoveredPath } = event.recovery;
+ const { openError, recoveredPath, recoveredProjectId } =
+ event.recovery;
+ pendingBranchIdRef.current = recoveredProjectId;
const revealPath = window.echoform?.revealPath;
const options = {
action: revealPath
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index e84043f..086e41e 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -1,32 +1,29 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
-@import "@fontsource-variable/inter";
+@import "@fontsource-variable/instrument-sans";
+@import "@fontsource-variable/geist-mono";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
- --font-sans: "Inter Variable", sans-serif;
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
+ --font-sans: "Instrument Sans Variable", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "Geist Mono Variable", ui-monospace, monospace;
+ --color-added: var(--added);
+ --color-removed: var(--removed);
+ --color-edited: var(--edited);
+ --color-line: var(--line);
+ --color-raised: var(--raised);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
+ --color-subtle-foreground: var(--subtle-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
@@ -37,6 +34,7 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
+ --color-sidebar: var(--sidebar);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
@@ -48,73 +46,49 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+/*
+ * Echoform is dark-only graphite. The interface itself stays colourless:
+ * saturated colour on screen comes from the user's music (Ableton's clip
+ * palette), plus three diff states.
+ *
+ * Text tiers:
+ * foreground – primary content
+ * muted-foreground – secondary content, still comfortably readable
+ * subtle-foreground – metadata only (times, counts); never for actions
+ */
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.148 0.004 228.8);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.148 0.004 228.8);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.148 0.004 228.8);
- --primary: oklch(0.511 0.096 186.391);
- --primary-foreground: oklch(0.984 0.014 180.72);
- --secondary: oklch(0.967 0.001 286.375);
- --secondary-foreground: oklch(0.21 0.006 285.885);
- --muted: oklch(0.963 0.002 197.1);
- --muted-foreground: oklch(0.56 0.021 213.5);
- --accent: oklch(0.963 0.002 197.1);
- --accent-foreground: oklch(0.218 0.008 223.9);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.925 0.005 214.3);
- --input: oklch(0.925 0.005 214.3);
- --ring: oklch(0.723 0.014 214.4);
- --chart-1: oklch(0.855 0.138 181.071);
- --chart-2: oklch(0.704 0.14 182.503);
- --chart-3: oklch(0.6 0.118 184.704);
- --chart-4: oklch(0.511 0.096 186.391);
- --chart-5: oklch(0.437 0.078 188.216);
- --radius: 0.75rem;
- --sidebar: oklch(0.987 0.002 197.1);
- --sidebar-foreground: oklch(0.148 0.004 228.8);
- --sidebar-primary: oklch(0.6 0.118 184.704);
- --sidebar-primary-foreground: oklch(0.984 0.014 180.72);
- --sidebar-accent: oklch(0.963 0.002 197.1);
- --sidebar-accent-foreground: oklch(0.218 0.008 223.9);
- --sidebar-border: oklch(0.925 0.005 214.3);
- --sidebar-ring: oklch(0.723 0.014 214.4);
-}
+ --background: #141416;
+ --sidebar: #111113;
+ --raised: #1c1c1f;
+ --line: #2a2a2e;
+ --foreground: #e9e7e3;
+ --muted-foreground: #a3a19c;
+ --subtle-foreground: #6f6d69;
+
+ --card: var(--raised);
+ --card-foreground: var(--foreground);
+ --popover: #1f1f22;
+ --popover-foreground: var(--foreground);
+ --primary: #e9e7e3;
+ --primary-foreground: #141416;
+ --secondary: #26262a;
+ --secondary-foreground: var(--foreground);
+ --muted: #222226;
+ --accent: #232327;
+ --accent-foreground: var(--foreground);
+ --border: var(--line);
+ --input: #34343a;
+ --ring: #8d8b87;
-.dark {
- --background: oklch(0.13 0.006 260);
- --foreground: oklch(0.95 0.005 250);
- --card: oklch(0.17 0.008 260);
- --card-foreground: oklch(0.95 0.005 250);
- --popover: oklch(0.17 0.008 260);
- --popover-foreground: oklch(0.95 0.005 250);
- --primary: oklch(0.65 0.14 182);
- --primary-foreground: oklch(0.13 0.006 260);
- --secondary: oklch(0.22 0.008 260);
- --secondary-foreground: oklch(0.92 0.005 250);
- --muted: oklch(0.2 0.008 260);
- --muted-foreground: oklch(0.6 0.015 250);
- --accent: oklch(0.2 0.008 260);
- --accent-foreground: oklch(0.95 0.005 250);
- --destructive: oklch(0.65 0.2 25);
- --border: oklch(1 0 0 / 8%);
- --input: oklch(1 0 0 / 12%);
- --ring: oklch(0.55 0.02 250);
- --chart-1: oklch(0.855 0.138 181.071);
- --chart-2: oklch(0.704 0.14 182.503);
- --chart-3: oklch(0.6 0.118 184.704);
- --chart-4: oklch(0.511 0.096 186.391);
- --chart-5: oklch(0.437 0.078 188.216);
- --sidebar: oklch(0.15 0.008 260);
- --sidebar-foreground: oklch(0.95 0.005 250);
- --sidebar-primary: oklch(0.65 0.14 182);
- --sidebar-primary-foreground: oklch(0.13 0.006 260);
- --sidebar-accent: oklch(0.2 0.008 260);
- --sidebar-accent-foreground: oklch(0.95 0.005 250);
- --sidebar-border: oklch(1 0 0 / 8%);
- --sidebar-ring: oklch(0.55 0.02 250);
+ --added: #7fd6a2;
+ --removed: #f07a6a;
+ --edited: #f2c36b;
+ --success: var(--added);
+ --warning: var(--edited);
+ --destructive: var(--removed);
+
+ --radius: 0.5rem;
+ color-scheme: dark;
}
@layer base {
@@ -122,6 +96,8 @@
@apply border-border outline-ring/50;
}
body {
+ font-size: 13px;
+ font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@@ -143,29 +119,38 @@
}
.scrollbar-thin {
- scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
+ scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
- width: 4px;
+ width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.08);
- border-radius: 2px;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.2);
}
}
}
-@keyframes shimmer {
- 0% {
- transform: translateX(-120%);
- }
- 100% {
- transform: translateX(400%);
+/* Hatching for clips that were removed in this checkpoint. */
+.hatch-removed {
+ background-image: repeating-linear-gradient(
+ -45deg,
+ color-mix(in srgb, var(--removed) 55%, transparent) 0 1.5px,
+ transparent 1.5px 5px
+ );
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
}
}
diff --git a/apps/desktop/src/lib/ableton-colors.ts b/apps/desktop/src/lib/ableton-colors.ts
new file mode 100644
index 0000000..0f8efbd
--- /dev/null
+++ b/apps/desktop/src/lib/ableton-colors.ts
@@ -0,0 +1,82 @@
+/**
+ * Ableton Live's clip and track colour palette (indices 0–69, Live 10–12).
+ * Track colours are the only saturated colour in Echoform's interface.
+ */
+const PALETTE = [
+ "#FF94A6",
+ "#FFA529",
+ "#CC9927",
+ "#F7F47C",
+ "#BFFB00",
+ "#1AFF2F",
+ "#25FFA8",
+ "#5CFFE8",
+ "#8BC5FF",
+ "#5480E4",
+ "#92A7FF",
+ "#D86CE4",
+ "#E553A0",
+ "#FFFFFF",
+ "#FF3636",
+ "#F66C03",
+ "#99724B",
+ "#FFF034",
+ "#87FF67",
+ "#3DC300",
+ "#00BFAF",
+ "#19E9FF",
+ "#10A4EE",
+ "#007DC0",
+ "#886CE4",
+ "#B677C6",
+ "#FF39D4",
+ "#D0D0D0",
+ "#E2675A",
+ "#FFA374",
+ "#D4AD71",
+ "#E8E55C",
+ "#98B954",
+ "#56C733",
+ "#00A279",
+ "#3FC0D2",
+ "#82B3F2",
+ "#4668C4",
+ "#8E69CF",
+ "#A34FA5",
+ "#EB57A3",
+ "#A0A0A0",
+ "#CC3B3C",
+ "#D47B35",
+ "#A07843",
+ "#C4B946",
+ "#84A131",
+ "#539F31",
+ "#0F7B3F",
+ "#2FA09E",
+ "#4A7CAD",
+ "#3B55A1",
+ "#6847A4",
+ "#A04F8D",
+ "#BE478E",
+ "#707070",
+ "#AF3333",
+ "#A95131",
+ "#724F41",
+ "#9F9B27",
+ "#6E8C22",
+ "#53742F",
+ "#274F2D",
+ "#254D52",
+ "#3B5E7E",
+ "#254475",
+ "#4D3478",
+ "#6B3662",
+ "#A33E64",
+ "#535353",
+];
+
+const FALLBACK = "#8d8b87";
+
+export function abletonColor(index: number): string {
+ return PALETTE[index] ?? FALLBACK;
+}
diff --git a/apps/desktop/src/lib/onboarding-store.ts b/apps/desktop/src/lib/onboarding-store.ts
index 010ce30..8a13516 100644
--- a/apps/desktop/src/lib/onboarding-store.ts
+++ b/apps/desktop/src/lib/onboarding-store.ts
@@ -1,11 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-export type OnboardingStep =
- | "welcome"
- | "pick-folder"
- | "how-it-works"
- | "done";
+export type OnboardingStep = "welcome" | "pick-folder" | "done";
interface OnboardingStore {
complete: () => void;
@@ -23,7 +19,15 @@ export const useOnboardingStore = create()(
step: "welcome",
}),
{
+ // v0 had a third "how-it-works" step; resume those users at folder setup.
+ migrate: (persisted) => {
+ const state = persisted as { step?: string } | undefined;
+ return {
+ step: state?.step === "how-it-works" ? "pick-folder" : state?.step,
+ } as OnboardingStore;
+ },
name: "echoform-onboarding",
+ version: 1,
}
)
);
diff --git a/apps/desktop/src/lib/project-status.ts b/apps/desktop/src/lib/project-status.ts
new file mode 100644
index 0000000..b8516dd
--- /dev/null
+++ b/apps/desktop/src/lib/project-status.ts
@@ -0,0 +1,69 @@
+import type { Project } from "@/lib/types";
+
+/** When the newest checkpoint was recorded; `updatedAt` also moves on settings changes. */
+export function lastCheckpointAt(project: Project): string | null {
+ let latest: string | null = null;
+ for (const save of project.saves) {
+ if (!latest || save.createdAt > latest) {
+ latest = save.createdAt;
+ }
+ }
+ return latest;
+}
+
+export type ProjectStatusTone = "ok" | "paused" | "warning" | "error";
+
+export interface ProjectStatus {
+ /** One-line explanation of what Echoform is (or isn't) doing right now. */
+ description: string;
+ label: string;
+ tone: ProjectStatusTone;
+}
+
+/**
+ * The single source of truth for "is this project protected right now?".
+ * Every surface (sidebar, header, search) must use this so they never disagree.
+ */
+export function getProjectStatus(project: Project): ProjectStatus {
+ if (project.presence === "missing") {
+ return {
+ description:
+ "The project folder moved or is unavailable. History is safe, but new saves are not being recorded.",
+ label: "Folder missing",
+ tone: "warning",
+ };
+ }
+ if (project.watchError) {
+ return {
+ description: project.watchError,
+ label: "Not recording",
+ tone: "error",
+ };
+ }
+ if (!project.watching) {
+ return {
+ description: "Saves in Ableton are not being recorded until you resume.",
+ label: "Paused",
+ tone: "paused",
+ };
+ }
+ return {
+ description: "Every save in Ableton becomes a checkpoint.",
+ label: "Recording saves",
+ tone: "ok",
+ };
+}
+
+export const STATUS_DOT_CLASS: Record = {
+ error: "bg-destructive",
+ ok: "bg-success",
+ paused: "bg-subtle-foreground",
+ warning: "bg-warning",
+};
+
+export const STATUS_TEXT_CLASS: Record = {
+ error: "text-destructive",
+ ok: "text-success",
+ paused: "text-muted-foreground",
+ warning: "text-warning",
+};
diff --git a/apps/desktop/src/lib/store.test.ts b/apps/desktop/src/lib/store.test.ts
index c41bd41..8be181f 100644
--- a/apps/desktop/src/lib/store.test.ts
+++ b/apps/desktop/src/lib/store.test.ts
@@ -70,7 +70,6 @@ describe("useStore", () => {
useStore.setState({
activeIdeaId: null,
activity: [],
- compare: null,
discoveredProjects: [],
projects: [],
rootSuggestions: [],
@@ -129,7 +128,6 @@ describe("useStore", () => {
act(() =>
useStore.setState({
activeIdeaId: "idea-1",
- compare: {} as never,
selectedProjectId: "other",
selectedSaveId: "save-1",
})
@@ -140,7 +138,6 @@ describe("useStore", () => {
expect(useStore.getState().selectedProjectId).toBe("proj-1");
expect(useStore.getState().selectedSaveId).toBeNull();
expect(useStore.getState().activeIdeaId).toBeNull();
- expect(useStore.getState().compare).toBeNull();
});
it("toggleSave() selects, deselects, and switches saves", () => {
diff --git a/apps/desktop/src/lib/store.ts b/apps/desktop/src/lib/store.ts
index 735814c..6b1be49 100644
--- a/apps/desktop/src/lib/store.ts
+++ b/apps/desktop/src/lib/store.ts
@@ -1,7 +1,6 @@
import { create } from "zustand";
import type {
ActivityItem,
- CompareResult,
DiscoveredProject,
Project,
RootSuggestion,
@@ -19,7 +18,6 @@ interface Store {
roots: TrackedRoot[],
activity: ActivityItem[]
) => void;
- compare: CompareResult | null;
discoveredProjects: DiscoveredProject[];
projects: Project[];
rootSuggestions: RootSuggestion[];
@@ -32,7 +30,6 @@ interface Store {
selectedSaveId: string | null;
selectProject: (id: string | null) => void;
setActiveIdea: (id: string) => void;
- setCompare: (compare: CompareResult | null) => void;
setDiscoveredProjects: (projects: DiscoveredProject[]) => void;
setRootSuggestions: (suggestions: RootSuggestion[]) => void;
snapshotReceived: boolean;
@@ -113,7 +110,6 @@ export const useStore = create((set, get) => ({
state.activeIdeaId
),
})),
- compare: null,
discoveredProjects: [],
projects: [],
rootSuggestions: [],
@@ -139,7 +135,6 @@ export const useStore = create((set, get) => ({
selectProject: (id) =>
set({
activeIdeaId: null,
- compare: null,
selectedProjectId: id,
selectedSaveId: null,
}),
@@ -149,7 +144,6 @@ export const useStore = create((set, get) => ({
activeIdeaId: id,
selectedSaveId: null,
}),
- setCompare: (compare) => set({ compare }),
setDiscoveredProjects: (projects) => set({ discoveredProjects: projects }),
setRootSuggestions: (suggestions) =>
diff --git a/apps/desktop/src/lib/track-name.ts b/apps/desktop/src/lib/track-name.ts
new file mode 100644
index 0000000..701a521
--- /dev/null
+++ b/apps/desktop/src/lib/track-name.ts
@@ -0,0 +1,13 @@
+/**
+ * How a producer refers to a track: without Live's auto-number ("4-Serum 2"
+ * → "Serum 2") and without the "[2026-01-10 084550]" suffix Live adds to
+ * consolidated audio. Mirrors displayName in packages/server/src/set-compare.ts.
+ */
+export function displayTrackName(name: string): string {
+ return (
+ name
+ .replace(/^\d+-/, "")
+ .replace(/\s*\[\d{4}-\d{2}-\d{2} \d{6}\]$/, "")
+ .trim() || name
+ );
+}
diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts
index 2ae1b73..e6fc62c 100644
--- a/apps/desktop/src/lib/types.ts
+++ b/apps/desktop/src/lib/types.ts
@@ -2,20 +2,23 @@
// This avoids duplication while keeping a single source of truth
export type {
ActivityItem,
- CompareResult,
+ ChangeWeight,
+ ClipAnalysis,
DiscoveredProject,
DiskUsage,
- DiskUsageSave,
Idea,
+ MiniNote,
PreviewRequestResult,
PreviewStatus,
Project,
RootSuggestion,
Save,
- SetDiff,
- TrackDiff,
+ SaveAnalysis,
+ SaveSummary,
+ SetChange,
+ TrackAnalysis,
+ TrackChange,
TrackedRoot,
- TrackSummaryItem,
WsCommand,
WsEvent,
} from "../../../../packages/server/src/types";
diff --git a/apps/desktop/src/lib/utils.ts b/apps/desktop/src/lib/utils.ts
index 7f2a6d9..7c89804 100644
--- a/apps/desktop/src/lib/utils.ts
+++ b/apps/desktop/src/lib/utils.ts
@@ -22,5 +22,35 @@ export function timeAgo(iso: string | null | undefined): string {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
- return `${days}d ago`;
+ if (days < 30) {
+ return `${days}d ago`;
+ }
+ return new Date(iso).toLocaleDateString(undefined, {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+/** Replace the home directory with ~ and the iCloud Drive container with ~/iCloud. */
+export function shortenPath(path: string): string {
+ let short = path;
+ const parts = short.split("/");
+ if (parts.length >= 3 && (parts[1] === "Users" || parts[1] === "home")) {
+ const home = `/${parts[1]}/${parts[2]}`;
+ if (short === home) {
+ return "~";
+ }
+ if (short.startsWith(`${home}/`)) {
+ short = `~${short.slice(home.length)}`;
+ }
+ }
+ return short.replace(
+ "~/Library/Mobile Documents/com~apple~CloudDocs",
+ "~/iCloud"
+ );
+}
+
+export function plural(count: number, noun: string, pluralNoun = `${noun}s`) {
+ return `${count} ${count === 1 ? noun : pluralNoun}`;
}
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index 42b9a25..366eb42 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -12,7 +12,6 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
-import { ThemeProvider } from "@/components/theme-provider.tsx";
import App from "./App.tsx";
const queryClient = new QueryClient();
@@ -20,9 +19,7 @@ const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
-
-
-
+
);
diff --git a/bun.lock b/bun.lock
index 07d09f0..6eb0ffd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,7 +11,7 @@
},
"apps/desktop": {
"name": "echoform",
- "version": "0.0.22",
+ "version": "0.0.23",
"dependencies": {
"@sentry/electron": "^7.16.0",
"@sentry/vite-plugin": "^5.4.0",
@@ -20,13 +20,12 @@
"wavesurfer.js": "^7.12.11",
},
"devDependencies": {
- "@base-ui/react": "^1.7.0",
"@biomejs/biome": "2.5.7",
- "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource-variable/geist-mono": "^5.3.0",
+ "@fontsource-variable/instrument-sans": "^5.3.0",
"@phosphor-icons/react": "^2.1.10",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
- "@tanstack/react-virtual": "^3.14.9",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
@@ -39,20 +38,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
- "date-fns": "^4.4.0",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
- "embla-carousel-react": "^8.6.0",
- "input-otp": "^1.4.2",
"jsdom": "^30.0.1",
- "next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
- "react-day-picker": "^10.0.1",
"react-dom": "^19.2.8",
- "react-resizable-panels": "^4.12.2",
- "recharts": "3.10.1",
"shadcn": "^4.16.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -60,7 +52,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "~7.0.2",
"ultracite": "7.10.0",
- "vaul": "^1.1.2",
"vite": "^8.2.0",
"vitest": "^4.1.10",
"zustand": "^5.0.14",
@@ -86,14 +77,10 @@
"packages/server": {
"name": "echoform-server",
"dependencies": {
- "cheerio": "^1.2.0",
"fast-xml-parser": "^5.10.1",
- "xml2js": "^0.6.2",
},
"devDependencies": {
"@types/bun": "^1.3.14",
- "@types/cheerio": "^1.0.0",
- "@types/xml2js": "^0.4.14",
"typescript": "~7.0.2",
},
},
@@ -224,10 +211,6 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
- "@base-ui/react": ["@base-ui/react@1.7.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.2", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug=="],
-
- "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="],
-
"@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="],
@@ -288,8 +271,6 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
- "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
-
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.59.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-Qg+meC+XFxliuVSDlEPkKnaUjdaJKK6FNx/Wwl2UxhQR8pyPIuLhMavsF7ePdB9qFZUWV1jEK3ckbJir/WmF4w=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
@@ -400,13 +381,17 @@
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
- "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
- "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
- "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
+ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="],
- "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
+ "@fontsource-variable/instrument-sans": ["@fontsource-variable/instrument-sans@5.3.0", "", {}, "sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
@@ -764,8 +749,6 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
- "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
-
"@release-it/conventional-changelog": ["@release-it/conventional-changelog@12.0.0", "", { "dependencies": { "@conventional-changelog/git-client": "^3.1.0", "concat-stream": "^2.0.0", "conventional-changelog": "^8.1.0", "conventional-changelog-angular": "^9.2.1", "conventional-changelog-conventionalcommits": "^10.2.1", "conventional-recommended-bump": "^12.1.0", "semver": "^7.8.5" }, "peerDependencies": { "release-it": "^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0" } }, "sha512-b9N8/tUjO9JNH1u2fVSmq8QmbqnE//Y5iCgvPcytsk0pj0GhgGCp+VmfzhEBIdd+lVGZJKs+ZSaGaOHjtmtv7g=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA=="],
@@ -930,8 +913,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
- "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
-
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@@ -968,10 +949,6 @@
"@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
- "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="],
-
- "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="],
-
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="],
@@ -992,26 +969,6 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
- "@types/cheerio": ["@types/cheerio@1.0.0", "", { "dependencies": { "cheerio": "*" } }, "sha512-zAaImHWoh5RY2CLgU2mvg3bl2k3F65B0N5yphuII3ythFLPmJhL7sj1RDu6gSxcgqHlETbr/lhA2OBY+WF1fXQ=="],
-
- "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
-
- "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
-
- "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
-
- "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
-
- "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
-
- "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
-
- "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
-
- "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
-
- "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
-
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -1056,12 +1013,8 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
- "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
-
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
- "@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="],
-
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.66.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.66.0", "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="],
@@ -1284,10 +1237,6 @@
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
- "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
-
- "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
-
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -1392,40 +1341,14 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
- "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
-
- "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
-
- "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
-
- "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
-
- "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
-
- "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
-
- "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
-
- "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
-
- "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
-
- "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
-
- "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
-
"data-uri-to-buffer": ["data-uri-to-buffer@8.0.0", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
- "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
-
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
- "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
-
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
@@ -1520,12 +1443,6 @@
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
- "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
-
- "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
-
- "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
-
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1534,8 +1451,6 @@
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
- "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
-
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -1558,8 +1473,6 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
- "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
-
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
@@ -1760,8 +1673,6 @@
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
- "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
-
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -1774,7 +1685,7 @@
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
- "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1792,10 +1703,6 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
-
- "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
-
"ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -2022,8 +1929,6 @@
"new-github-release-url": ["new-github-release-url@2.0.0", "", { "dependencies": { "type-fest": "^2.5.1" } }, "sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ=="],
- "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
-
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
@@ -2116,10 +2021,6 @@
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
- "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
-
- "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
-
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -2234,20 +2135,14 @@
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
- "react-day-picker": ["react-day-picker@10.0.1", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="],
-
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
- "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
-
- "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
+ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
- "react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="],
-
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
@@ -2258,14 +2153,8 @@
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
- "recharts": ["recharts@3.10.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA=="],
-
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
- "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
-
- "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
-
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
@@ -2284,8 +2173,6 @@
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
- "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
-
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -2558,16 +2445,12 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
- "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
-
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
- "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
-
"vite": ["vite@8.2.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.23", "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ=="],
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
@@ -2622,8 +2505,6 @@
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
- "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
-
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@17.1.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw=="],
@@ -2648,9 +2529,7 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
- "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
-
- "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
+ "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
@@ -2724,8 +2603,6 @@
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
- "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
-
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -2850,8 +2727,6 @@
"@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
- "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
-
"@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
@@ -2916,10 +2791,6 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
- "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
-
- "@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
-
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
@@ -2964,8 +2835,6 @@
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
- "@types/xml2js/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
-
"@typescript-eslint/project-service/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"@typescript-eslint/tsconfig-utils/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
@@ -3002,8 +2871,6 @@
"astro/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
- "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
-
"builder-util/http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -3014,10 +2881,6 @@
"cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
- "cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
-
- "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
-
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -3052,6 +2915,8 @@
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
+ "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -3078,8 +2943,6 @@
"html-encoding-sniffer/@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
- "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
-
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -3104,22 +2967,14 @@
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
- "parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
-
- "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
-
"path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
- "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
-
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
- "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
-
"promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -3136,8 +2991,6 @@
"radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
- "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
-
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -3220,8 +3073,6 @@
"@radix-ui/react-dialog/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
- "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
-
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -3256,8 +3107,6 @@
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
- "@types/xml2js/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
-
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
@@ -3272,8 +3121,6 @@
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
- "cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
-
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
@@ -3308,10 +3155,6 @@
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "parse5-htmlparser2-tree-adapter/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
-
- "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
-
"radix-ui/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -3334,10 +3177,6 @@
"@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
- "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
-
- "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
-
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
diff --git a/packages/server/package.json b/packages/server/package.json
index 65082a2..977d9ff 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -11,13 +11,9 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
- "@types/cheerio": "^1.0.0",
- "@types/xml2js": "^0.4.14",
"typescript": "~7.0.2"
},
"dependencies": {
- "cheerio": "^1.2.0",
- "fast-xml-parser": "^5.10.1",
- "xml2js": "^0.6.2"
+ "fast-xml-parser": "^5.10.1"
}
}
diff --git a/packages/server/src/als-diff.ts b/packages/server/src/als-diff.ts
deleted file mode 100644
index bc4f932..0000000
--- a/packages/server/src/als-diff.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-/**
- * als-diff.ts – Compute a semantic diff between two Ableton Live Set snapshots.
- *
- * Matches tracks by their stable XML Id, detects renames, and surfaces
- * musically-meaningful changes: tracks, devices, clips, tempo, time sig, mixer.
- */
-
-import type { DeviceSnapshot, SetSnapshot, TrackSnapshot } from "./als-parser";
-import type { SetDiff, TrackDiff } from "./types";
-
-export type { SetDiff, TrackDiff };
-
-// ── Helpers ─────────────────────────────────────────────────────────
-
-/** Round to avoid floating-point noise when comparing mixer values. */
-function fuzzyEq(a: number, b: number, epsilon = 0.0001): boolean {
- return Math.abs(a - b) < epsilon;
-}
-
-function diffDevices(
- prev: DeviceSnapshot[],
- curr: DeviceSnapshot[],
-): {
- added: string[];
- removed: string[];
- toggled: { name: string; enabled: boolean }[];
-} {
- const prevMap = new Map(prev.map((d) => [d.id, d]));
- const currMap = new Map(curr.map((d) => [d.id, d]));
-
- const added: string[] = [];
- const removed: string[] = [];
- const toggled: { name: string; enabled: boolean }[] = [];
-
- for (const [id, device] of currMap) {
- const prevDevice = prevMap.get(id);
- if (!prevDevice) {
- added.push(device.name);
- } else if (prevDevice.enabled !== device.enabled) {
- toggled.push({ name: device.name, enabled: device.enabled });
- }
- }
- for (const [id, device] of prevMap) {
- if (!currMap.has(id)) {
- removed.push(device.name);
- }
- }
-
- return { added, removed, toggled };
-}
-
-function diffClips(
- prev: string[],
- curr: string[],
-): { added: string[]; removed: string[] } {
- // Clips don't have stable IDs, so we diff by name using multiset comparison
- const prevCounts = new Map();
- const currCounts = new Map();
- for (const n of prev) {
- prevCounts.set(n, (prevCounts.get(n) ?? 0) + 1);
- }
- for (const n of curr) {
- currCounts.set(n, (currCounts.get(n) ?? 0) + 1);
- }
-
- const added: string[] = [];
- const removed: string[] = [];
-
- for (const [name, count] of currCounts) {
- const prevCount = prevCounts.get(name) ?? 0;
- for (let i = 0; i < count - prevCount; i++) {
- added.push(name);
- }
- }
- for (const [name, count] of prevCounts) {
- const currCount = currCounts.get(name) ?? 0;
- for (let i = 0; i < count - currCount; i++) {
- removed.push(name);
- }
- }
-
- return { added, removed };
-}
-
-function diffMixer(prev: TrackSnapshot, curr: TrackSnapshot): string[] {
- const changes: string[] = [];
- if (!fuzzyEq(prev.volume, curr.volume)) {
- changes.push("volume");
- }
- if (!fuzzyEq(prev.pan, curr.pan)) {
- changes.push("pan");
- }
- if (prev.muted !== curr.muted) {
- changes.push(curr.muted ? "muted" : "unmuted");
- }
- if (prev.soloed !== curr.soloed) {
- changes.push(curr.soloed ? "soloed" : "unsoloed");
- }
- return changes;
-}
-
-// ── Main differ ─────────────────────────────────────────────────────
-
-export function diffSets(prev: SetSnapshot, curr: SetSnapshot): SetDiff {
- // Tempo
- const tempoChange =
- prev.tempo === curr.tempo ? null : { from: prev.tempo, to: curr.tempo };
-
- // Time signature
- const timeSignatureChange =
- prev.timeSignature === curr.timeSignature
- ? null
- : { from: prev.timeSignature, to: curr.timeSignature };
-
- // Arrangement length
- const arrangementLengthChange = fuzzyEq(
- prev.arrangementLength,
- curr.arrangementLength,
- 0.01,
- )
- ? null
- : { from: prev.arrangementLength, to: curr.arrangementLength };
-
- // Scene count
- const sceneCountChange =
- prev.sceneCount === curr.sceneCount
- ? null
- : { from: prev.sceneCount, to: curr.sceneCount };
-
- // Locator / cue point count
- const locatorCountChange =
- prev.locatorCount === curr.locatorCount
- ? null
- : { from: prev.locatorCount, to: curr.locatorCount };
-
- // Build track maps keyed by stable XML Id
- const prevTracks = new Map(prev.tracks.map((t) => [t.id, t]));
- const currTracks = new Map(curr.tracks.map((t) => [t.id, t]));
-
- const addedTracks: SetDiff["addedTracks"] = [];
- const removedTracks: SetDiff["removedTracks"] = [];
- const modifiedTracks: TrackDiff[] = [];
-
- // Detect added tracks
- for (const [id, track] of currTracks) {
- if (!prevTracks.has(id)) {
- addedTracks.push({ name: track.name, type: track.type });
- }
- }
-
- // Detect removed tracks
- for (const [id, track] of prevTracks) {
- if (!currTracks.has(id)) {
- removedTracks.push({ name: track.name, type: track.type });
- }
- }
-
- // Detect track reordering (compare order of shared track IDs)
- const prevOrder = prev.tracks
- .map((t) => t.id)
- .filter((id) => currTracks.has(id));
- const currOrder = curr.tracks
- .map((t) => t.id)
- .filter((id) => prevTracks.has(id));
- let tracksReordered = false;
- if (prevOrder.length === currOrder.length) {
- tracksReordered = prevOrder.some((id, i) => id !== currOrder[i]);
- }
-
- // Detect modified tracks (present in both)
- for (const [id, currTrack] of currTracks) {
- const prevTrack = prevTracks.get(id);
- if (!prevTrack) {
- continue;
- }
-
- const renamedFrom =
- prevTrack.name === currTrack.name ? undefined : prevTrack.name;
- const colorChanged = prevTrack.color !== currTrack.color;
- const {
- added: addedDevices,
- removed: removedDevices,
- toggled: deviceToggles,
- } = diffDevices(prevTrack.devices, currTrack.devices);
- const clipCountDelta = currTrack.clipCount - prevTrack.clipCount;
- const { added: addedClips, removed: removedClips } = diffClips(
- prevTrack.clipNames,
- currTrack.clipNames,
- );
- const mixerChanges = diffMixer(prevTrack, currTrack);
-
- // Only include if something actually changed
- const hasChanges =
- renamedFrom !== undefined ||
- colorChanged ||
- addedDevices.length > 0 ||
- removedDevices.length > 0 ||
- deviceToggles.length > 0 ||
- clipCountDelta !== 0 ||
- mixerChanges.length > 0;
-
- if (hasChanges) {
- modifiedTracks.push({
- name: currTrack.name,
- type: currTrack.type,
- renamedFrom,
- colorChanged,
- addedDevices,
- removedDevices,
- deviceToggles,
- clipCountDelta,
- addedClips,
- removedClips,
- mixerChanges,
- });
- }
- }
-
- return {
- tempoChange,
- timeSignatureChange,
- arrangementLengthChange,
- sceneCountChange,
- locatorCountChange,
- addedTracks,
- removedTracks,
- modifiedTracks,
- tracksReordered,
- };
-}
-
-/** Returns true if the diff contains no meaningful changes. */
-export function isEmptyDiff(diff: SetDiff): boolean {
- return (
- diff.tempoChange === null &&
- diff.timeSignatureChange === null &&
- diff.arrangementLengthChange === null &&
- diff.sceneCountChange === null &&
- diff.locatorCountChange === null &&
- !diff.tracksReordered &&
- diff.addedTracks.length === 0 &&
- diff.removedTracks.length === 0 &&
- diff.modifiedTracks.length === 0
- );
-}
diff --git a/packages/server/src/als-parser.test.ts b/packages/server/src/als-parser.test.ts
deleted file mode 100644
index f4106b2..0000000
--- a/packages/server/src/als-parser.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { gzipSync } from "node:zlib";
-import type { SetSnapshot, TrackSnapshot } from "./als-parser";
-import { extractTrackSummary, parseAlsFile } from "./als-parser";
-
-const tempDirs: string[] = [];
-
-afterEach(async () => {
- await Promise.all(
- tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
- );
-});
-
-function makeTrack(
- id: string,
- type: TrackSnapshot["type"],
- name: string,
- fields: Partial = {},
-): TrackSnapshot {
- return {
- id,
- type,
- name,
- color: 0,
- groupId: null,
- muted: false,
- soloed: false,
- volume: 0.85,
- pan: 0,
- devices: [],
- clipCount: 0,
- clipNames: [],
- ...fields,
- };
-}
-
-async function writeAlsFile(xml: string): Promise {
- const dir = await mkdtemp(join(tmpdir(), "echoform-als-parser-"));
- tempDirs.push(dir);
- const filePath = join(dir, "test.als");
- await writeFile(filePath, gzipSync(xml));
- return filePath;
-}
-
-function trackXml(
- tag: "AudioTrack" | "MidiTrack" | "ReturnTrack" | "GroupTrack",
- id: string,
- name: string,
- groupId = -1,
-): string {
- return `
- <${tag} Id="${id}">
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${tag}>
- `.trim();
-}
-
-describe("extractTrackSummary", () => {
- test("aggregates nested groups and keeps orphaned tracks visible", () => {
- const snapshot: SetSnapshot = {
- tempo: 120,
- timeSignature: "4/4",
- arrangementLength: 0,
- locatorCount: 0,
- sceneCount: 0,
- tracks: [
- makeTrack("group-1", "group", "Drums", { clipCount: 1 }),
- makeTrack("audio-1", "audio", "Kick", {
- groupId: "group-1",
- clipCount: 2,
- }),
- makeTrack("group-2", "group", "Perc", {
- groupId: "group-1",
- clipCount: 0,
- }),
- makeTrack("midi-1", "midi", "Hat", {
- groupId: "group-2",
- clipCount: 4,
- }),
- makeTrack("return-1", "return", "Reverb", { clipCount: 1 }),
- makeTrack("audio-2", "audio", "Loose Audio", {
- groupId: "missing-group",
- clipCount: 3,
- }),
- ],
- };
-
- expect(extractTrackSummary(snapshot)).toEqual([
- {
- name: "Drums",
- type: "group",
- color: 0,
- clipCount: 7,
- trackCount: 4,
- children: [
- {
- name: "Kick",
- type: "audio",
- color: 0,
- clipCount: 2,
- trackCount: 1,
- children: undefined,
- },
- {
- name: "Perc",
- type: "group",
- color: 0,
- clipCount: 4,
- trackCount: 2,
- children: [
- {
- name: "Hat",
- type: "midi",
- color: 0,
- clipCount: 4,
- trackCount: 1,
- children: undefined,
- },
- ],
- },
- ],
- },
- {
- name: "Reverb",
- type: "return",
- color: 0,
- clipCount: 1,
- trackCount: 1,
- children: undefined,
- },
- {
- name: "Loose Audio",
- type: "audio",
- color: 0,
- clipCount: 3,
- trackCount: 1,
- children: undefined,
- },
- ]);
- });
-});
-
-describe("parseAlsFile", () => {
- test("preserves mixed track order from the XML", async () => {
- const xml = `
-
-
-
- ${trackXml("AudioTrack", "1", "Audio First")}
- ${trackXml("GroupTrack", "2", "Band Bus")}
- ${trackXml("MidiTrack", "3", "Bass MIDI", 2)}
- ${trackXml("AudioTrack", "4", "Audio Last")}
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- const filePath = await writeAlsFile(xml);
- const snapshot = await parseAlsFile(filePath);
-
- expect(snapshot.tracks.map((track) => track.name)).toEqual([
- "Audio First",
- "Band Bus",
- "Bass MIDI",
- "Audio Last",
- ]);
- expect(snapshot.tracks.map((track) => track.type)).toEqual([
- "audio",
- "group",
- "midi",
- "audio",
- ]);
- });
-});
diff --git a/packages/server/src/als-parser.ts b/packages/server/src/als-parser.ts
deleted file mode 100644
index 923d1d5..0000000
--- a/packages/server/src/als-parser.ts
+++ /dev/null
@@ -1,536 +0,0 @@
-/**
- * als-parser.ts – Extract a structured snapshot from an Ableton Live Set (.als) file.
- *
- * .als files are gzipped XML. We decompress, parse the XML, and pull out
- * the musically-relevant data: tracks, devices, clips, tempo, time sig.
- */
-
-import { readFile } from "node:fs/promises";
-import { gunzipSync } from "node:zlib";
-import { XMLParser } from "fast-xml-parser";
-import type { TrackSummaryItem } from "./types";
-
-// ── Public types ────────────────────────────────────────────────────
-
-export interface SetSnapshot {
- arrangementLength: number; // beats (from MainTrack's ArrangementLength)
- locatorCount: number; // number of cue points / locators
- sceneCount: number; // number of scenes in session view
- tempo: number;
- timeSignature: string; // e.g. "4/4"
- tracks: TrackSnapshot[];
-}
-
-export interface TrackSnapshot {
- clipCount: number;
- clipNames: string[];
- color: number;
- devices: DeviceSnapshot[];
- groupId: string | null; // parent GroupTrack id, or null for top-level tracks
- id: string;
- muted: boolean; // Speaker off = muted
- name: string;
- pan: number;
- soloed: boolean;
- type: "audio" | "midi" | "return" | "group";
- volume: number; // raw internal value
-}
-
-export interface DeviceSnapshot {
- className: string; // XML tag name: "Reverb", "AuPluginDevice", etc.
- enabled: boolean;
- id: string;
- name: string; // human-readable: "Pro-Q 4", "Reverb", etc.
-}
-
-// ── XML parser setup ────────────────────────────────────────────────
-
-const parser = new XMLParser({
- ignoreAttributes: false,
- attributeNamePrefix: "@_",
- // We need to force certain elements to always be arrays, even when
- // there's only one child. fast-xml-parser collapses single-child
- // arrays into plain objects otherwise.
- isArray: (
- _name: string,
- _jpath: any,
- isLeafNode: boolean,
- _isAttribute: boolean,
- ) => {
- // Only force non-leaf nodes into arrays when they're known list items.
- if (isLeafNode) {
- return false;
- }
- const listTags = new Set([
- "MidiTrack",
- "AudioTrack",
- "ReturnTrack",
- "GroupTrack",
- "ClipSlot",
- "MidiClip",
- "AudioClip",
- "TrackSendHolder",
- ]);
- return listTags.has(_name);
- },
-});
-
-const orderedParser = new XMLParser({
- ignoreAttributes: false,
- attributeNamePrefix: "@_",
- preserveOrder: true,
-});
-
-// ── Helpers ─────────────────────────────────────────────────────────
-
-/** Safely get a property that may be an object or missing entirely. */
-function val(node: any, ...path: string[]): any {
- let cur = node;
- for (const key of path) {
- if (cur == null || typeof cur !== "object") {
- return undefined;
- }
- cur = cur[key];
- }
- return cur;
-}
-
-/** Read the Value attribute that Ableton puts everywhere: */
-function attrVal(node: any): string | undefined {
- if (node == null) {
- return undefined;
- }
- return node["@_Value"] ?? undefined;
-}
-
-/** Wrap a value that might be a single object into an array. */
-function asArray(x: T | T[] | undefined | null): T[] {
- if (x == null) {
- return [];
- }
- return Array.isArray(x) ? x : [x];
-}
-
-/** Decode Ableton's encoded time signature integer.
- * Formula: value = (numerator - 1) + denominator_index * 99
- * where denominator = 2 ^ denominator_index (1, 2, 4, 8, 16, …)
- */
-function decodeTimeSignature(encoded: number): string {
- const denomIndex = Math.floor(encoded / 99);
- const numerator = (encoded % 99) + 1;
- const denominator = 2 ** denomIndex;
- return `${numerator}/${denominator}`;
-}
-
-// Known Ableton-native device tags → friendly names.
-// Plugins (AU/VST/VST3/Max) are handled separately.
-const NATIVE_DEVICE_NAMES: Record = {
- Reverb: "Reverb",
- Delay: "Delay",
- Compressor2: "Compressor",
- Eq8: "EQ Eight",
- AutoFilter: "Auto Filter",
- Saturator: "Saturator",
- GlueCompressor: "Glue Compressor",
- Chorus2: "Chorus-Ensemble",
- Phaser: "Phaser-Flanger",
- Redux2: "Redux",
- Gate: "Gate",
- Erosion: "Erosion",
- FilterDelay: "Filter Delay",
- GrainDelay: "Grain Delay",
- BeatRepeat: "Beat Repeat",
- Looper: "Looper",
- Tuner: "Tuner",
- Limiter: "Limiter",
- MultibandDynamics: "Multiband Dynamics",
- OriginalSimpler: "Simpler",
- MultiSampler: "Sampler",
- InstrumentGroupDevice: "Instrument Rack",
- AudioEffectGroupDevice: "Audio Effect Rack",
- MidiEffectGroupDevice: "MIDI Effect Rack",
- DrumGroupDevice: "Drum Rack",
- InstrumentVector: "Wavetable",
- Drift: "Drift",
- Collision: "Collision",
- StringStudio: "Tension",
- LoungeLizard: "Electric",
- UltraAnalog: "Analog",
- Operator: "Operator",
- MxDeviceAudioEffect: "Max Audio Effect",
- MxDeviceInstrument: "Max Instrument",
- MxDeviceMidiEffect: "Max MIDI Effect",
- ProxyAudioEffectDevice: "Audio Effect Rack",
- ProxyInstrumentDevice: "Instrument Rack",
- Amp: "Amp",
- Cabinet: "Cabinet",
- Corpus: "Corpus",
- Resonators: "Resonators",
- Vocoder: "Vocoder",
- FrequencyShifter: "Frequency Shifter",
- PingPongDelay: "Ping Pong Delay",
- SimpleDelay: "Simple Delay",
- StereoGain: "Utility",
- Vinyl: "Vinyl Distortion",
- Overdrive: "Overdrive",
- Pedal: "Pedal",
- Echo: "Echo",
- SpectrumAnalyzer: "Spectrum",
- CrossDelay: "Hybrid Reverb", // Live 12
-};
-
-// Tags that represent plugin wrappers
-const PLUGIN_DEVICE_TAGS = new Set([
- "AuPluginDevice",
- "VstPluginDevice",
- "Vst3PluginDevice",
-]);
-
-// Tags that should be skipped (not actual audio devices)
-const SKIP_DEVICE_TAGS = new Set([
- "LomId",
- "LomIdView",
- "IsExpanded",
- "BreakoutIsExpanded",
- "On",
- "ModulationSourceCount",
- "ParametersListWrapper",
- "Pointee",
- "LastSelectedTimeableIndex",
- "LastSelectedClipEnvelopeIndex",
- "LastPresetRef",
- "LockedScripts",
- "IsFolded",
- "ShouldShowPresetName",
- "UserName",
- "Annotation",
- "SourceContext",
- "MpePitchBendUsesTuning",
- "ViewData",
-]);
-
-// ── Device extraction ───────────────────────────────────────────────
-
-function extractPluginName(device: any, tag: string): string {
- // AU plugins
- const auInfo = val(device, "PluginDesc", "AuPluginInfo");
- if (auInfo) {
- return attrVal(val(auInfo, "Name")) ?? tag;
- }
-
- // VST plugins
- const vstInfo = val(device, "PluginDesc", "VstPluginInfo");
- if (vstInfo) {
- return attrVal(val(vstInfo, "PlugName")) ?? tag;
- }
-
- // VST3 plugins
- const vst3Info = val(device, "PluginDesc", "Vst3PluginInfo");
- if (vst3Info) {
- return attrVal(val(vst3Info, "Name")) ?? tag;
- }
-
- return tag;
-}
-
-function extractDevices(devicesNode: any): DeviceSnapshot[] {
- if (!devicesNode || typeof devicesNode !== "object") {
- return [];
- }
-
- const results: DeviceSnapshot[] = [];
-
- for (const [tag, value] of Object.entries(devicesNode)) {
- if (SKIP_DEVICE_TAGS.has(tag) || tag.startsWith("@_")) {
- continue;
- }
-
- for (const device of asArray(value) as any[]) {
- if (device == null || typeof device !== "object") {
- continue;
- }
-
- const id = device["@_Id"] ?? "";
- const enabled = attrVal(val(device, "On", "Manual")) !== "false";
-
- let name: string;
- if (PLUGIN_DEVICE_TAGS.has(tag)) {
- name = extractPluginName(device, tag);
- } else {
- // Native device — use user name if set, otherwise map the tag
- const userName = attrVal(val(device, "UserName"));
- name =
- userName && userName.length > 0
- ? userName
- : (NATIVE_DEVICE_NAMES[tag] ?? tag);
- }
-
- results.push({ id: String(id), className: tag, name, enabled });
- }
- }
-
- return results;
-}
-
-// ── Clip extraction ─────────────────────────────────────────────────
-
-function extractClips(mainSequencer: any): { count: number; names: string[] } {
- if (!mainSequencer) {
- return { count: 0, names: [] };
- }
-
- const names: string[] = [];
-
- // Session clips: ClipSlotList > ClipSlot[] > ClipSlot > Value > MidiClip/AudioClip
- const clipSlotList = val(mainSequencer, "ClipSlotList");
- if (clipSlotList) {
- for (const slot of asArray(val(clipSlotList, "ClipSlot"))) {
- // The inner holds the actual clip
- const inner = val(slot, "ClipSlot");
- if (!inner) {
- continue;
- }
- const clipValue = val(inner, "Value");
- if (!clipValue) {
- continue;
- }
- for (const clipType of ["MidiClip", "AudioClip"]) {
- for (const clip of asArray(clipValue[clipType])) {
- const name = attrVal(val(clip, "Name")) || "(unnamed clip)";
- names.push(name);
- }
- }
- }
- }
-
- // Arrangement clips: ClipTimeable > ArrangerAutomation > Events > MidiClip/AudioClip
- const events = val(
- mainSequencer,
- "ClipTimeable",
- "ArrangerAutomation",
- "Events",
- );
- if (events) {
- for (const clipType of ["MidiClip", "AudioClip"]) {
- for (const clip of asArray(events[clipType])) {
- const name = attrVal(val(clip, "Name")) || "(unnamed clip)";
- names.push(name);
- }
- }
- }
-
- return { count: names.length, names };
-}
-
-// ── Track extraction ────────────────────────────────────────────────
-
-const TRACK_TYPE_MAP: Record = {
- MidiTrack: "midi",
- AudioTrack: "audio",
- ReturnTrack: "return",
- GroupTrack: "group",
-};
-
-const TRACK_TAGS = new Set(Object.keys(TRACK_TYPE_MAP));
-
-function extractTrack(
- trackNode: any,
- trackType: TrackSnapshot["type"],
-): TrackSnapshot {
- const id = String(trackNode["@_Id"] ?? "");
- const name = attrVal(val(trackNode, "Name", "EffectiveName")) ?? "(unnamed)";
- const color = Number(attrVal(val(trackNode, "Color")) ?? -1);
- const rawGroupId = Number(attrVal(val(trackNode, "TrackGroupId")) ?? -1);
- const groupId = rawGroupId >= 0 ? String(rawGroupId) : null;
-
- // Mixer
- const mixer = val(trackNode, "DeviceChain", "Mixer");
- const volume = Number(attrVal(val(mixer, "Volume", "Manual")) ?? 0);
- const pan = Number(attrVal(val(mixer, "Pan", "Manual")) ?? 0);
- const speakerOn = attrVal(val(mixer, "Speaker", "Manual"));
- const muted = speakerOn === "false";
- const soloed = attrVal(val(mixer, "SoloSink")) === "true";
-
- // Devices — in some XML layouts there's an extra DeviceChain nesting
- let devicesNode = val(trackNode, "DeviceChain", "DeviceChain", "Devices");
- if (!devicesNode) {
- devicesNode = val(trackNode, "DeviceChain", "Devices");
- }
- const devices = extractDevices(devicesNode);
-
- // Clips
- const mainSeq = val(trackNode, "DeviceChain", "MainSequencer");
- const { count: clipCount, names: clipNames } = extractClips(mainSeq);
-
- return {
- id,
- type: trackType,
- name,
- color,
- groupId,
- muted,
- soloed,
- volume,
- pan,
- devices,
- clipCount,
- clipNames,
- };
-}
-
-// ── Track summary extraction ────────────────────────────────────────
-
-/**
- * Extract a lightweight track summary from a SetSnapshot for visual thumbnails.
- *
- * Each top-level track becomes one thumbnail row. Groups retain their nested
- * child structure so the UI can render a miniature version of Ableton's track
- * list instead of flattening everything into one misleading bar.
- */
-export function extractTrackSummary(snapshot: SetSnapshot): TrackSummaryItem[] {
- const tracksById = new Map(snapshot.tracks.map((track) => [track.id, track]));
- const childrenByGroup = new Map();
- for (const t of snapshot.tracks) {
- if (t.groupId !== null && tracksById.has(t.groupId)) {
- if (!childrenByGroup.has(t.groupId)) {
- childrenByGroup.set(t.groupId, []);
- }
- childrenByGroup.get(t.groupId)?.push(t);
- }
- }
-
- function buildSummary(track: TrackSnapshot): TrackSummaryItem {
- const children = childrenByGroup.get(track.id) ?? [];
- const childSummaries = children.map(buildSummary);
-
- return {
- name: track.name,
- type: track.type,
- color: track.color,
- clipCount:
- track.clipCount +
- childSummaries.reduce((sum, child) => sum + child.clipCount, 0),
- trackCount:
- 1 +
- childSummaries.reduce((sum, child) => sum + (child.trackCount ?? 1), 0),
- children: childSummaries.length > 0 ? childSummaries : undefined,
- };
- }
-
- const result: TrackSummaryItem[] = [];
-
- for (const t of snapshot.tracks) {
- if (t.groupId !== null && tracksById.has(t.groupId)) {
- continue;
- }
- result.push(buildSummary(t));
- }
-
- return result;
-}
-
-function extractTrackTagOrder(xml: string): string[] {
- const tracksMatch = xml.match(/[\s\S]*?<\/Tracks>/);
- if (!tracksMatch) {
- return [];
- }
-
- try {
- const ordered = orderedParser.parse(tracksMatch[0]);
- const tracks = ordered?.[0]?.Tracks;
- if (!Array.isArray(tracks)) {
- return [];
- }
-
- return tracks.flatMap((node: Record) => {
- const tag = Object.keys(node).find((key) => key !== ":@");
- return tag && TRACK_TAGS.has(tag) ? [tag] : [];
- });
- } catch {
- return [];
- }
-}
-
-// ── Main parser ─────────────────────────────────────────────────────
-
-export async function parseAlsFile(filePath: string): Promise {
- const compressed = await readFile(filePath);
- let xml: string;
- try {
- xml = gunzipSync(compressed).toString("utf-8");
- } catch (err) {
- const detail = err instanceof Error ? err.message : "unknown error";
- throw new Error(
- `Corrupt or invalid .als file — failed to decompress: ${detail}`,
- );
- }
- const doc = parser.parse(xml);
-
- const liveSet = val(doc, "Ableton", "LiveSet");
- if (!liveSet) {
- throw new Error("Invalid .als file: no LiveSet element found.");
- }
-
- // ── Tracks ──────────────────────────────────────────────────────
- const tracksNode = val(liveSet, "Tracks");
- const tracks: TrackSnapshot[] = [];
-
- if (tracksNode) {
- const tracksByTag = Object.fromEntries(
- Object.keys(TRACK_TYPE_MAP).map((tag) => [tag, asArray(tracksNode[tag])]),
- ) as Record;
-
- for (const tag of extractTrackTagOrder(xml)) {
- const trackNode = tracksByTag[tag]?.shift();
- if (!trackNode) {
- continue;
- }
- tracks.push(extractTrack(trackNode, TRACK_TYPE_MAP[tag]!));
- }
-
- for (const [tag, type] of Object.entries(TRACK_TYPE_MAP)) {
- for (const trackNode of tracksByTag[tag] ?? []) {
- tracks.push(extractTrack(trackNode, type));
- }
- }
- }
-
- // ── Tempo & Time Signature (from MainTrack) ─────────────────────
- const mainTrack = val(liveSet, "MainTrack");
- const mainMixer = val(mainTrack, "DeviceChain", "Mixer");
- const tempo = Number(attrVal(val(mainMixer, "Tempo", "Manual")) ?? 120);
- const tsEncoded = Number(
- attrVal(val(mainMixer, "TimeSignature", "Manual")) ?? 201,
- );
- const timeSignature = decodeTimeSignature(tsEncoded);
-
- // ── Arrangement length (beats, from MainTrack) ──────────────────
- const mainSeqRoot = val(mainTrack, "DeviceChain", "MainSequencer");
- const arrangementLength = Number(
- attrVal(
- val(
- mainSeqRoot,
- "ClipTimeable",
- "ArrangerAutomation",
- "ArrangementLength",
- ),
- ) ?? 0,
- );
-
- // ── Scene count (Session view scenes) ───────────────────────────
- const scenes = asArray(val(liveSet, "Scenes", "Scene"));
- const sceneCount = scenes.length;
-
- // ── Locator / cue point count ───────────────────────────────────
- const locators = asArray(val(liveSet, "Locators", "Locators", "Locator"));
- const locatorCount = locators.length;
-
- return {
- tempo,
- timeSignature,
- tracks,
- arrangementLength,
- sceneCount,
- locatorCount,
- };
-}
diff --git a/packages/server/src/core.test.ts b/packages/server/src/core.test.ts
index 2ee6f45..e6d93b7 100644
--- a/packages/server/src/core.test.ts
+++ b/packages/server/src/core.test.ts
@@ -15,6 +15,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AbletonLauncher } from "./ableton-files";
import { EchoformService } from "./core";
+import { writeAls } from "./test-support/als";
import type { AppState, Idea, Project, Save } from "./types";
function expectPresent(value: T | null | undefined): T {
@@ -855,17 +856,67 @@ describe("EchoformService history reliability", () => {
await expect(access(manifestPath)).rejects.toThrow();
});
- test("change backfill uses central history after the working folder is lost", async () => {
- await writeFile(join(projectDir, "sample.wav"), "sample-v1");
+ test("analysis reads from history after the working folder is lost", async () => {
+ const songPath = join(projectDir, "song.als");
+ const track = { id: "1", name: "Lead", type: "midi" as const };
+ await writeAls(songPath, { tracks: [{ ...track, clips: [] }] });
const tracked = await svc.trackProject({ projectPath: projectDir });
await svc.createSave(tracked.id, { label: "Base" });
- await writeFile(join(projectDir, "sample.wav"), "sample-v2-longer");
- await writeFile(join(projectDir, "song.als"), "version-2");
+ await writeAls(songPath, {
+ tracks: [{ ...track, clips: [{ end: 8, notes: [{ key: 60, time: 0 }], start: 4 }] }],
+ });
const latest = expectSave(await svc.createSave(tracked.id, { auto: true }));
- await rm(projectDir, { recursive: true, force: true });
+ await rm(projectDir, { force: true, recursive: true });
+
+ const { analysis } = await svc.getSaveAnalysis(tracked.id, latest.id);
+
+ expect(analysis.summary.headline).toBe("Lead: new part in bar 2");
+ const [project] = await svc.listProjects();
+ expect(project?.saves.find((s) => s.id === latest.id)?.summary?.headline).toBe(
+ "Lead: new part in bar 2",
+ );
+ });
- const computed = await svc.computeChanges(tracked.id, latest.id);
- expect(computed.changes?.modifiedFiles).toContain("sample.wav");
+ test("summaries are backfilled and recomputed when the previous checkpoint is deleted", async () => {
+ const songPath = join(projectDir, "song.als");
+ const lead = (clips: number) => ({
+ tracks: [
+ {
+ clips: Array.from({ length: clips }, (_, i) => ({
+ end: i * 4 + 4,
+ notes: [{ key: 60, time: 0 }],
+ start: i * 4,
+ })),
+ id: "1",
+ name: "Lead",
+ type: "midi" as const,
+ },
+ ],
+ });
+ await writeAls(songPath, lead(1));
+ const tracked = await svc.trackProject({ projectPath: projectDir });
+ await svc.createSave(tracked.id, { label: "One" });
+ await writeAls(songPath, lead(2));
+ const middle = expectSave(await svc.createSave(tracked.id, { auto: true }));
+ await writeAls(songPath, lead(3));
+ const last = expectSave(await svc.createSave(tracked.id, { auto: true }));
+
+ while ((await svc.summarizeNextSave()) !== undefined) {
+ // drain
+ }
+ let [project] = await svc.listProjects();
+ expect(project?.saves.find((s) => s.id === last.id)?.summary?.headline).toBe(
+ "Lead: new part in bar 3",
+ );
+
+ await svc.deleteSave(tracked.id, middle.id);
+ while ((await svc.summarizeNextSave()) !== undefined) {
+ // drain
+ }
+ [project] = await svc.listProjects();
+ expect(project?.saves.find((s) => s.id === last.id)?.summary?.headline).toBe(
+ "Lead: new part in bars 2–3",
+ );
});
test("deleting a tracked project only removes it from Echoform state", async () => {
diff --git a/packages/server/src/core.ts b/packages/server/src/core.ts
index 4c408ae..6c2e2f9 100644
--- a/packages/server/src/core.ts
+++ b/packages/server/src/core.ts
@@ -11,9 +11,6 @@ import {
} from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
-import { diffSets, isEmptyDiff } from "./als-diff";
-import type { SetSnapshot } from "./als-parser";
-import { extractTrackSummary, parseAlsFile } from "./als-parser";
import { mapWithConcurrency } from "./async-utils";
import type { ManifestEntry } from "./blob-store";
import {
@@ -33,8 +30,9 @@ import {
} from "./ableton-files";
import { discoverProjectsInRoot, discoverRootSuggestions } from "./discovery";
import { STATE_DIRNAME } from "./paths";
-import { formatDiffAsLabel } from "./smart-naming";
import { captureStoredSnapshot, recoverStoredSnapshot } from "./history-store";
+import { type LiveSet, readLiveSet } from "./live-set";
+import { analyzeSets } from "./set-compare";
import { StateRepository } from "./state-repository";
import type {
ActivityItem,
@@ -52,10 +50,11 @@ import type {
RootSuggestion,
RecoveryResult,
Save,
- SetDiff,
+ SaveAnalysis,
+ SaveSummary,
TrackedRoot,
- TrackSummaryItem,
} from "./types";
+import { ANALYSIS_VERSION } from "./types";
const AUDIO_EXTENSIONS = new Set([
".aif",
@@ -80,6 +79,7 @@ interface ProjectSnapshot {
const MAX_ACTIVITY_ITEMS = 80;
const WALK_CONCURRENCY = 32;
const AUTO_COMPACT_MAX_AUTO_SAVES = 100;
+const LIVE_SET_CACHE_SIZE = 4;
const AUTO_COMPACT_MAX_BLOB_STORAGE_BYTES = 2 * 1024 * 1024 * 1024;
const PREVIEW_FILE_BASENAME = "preview";
const PREVIEW_EXTENSIONS = [".wav", ".aif", ".aiff", ".mp3", ".m4a"] as const;
@@ -339,6 +339,10 @@ function autoLabel(): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
+function isSameSummary(a: SaveSummary | undefined, b: SaveSummary): boolean {
+ return a !== undefined && JSON.stringify(a) === JSON.stringify(b);
+}
+
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}
@@ -484,41 +488,6 @@ function computeAutoSavesToCompact(project: Project, now = new Date()): Save[] {
return autoSaves.filter((save) => !kept.has(save.id));
}
-/** Attempt to compute the semantic .als diff between two .als blob paths.
- * Returns the diff and the current snapshot. Never throws. */
-async function tryComputeSetDiff(
- prevAlsPath: string,
- currAlsPath: string,
-): Promise<{
- diff: SetDiff | undefined;
- currSnapshot: SetSnapshot | undefined;
-}> {
- try {
- const [prevSnapshot, currSnapshot] = await Promise.all([
- parseAlsFile(prevAlsPath),
- parseAlsFile(currAlsPath),
- ]);
- const diff = diffSets(prevSnapshot, currSnapshot);
- return {
- diff: isEmptyDiff(diff) ? undefined : diff,
- currSnapshot,
- };
- } catch {
- return { diff: undefined, currSnapshot: undefined };
- }
-}
-
-/** Parse a single .als file for track summary. Never throws. */
-async function tryParseSnapshot(
- alsPath: string,
-): Promise {
- try {
- return await parseAlsFile(alsPath);
- } catch {
- return undefined;
- }
-}
-
/** Find the blob hash for the active .als file in a manifest's entries. */
function findAlsHashInEntries(
entries: ManifestEntry[],
@@ -530,22 +499,6 @@ function findAlsHashInEntries(
);
}
-/** Find the blob hash for a previous save's active .als file by reading its manifest. */
-async function findPrevAlsHash(
- historyDir: string,
- prevSave: Save,
-): Promise {
- try {
- const manifest = await readManifest(historyDir, prevSave.id);
- return findAlsHashInEntries(
- manifest.files,
- prevSave.metadata.activeSetPath,
- );
- } catch {
- return null;
- }
-}
-
async function findAlsHashForSave(
historyDir: string,
saveId: string,
@@ -580,49 +533,6 @@ async function computeManifestChangeSummary(
}
}
-async function computeSemanticSaveData(
- historyDir: string,
- prevSave: Save | null,
- save: Pick,
-): Promise<{
- setDiff: SetDiff | undefined;
- trackSummary: TrackSummaryItem[] | undefined;
-}> {
- const currAlsHash = await findAlsHashForSave(
- historyDir,
- save.id,
- save.metadata.activeSetPath,
- );
-
- if (prevSave) {
- const prevAlsHash = await findPrevAlsHash(historyDir, prevSave);
- if (prevAlsHash && currAlsHash) {
- const result = await tryComputeSetDiff(
- getBlobPath(historyDir, prevAlsHash),
- getBlobPath(historyDir, currAlsHash),
- );
- return {
- setDiff: result.diff,
- trackSummary: result.currSnapshot
- ? extractTrackSummary(result.currSnapshot)
- : undefined,
- };
- }
- }
-
- if (currAlsHash) {
- const parsed = await tryParseSnapshot(getBlobPath(historyDir, currAlsHash));
- if (parsed) {
- return {
- setDiff: undefined,
- trackSummary: extractTrackSummary(parsed),
- };
- }
- }
-
- return { setDiff: undefined, trackSummary: undefined };
-}
-
// ── Async Mutex ─────────────────────────────────────────────────────
const MUTEX_TIMEOUT_MS = 30_000;
@@ -670,6 +580,8 @@ export class EchoformService {
private readonly mutex = new AsyncMutex();
private readonly launcher: AbletonLauncher;
private readonly recoveryRoot: string;
+ private readonly liveSetCache = new Map>();
+ private readonly unreadableSaves = new Set();
private readonly ideaPathIndex = new Map>();
private readonly previewPathIndex = new Map<
string,
@@ -856,9 +768,10 @@ export class EchoformService {
await this.storageKeepSaveIds(project),
);
await Promise.all(
- deletedSaves.map((save) =>
+ deletedSaves.flatMap((save) => [
this.clearManagedPreviewFiles(save.previewRefs),
- ),
+ rm(this.analysisCachePath(project.id, save.id), { force: true }),
+ ]),
);
} catch (error) {
pushActivity(
@@ -1718,26 +1631,9 @@ export class EchoformService {
metadata,
)
: undefined;
- let setDiff: SetDiff | undefined;
- let trackSummary: TrackSummaryItem[] | undefined;
- if (!input?.auto) {
- const semanticData = await computeSemanticSaveData(
- this.projectHistoryDir(project.id),
- saveBaseline,
- {
- id: saveId,
- metadata,
- },
- );
- setDiff = semanticData.setDiff;
- trackSummary = semanticData.trackSummary;
- }
-
const save: Save = {
id: saveId,
- label:
- input?.label?.trim() ||
- (input?.auto ? formatDiffAsLabel(setDiff, changes) : autoLabel()),
+ label: input?.label?.trim() || autoLabel(),
customLabel: Boolean(input?.label?.trim()),
note: input?.note?.trim() || "",
pinned: input?.pinned ?? false,
@@ -1752,8 +1648,6 @@ export class EchoformService {
metadata,
auto: input?.auto ?? false,
changes,
- setDiff,
- trackSummary,
};
project.saves.push(save);
idea.headSaveId = save.id;
@@ -2363,90 +2257,155 @@ export class EchoformService {
}
/** Compute changes for a save that doesn't have them yet (backfill). */
- async computeChanges(
+ // ── Analysis ────────────────────────────────────────────────────
+
+ /** The checkpoint a save is compared with: the one before it in the same set. */
+ private previousSaveInIdea(project: Project, save: Save): Save | null {
+ return (
+ project.saves
+ .filter((s) => s.ideaId === save.ideaId && s.createdAt < save.createdAt)
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0] ?? null
+ );
+ }
+
+ private analysisCachePath(projectId: string, saveId: string): string {
+ return join(this.projectHistoryDir(projectId), "analysis", `${saveId}.json`);
+ }
+
+ /** Parsing a large set takes about a second; neighbouring saves share sets. */
+ private readLiveSetCached(blobPath: string): Promise {
+ const cached = this.liveSetCache.get(blobPath);
+ if (cached) {
+ this.liveSetCache.delete(blobPath);
+ this.liveSetCache.set(blobPath, cached);
+ return cached;
+ }
+ const pending = readLiveSet(blobPath);
+ pending.catch(() => this.liveSetCache.delete(blobPath));
+ this.liveSetCache.set(blobPath, pending);
+ while (this.liveSetCache.size > LIVE_SET_CACHE_SIZE) {
+ const oldest = this.liveSetCache.keys().next().value;
+ if (oldest === undefined) {
+ break;
+ }
+ this.liveSetCache.delete(oldest);
+ }
+ return pending;
+ }
+
+ private async liveSetForSave(
+ projectId: string,
+ save: Save,
+ ): Promise {
+ const historyDir = this.projectHistoryDir(projectId);
+ const hash = await findAlsHashForSave(
+ historyDir,
+ save.id,
+ save.metadata.activeSetPath,
+ );
+ return hash ? this.readLiveSetCached(getBlobPath(historyDir, hash)) : null;
+ }
+
+ /**
+ * Compare a checkpoint's Ableton set with the previous checkpoint of the same
+ * set. Reads only from Echoform's history, so it works after the working
+ * folder is gone. Results are cached on disk and the compact summary is
+ * stored on the save.
+ */
+ async getSaveAnalysis(
projectId: string,
saveId: string,
- ): Promise<{ project: Project; changes: ChangeSummary | null }> {
- const analysisTarget = await this.withLock(async () => {
+ ): Promise<{ analysis: SaveAnalysis; project: Project | null }> {
+ const { save, previous } = await this.withLock(async () => {
const state = await this.loadState();
const project = requireProject(state, projectId);
- const save = requireSave(project, saveId);
+ const target = requireSave(project, saveId);
+ return { previous: this.previousSaveInIdea(project, target), save: target };
+ });
+ const baseSaveId = previous?.id ?? null;
+ const cachePath = this.analysisCachePath(projectId, saveId);
- if (save.changes && save.setDiff !== undefined && save.trackSummary) {
- return {
- project,
- save,
- prevSave: null,
- missingChanges: false,
- missingSemantic: false,
- };
+ let analysis: SaveAnalysis | null = null;
+ try {
+ const cached = JSON.parse(await readFile(cachePath, "utf8")) as SaveAnalysis;
+ if (
+ cached.baseSaveId === baseSaveId &&
+ cached.summary.version === ANALYSIS_VERSION
+ ) {
+ analysis = cached;
}
+ } catch {
+ // No usable cache; analyze below.
+ }
- const ideaSaves = project.saves.filter((s) => s.ideaId === save.ideaId);
- const idx = ideaSaves.findIndex((s) => s.id === saveId);
- const prevSave = idx > 0 ? ideaSaves[idx - 1] : null;
-
- return {
- project,
- save,
- prevSave,
- missingChanges: !save.changes && Boolean(prevSave),
- missingSemantic: save.setDiff === undefined || !save.trackSummary,
- };
- });
-
- if (!(analysisTarget.missingChanges || analysisTarget.missingSemantic)) {
- return {
- project: analysisTarget.project,
- changes: analysisTarget.save.changes ?? null,
- };
+ if (!analysis) {
+ const after = await this.liveSetForSave(projectId, save);
+ if (!after) {
+ throw new AppError("This checkpoint has no Ableton set to analyze.", 404);
+ }
+ const before = previous ? await this.liveSetForSave(projectId, previous) : null;
+ analysis = analyzeSets(before, after, baseSaveId);
+ await mkdir(dirname(cachePath), { recursive: true });
+ await writeFile(cachePath, JSON.stringify(analysis));
}
- const computedChanges =
- analysisTarget.missingChanges && analysisTarget.prevSave
- ? await computeManifestChangeSummary(
- this.projectHistoryDir(analysisTarget.project.id),
- analysisTarget.prevSave,
- analysisTarget.save.id,
- analysisTarget.save.metadata,
- )
- : analysisTarget.save.changes;
-
- const semanticData = analysisTarget.missingSemantic
- ? await computeSemanticSaveData(
- this.projectHistoryDir(analysisTarget.project.id),
- analysisTarget.prevSave,
- analysisTarget.save,
- )
- : {
- setDiff: analysisTarget.save.setDiff,
- trackSummary: analysisTarget.save.trackSummary,
- };
+ const project = await this.storeSummary(projectId, saveId, analysis.summary);
+ return { analysis, project };
+ }
+ /** Persist a summary if it differs; returns the project when state changed. */
+ private async storeSummary(
+ projectId: string,
+ saveId: string,
+ summary: SaveSummary,
+ ): Promise {
return this.withLock(async () => {
const state = await this.loadState();
- const project = requireProject(state, projectId);
- const save = requireSave(project, saveId);
- let dirty = false;
-
- if (!save.changes && computedChanges) {
- save.changes = computedChanges;
- dirty = true;
- }
- if (save.setDiff === undefined && semanticData.setDiff !== undefined) {
- save.setDiff = semanticData.setDiff;
- dirty = true;
- }
- if (!save.trackSummary && semanticData.trackSummary) {
- save.trackSummary = semanticData.trackSummary;
- dirty = true;
+ const project = state.projects.find((p) => p.id === projectId);
+ const save = project?.saves.find((s) => s.id === saveId);
+ if (!(project && save) || isSameSummary(save.summary, summary)) {
+ return null;
}
+ save.summary = summary;
+ await this.saveState(state);
+ return project;
+ });
+ }
- if (dirty) {
- await this.saveState(state);
- }
- return { project, changes: save.changes ?? null };
+ /**
+ * Summarize the newest checkpoint whose summary is missing or stale.
+ * Returns the updated project, or null when everything is current.
+ */
+ async summarizeNextSave(): Promise {
+ const next = await this.withLock(async () => {
+ const state = await this.loadState();
+ const candidates = state.projects.flatMap((project) =>
+ project.saves
+ .filter((save) => !this.unreadableSaves.has(save.id))
+ .filter((save) => {
+ const summary = save.summary;
+ return (
+ !summary ||
+ summary.version !== ANALYSIS_VERSION ||
+ summary.baseSaveId !==
+ (this.previousSaveInIdea(project, save)?.id ?? null)
+ );
+ })
+ .map((save) => ({ createdAt: save.createdAt, projectId: project.id, saveId: save.id })),
+ );
+ return candidates.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
});
+ if (!next) {
+ return undefined;
+ }
+ try {
+ const { project } = await this.getSaveAnalysis(next.projectId, next.saveId);
+ return project;
+ } catch {
+ // Unreadable or unparseable set; don't retry it this session.
+ this.unreadableSaves.add(next.saveId);
+ return null;
+ }
}
/** Get disk usage statistics for a project. */
diff --git a/packages/server/src/live-set.ts b/packages/server/src/live-set.ts
new file mode 100644
index 0000000..7a1729e
--- /dev/null
+++ b/packages/server/src/live-set.ts
@@ -0,0 +1,647 @@
+/**
+ * live-set.ts – Read an Ableton Live Set (.als) into a musician-level model.
+ *
+ * .als files are gzipped XML. We keep what a producer would recognise when
+ * comparing two versions of a song: where clips sit in the arrangement, what
+ * notes they hold, which samples they play, device chains and their settings,
+ * mixer levels, automation, tempo, and locators.
+ */
+
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { gunzipSync } from "node:zlib";
+import { XMLParser } from "fast-xml-parser";
+
+// ── Model ───────────────────────────────────────────────────────────
+
+export type TrackType = "audio" | "midi" | "return" | "group";
+
+export interface LiveSet {
+ liveVersion: string | null;
+ locators: Locator[];
+ tempo: number;
+ timeSignature: string;
+ tracks: Track[];
+}
+
+export interface Locator {
+ name: string;
+ time: number; // beats
+}
+
+export interface Track {
+ automation: Automation[];
+ clips: Clip[];
+ color: number;
+ devices: Device[];
+ groupId: string | null;
+ id: string;
+ mixer: Mixer;
+ name: string;
+ sessionClipCount: number;
+ type: TrackType;
+}
+
+export interface Mixer {
+ muted: boolean;
+ pan: number; // -1..1
+ sends: number[]; // dB, one per return track
+ soloed: boolean;
+ volumeDb: number;
+}
+
+export interface Clip {
+ /** Hash of the clip's musical content, independent of where it sits. */
+ contentHash: string;
+ color: number;
+ disabled: boolean;
+ end: number; // beats, arrangement position
+ kind: "midi" | "audio";
+ name: string;
+ notes: Note[]; // MIDI only, times relative to clip start
+ sample: string | null; // audio only, file name
+ start: number; // beats, arrangement position
+}
+
+export interface Note {
+ duration: number;
+ key: number;
+ time: number;
+ velocity: number;
+}
+
+export interface Device {
+ className: string;
+ enabled: boolean;
+ id: string;
+ name: string;
+ params: Record;
+ /** Opaque plugin state (preset blobs). Some plugins rewrite it on every save. */
+ pluginStateHash: string | null;
+ /** Hash of the device's readable settings. */
+ stateHash: string;
+}
+
+export interface Automation {
+ hash: string;
+ pointCount: number;
+ target: string; // "Volume", "Pad › Echo › Feedback", …
+}
+
+// ── XML access ──────────────────────────────────────────────────────
+
+/** Every element becomes an array so lookups never depend on child count. */
+const xml = new XMLParser({
+ attributeNamePrefix: "@_",
+ ignoreAttributes: false,
+ isArray: (_name, _jpath, _isLeaf, isAttribute) => !isAttribute,
+ parseAttributeValue: false,
+ parseTagValue: false,
+});
+
+type Node = Record;
+
+function children(node: Node | undefined, tag: string): Node[] {
+ const value = node?.[tag];
+ return Array.isArray(value) ? (value as Node[]) : [];
+}
+
+function child(node: Node | undefined, ...path: string[]): Node | undefined {
+ let current = node;
+ for (const tag of path) {
+ current = children(current, tag)[0];
+ if (!current) {
+ return;
+ }
+ }
+ return current;
+}
+
+function attr(node: Node | undefined, name: string): string | undefined {
+ const value = node?.[`@_${name}`];
+ return typeof value === "string" ? value : undefined;
+}
+
+function value(node: Node | undefined, ...path: string[]): string | undefined {
+ return attr(child(node, ...path), "Value");
+}
+
+function num(raw: string | undefined, fallback = 0): number {
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function elementTags(node: Node): string[] {
+ return Object.keys(node).filter((key) => !key.startsWith("@_"));
+}
+
+/**
+ * Keys that change with UI state or Live's internal bookkeeping, not with the
+ * music. Live renumbers object ids on every save, so ids never count.
+ */
+const VOLATILE_TAGS = new Set([
+ "@_Id",
+ "AnchorTime",
+ "BreakoutIsExpanded",
+ "Grid",
+ "IsExpanded",
+ "IsFolded",
+ "LastModDate",
+ "LastPresetRef",
+ "LastSelectedClipEnvelopeIndex",
+ "LastSelectedTimeableIndex",
+ "LomId",
+ "LomIdView",
+ "ModulationSourceCount",
+ "Pointee",
+ "ScrollerTimePreserver",
+ "SelectedEnvelope",
+ "ShouldShowPresetName",
+ "SourceContext",
+ "TimeSelection",
+ "ViewData",
+ "ViewStateExtended",
+]);
+
+function stableHash(node: Node, skip: ReadonlySet = new Set()): string {
+ const hash = createHash("sha1");
+ const visit = (current: Node) => {
+ for (const key of Object.keys(current).sort()) {
+ if (VOLATILE_TAGS.has(key) || skip.has(key)) {
+ continue;
+ }
+ const entry = current[key];
+ if (Array.isArray(entry)) {
+ hash.update(`<${key}>`);
+ for (const item of entry) {
+ if (item && typeof item === "object") {
+ visit(item as Node);
+ } else {
+ hash.update(String(item));
+ }
+ }
+ hash.update(`${key}>`);
+ } else {
+ hash.update(`${key}=${String(entry)};`);
+ }
+ }
+ };
+ visit(node);
+ return hash.digest("hex").slice(0, 16);
+}
+
+// ── Devices ─────────────────────────────────────────────────────────
+
+const NATIVE_DEVICE_NAMES: Record = {
+ Amp: "Amp",
+ AudioEffectGroupDevice: "Audio Effect Rack",
+ AutoFilter: "Auto Filter",
+ AutoFilter2: "Auto Filter",
+ AutoPan: "Auto Pan",
+ BeatRepeat: "Beat Repeat",
+ Cabinet: "Cabinet",
+ Chorus2: "Chorus-Ensemble",
+ Collision: "Collision",
+ Compressor2: "Compressor",
+ Corpus: "Corpus",
+ CrossDelay: "Hybrid Reverb",
+ Delay: "Delay",
+ Drift: "Drift",
+ DrumGroupDevice: "Drum Rack",
+ Echo: "Echo",
+ Eq8: "EQ Eight",
+ Erosion: "Erosion",
+ FilterDelay: "Filter Delay",
+ FrequencyShifter: "Frequency Shifter",
+ Gate: "Gate",
+ GlueCompressor: "Glue Compressor",
+ GrainDelay: "Grain Delay",
+ InstrumentGroupDevice: "Instrument Rack",
+ InstrumentVector: "Wavetable",
+ Limiter: "Limiter",
+ Looper: "Looper",
+ LoungeLizard: "Electric",
+ MidiArpeggiator: "Arpeggiator",
+ MidiChord: "Chord",
+ MidiEffectGroupDevice: "MIDI Effect Rack",
+ MidiNoteLength: "Note Length",
+ MidiPitcher: "Pitch",
+ MidiRandom: "Random",
+ MidiScale: "Scale",
+ MidiVelocity: "Velocity",
+ MultiSampler: "Sampler",
+ MultibandDynamics: "Multiband Dynamics",
+ MxDeviceAudioEffect: "Max Audio Effect",
+ MxDeviceInstrument: "Max Instrument",
+ MxDeviceMidiEffect: "Max MIDI Effect",
+ Operator: "Operator",
+ OriginalSimpler: "Simpler",
+ Overdrive: "Overdrive",
+ Pedal: "Pedal",
+ Phaser: "Phaser-Flanger",
+ PhaserNew: "Phaser-Flanger",
+ PingPongDelay: "Ping Pong Delay",
+ Redux2: "Redux",
+ Resonators: "Resonators",
+ Reverb: "Reverb",
+ Roar: "Roar",
+ Saturator: "Saturator",
+ SimpleDelay: "Simple Delay",
+ SpectrumAnalyzer: "Spectrum",
+ StereoGain: "Utility",
+ StringStudio: "Tension",
+ Tuner: "Tuner",
+ UltraAnalog: "Analog",
+ Vinyl: "Vinyl Distortion",
+ Vocoder: "Vocoder",
+};
+
+const PLUGIN_TAGS = new Set([
+ "AuPluginDevice",
+ "PluginDevice",
+ "VstPluginDevice",
+ "Vst3PluginDevice",
+]);
+
+const PLUGIN_BLOB_TAGS = new Set(["Buffer", "ControllerState", "ProcessorState"]);
+
+function blobHash(device: Node): string {
+ const hash = createHash("sha1");
+ const visit = (node: Node) => {
+ for (const tag of elementTags(node)) {
+ for (const item of children(node, tag)) {
+ if (PLUGIN_BLOB_TAGS.has(tag)) {
+ hash.update(String(item["#text"] ?? ""));
+ } else if (item && typeof item === "object") {
+ visit(item);
+ }
+ }
+ }
+ };
+ visit(device);
+ return hash.digest("hex").slice(0, 16);
+}
+
+/** "FilterFreq" → "Filter Freq", "Eq8 Gain" stays readable. */
+function humanize(tag: string): string {
+ return tag
+ .replace(/([a-z])([A-Z0-9])/g, "$1 $2")
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
+ .replace(/_/g, " ")
+ .trim();
+}
+
+function pluginName(device: Node, tag: string): string {
+ const desc = child(device, "PluginDesc");
+ return (
+ value(desc, "Vst3PluginInfo", "Name") ??
+ value(desc, "VstPluginInfo", "PlugName") ??
+ value(desc, "AuPluginInfo", "Name") ??
+ tag
+ );
+}
+
+/** Numeric parameters a producer can see on the device face. */
+function deviceParams(device: Node, isPlugin: boolean): Record {
+ const params: Record = {};
+ if (isPlugin) {
+ for (const holder of children(child(device, "ParameterList"), "PluginFloatParameter")) {
+ const name = value(holder, "ParameterName");
+ const manual = value(holder, "ParameterValue", "Manual");
+ if (name && manual !== undefined) {
+ params[name] = num(manual);
+ }
+ }
+ return params;
+ }
+ for (const tag of elementTags(device)) {
+ if (VOLATILE_TAGS.has(tag) || tag === "On") {
+ continue;
+ }
+ for (const holder of children(device, tag)) {
+ const manual = value(holder, "Manual");
+ if (manual === undefined || !child(holder, "AutomationTarget")) {
+ continue;
+ }
+ params[humanize(tag)] =
+ manual === "true" ? 1 : manual === "false" ? 0 : num(manual);
+ }
+ }
+ return params;
+}
+
+function readDevices(
+ devicesNode: Node | undefined,
+ targets: Map,
+): Device[] {
+ if (!devicesNode) {
+ return [];
+ }
+ const devices: Device[] = [];
+ for (const tag of elementTags(devicesNode)) {
+ for (const device of children(devicesNode, tag)) {
+ const isPlugin = PLUGIN_TAGS.has(tag);
+ const userName = value(device, "UserName");
+ const name = isPlugin
+ ? pluginName(device, tag)
+ : userName || NATIVE_DEVICE_NAMES[tag] || humanize(tag);
+ collectTargets(device, targets, name);
+ devices.push({
+ className: tag,
+ enabled: value(device, "On", "Manual") !== "false",
+ id: attr(device, "Id") ?? `${tag}-${devices.length}`,
+ name,
+ params: deviceParams(device, isPlugin),
+ pluginStateHash: isPlugin ? blobHash(device) : null,
+ stateHash: stableHash(device, PLUGIN_BLOB_TAGS),
+ });
+ }
+ }
+ return devices;
+}
+
+/** Map AutomationTarget ids to readable parameter paths for automation lanes. */
+function collectTargets(node: Node, targets: Map, prefix: string) {
+ for (const tag of elementTags(node)) {
+ for (const item of children(node, tag)) {
+ const target = child(item, "AutomationTarget");
+ const id = attr(target, "Id");
+ if (id) {
+ const label = value(item, "ParameterName") ?? humanize(tag);
+ targets.set(id, prefix ? `${prefix} › ${label}` : label);
+ }
+ if (tag !== "AutomationTarget") {
+ collectTargets(item, targets, prefix);
+ }
+ }
+ }
+}
+
+// ── Mixer ───────────────────────────────────────────────────────────
+
+/** Live stores fader gain as linear amplitude: 1 = 0 dB. */
+export function gainToDb(gain: number): number {
+ if (gain <= 0.000_316_3) {
+ return Number.NEGATIVE_INFINITY;
+ }
+ return Math.round(20 * Math.log10(gain) * 10) / 10;
+}
+
+function readMixer(mixer: Node | undefined): Mixer {
+ return {
+ muted: value(mixer, "Speaker", "Manual") === "false",
+ pan: num(value(mixer, "Pan", "Manual")),
+ sends: children(child(mixer, "Sends"), "TrackSendHolder").map((holder) =>
+ gainToDb(num(value(holder, "Send", "Manual"))),
+ ),
+ soloed: value(mixer, "SoloSink") === "true",
+ volumeDb: gainToDb(num(value(mixer, "Volume", "Manual"), 1)),
+ };
+}
+
+// ── Clips ───────────────────────────────────────────────────────────
+
+function readNotes(clip: Node): Note[] {
+ const notes: Note[] = [];
+ for (const keyTrack of children(child(clip, "Notes", "KeyTracks"), "KeyTrack")) {
+ const key = num(value(keyTrack, "MidiKey"));
+ for (const event of children(child(keyTrack, "Notes"), "MidiNoteEvent")) {
+ if (attr(event, "IsEnabled") === "false") {
+ continue;
+ }
+ notes.push({
+ duration: num(attr(event, "Duration")),
+ key,
+ time: num(attr(event, "Time")),
+ velocity: num(attr(event, "Velocity"), 100),
+ });
+ }
+ }
+ return notes.sort((a, b) => a.time - b.time || a.key - b.key);
+}
+
+function fileName(path: string): string {
+ return path.split(/[\\/]/).pop() ?? path;
+}
+
+const round = (raw: string | undefined) => num(raw).toFixed(3);
+
+/**
+ * What the clip plays, independent of where it sits. Built from musical fields
+ * only: Live re-derives warp-marker timings and clip ends whenever the tempo
+ * changes, and that bookkeeping must not read as an edit.
+ */
+function clipContentHash(clip: Node, notes: Note[], sample: string | null): string {
+ const loop = child(clip, "Loop");
+ const fades = child(clip, "Fades");
+ const envelopes = child(clip, "Envelopes");
+ const parts = [
+ value(clip, "Name"),
+ value(clip, "Disabled"),
+ round(value(loop, "LoopStart")),
+ round(value(loop, "LoopEnd")),
+ round(value(loop, "StartRelative")),
+ value(loop, "LoopOn"),
+ envelopes ? stableHash(envelopes) : "",
+ sample,
+ value(clip, "IsWarped"),
+ value(clip, "WarpMode"),
+ value(clip, "PitchCoarse"),
+ round(value(clip, "PitchFine")),
+ round(value(clip, "SampleVolume")),
+ round(value(fades, "FadeInLength")),
+ round(value(fades, "FadeOutLength")),
+ notes
+ .map((n) => `${n.key}:${n.time.toFixed(3)}:${n.duration.toFixed(3)}:${n.velocity}`)
+ .join(","),
+ ];
+ return createHash("sha1").update(parts.join("|")).digest("hex").slice(0, 16);
+}
+
+function readClip(clip: Node, kind: Clip["kind"]): Clip {
+ const fileRef = child(clip, "SampleRef", "FileRef");
+ const samplePath = value(fileRef, "RelativePath") || value(fileRef, "Path");
+ const sample = samplePath ? fileName(samplePath) : null;
+ const notes = kind === "midi" ? readNotes(clip) : [];
+ const start = num(value(clip, "CurrentStart"), num(attr(clip, "Time")));
+ return {
+ contentHash: clipContentHash(clip, notes, sample),
+ color: num(value(clip, "Color"), -1),
+ disabled: value(clip, "Disabled") === "true",
+ end: num(value(clip, "CurrentEnd"), start),
+ kind,
+ name: value(clip, "Name") ?? "",
+ notes,
+ sample,
+ start,
+ };
+}
+
+function readArrangementClips(sequencer: Node | undefined): Clip[] {
+ const clips: Clip[] = [];
+ // MIDI tracks keep arrangement clips under ClipTimeable, audio tracks under Sample.
+ for (const holder of ["ClipTimeable", "Sample"]) {
+ const events = child(sequencer, holder, "ArrangerAutomation", "Events");
+ for (const clip of children(events, "MidiClip")) {
+ clips.push(readClip(clip, "midi"));
+ }
+ for (const clip of children(events, "AudioClip")) {
+ clips.push(readClip(clip, "audio"));
+ }
+ }
+ return clips.sort((a, b) => a.start - b.start);
+}
+
+function countSessionClips(sequencer: Node | undefined): number {
+ let count = 0;
+ for (const slot of children(child(sequencer, "ClipSlotList"), "ClipSlot")) {
+ const holder = child(slot, "ClipSlot", "Value");
+ count += children(holder, "MidiClip").length + children(holder, "AudioClip").length;
+ }
+ return count;
+}
+
+// ── Automation ──────────────────────────────────────────────────────
+
+function readAutomation(track: Node, targets: Map): Automation[] {
+ const lanes: Automation[] = [];
+ for (const envelope of children(
+ child(track, "AutomationEnvelopes", "Envelopes"),
+ "AutomationEnvelope",
+ )) {
+ const targetId = value(envelope, "EnvelopeTarget", "PointeeId");
+ const events = child(envelope, "Automation", "Events");
+ if (!(targetId && events)) {
+ continue;
+ }
+ const pointCount = elementTags(events).reduce(
+ (sum, tag) => sum + children(events, tag).length,
+ 0,
+ );
+ lanes.push({
+ hash: stableHash(events),
+ pointCount,
+ target: targets.get(targetId) ?? "Parameter",
+ });
+ }
+ return lanes;
+}
+
+// ── Tracks ──────────────────────────────────────────────────────────
+
+const TRACK_TYPES: Record = {
+ AudioTrack: "audio",
+ GroupTrack: "group",
+ MidiTrack: "midi",
+ ReturnTrack: "return",
+};
+
+function readTrack(node: Node, type: TrackType): Track {
+ const name = value(node, "Name", "EffectiveName") ?? "Untitled";
+ const groupId = num(value(node, "TrackGroupId"), -1);
+ const deviceChain = child(node, "DeviceChain");
+ const mixer = child(deviceChain, "Mixer");
+ // Automation lanes point at parameters by id; name them "Volume" or "Echo › Feedback".
+ const targets = new Map();
+ collectTargets(mixer ?? {}, targets, "");
+ const devices = readDevices(
+ child(deviceChain, "DeviceChain", "Devices") ?? child(deviceChain, "Devices"),
+ targets,
+ );
+ const sequencer = child(deviceChain, "MainSequencer");
+
+ return {
+ automation: readAutomation(node, targets),
+ clips: readArrangementClips(sequencer),
+ color: num(value(node, "Color"), -1),
+ devices,
+ groupId: groupId >= 0 ? String(groupId) : null,
+ id: attr(node, "Id") ?? name,
+ mixer: readMixer(mixer),
+ name,
+ sessionClipCount: countSessionClips(sequencer),
+ type,
+ };
+}
+
+/** Track order matters (it's the arrangement's row order); recover it from the raw XML. */
+function trackTagOrder(raw: string): string[] {
+ const start = raw.indexOf("");
+ const end = raw.indexOf(" ", start);
+ if (start === -1 || end === -1) {
+ return [];
+ }
+ const order: string[] = [];
+ let depth = 0;
+ const tagPattern = /<(\/?)(\w+)[^>]*?(\/?)>/g;
+ tagPattern.lastIndex = start + "".length;
+ for (let match = tagPattern.exec(raw); match && match.index < end; match = tagPattern.exec(raw)) {
+ const [, closing, tag, selfClosing] = match;
+ if (closing) {
+ depth -= 1;
+ } else if (!selfClosing) {
+ if (depth === 0 && tag && TRACK_TYPES[tag]) {
+ order.push(tag);
+ }
+ depth += 1;
+ }
+ }
+ return order;
+}
+
+// ── Entry point ─────────────────────────────────────────────────────
+
+export function parseLiveSetXml(raw: string): LiveSet {
+ const doc = xml.parse(raw) as Node;
+ const ableton = child(doc, "Ableton");
+ const liveSet = child(ableton, "LiveSet");
+ if (!liveSet) {
+ throw new Error("Not an Ableton Live Set: no LiveSet element.");
+ }
+
+ const tracksNode = child(liveSet, "Tracks");
+ const queues = new Map(
+ Object.keys(TRACK_TYPES).map((tag) => [tag, [...children(tracksNode, tag)]]),
+ );
+ const tracks: Track[] = [];
+ for (const tag of trackTagOrder(raw)) {
+ const node = queues.get(tag)?.shift();
+ if (node) {
+ tracks.push(readTrack(node, TRACK_TYPES[tag]!));
+ }
+ }
+ for (const [tag, rest] of queues) {
+ for (const node of rest) {
+ tracks.push(readTrack(node, TRACK_TYPES[tag]!));
+ }
+ }
+
+ const main = child(liveSet, "MainTrack") ?? child(liveSet, "MasterTrack");
+ const mainMixer = child(main, "DeviceChain", "Mixer");
+ const encodedSignature = num(value(mainMixer, "TimeSignature", "Manual"), 201);
+
+ return {
+ liveVersion: attr(ableton, "Creator")?.replace(/^Ableton Live\s*/, "") ?? null,
+ locators: children(child(liveSet, "Locators", "Locators"), "Locator")
+ .map((locator) => ({
+ name: value(locator, "Name") ?? "",
+ time: num(value(locator, "Time")),
+ }))
+ .sort((a, b) => a.time - b.time),
+ tempo: Math.round(num(value(mainMixer, "Tempo", "Manual"), 120) * 100) / 100,
+ timeSignature: `${(encodedSignature % 99) + 1}/${2 ** Math.floor(encodedSignature / 99)}`,
+ tracks,
+ };
+}
+
+export async function readLiveSet(filePath: string): Promise {
+ const compressed = await readFile(filePath);
+ let raw: string;
+ try {
+ raw = gunzipSync(compressed).toString("utf-8");
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : "unknown error";
+ throw new Error(`Not a readable .als file (${detail}).`);
+ }
+ return parseLiveSetXml(raw);
+}
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 0b9080c..cd71e30 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -56,19 +56,40 @@ async function buildSnapshotEvent(): Promise {
async function broadcastSnapshot(): Promise {
broadcast(await buildSnapshotEvent());
+ void summarizePendingSaves();
}
-async function backfillSaveAnalysis(
- projectId: string,
- saveId: string,
-): Promise {
+// Checkpoint summaries are computed in the background, one at a time,
+// newest first. Any change to history may make a neighbour's summary stale,
+// so callers just ask for another pass.
+let summarizing = false;
+let summarizeRequested = false;
+
+async function summarizePendingSaves(): Promise {
+ if (summarizing) {
+ summarizeRequested = true;
+ return;
+ }
+ summarizing = true;
try {
- const { project } = await service.computeChanges(projectId, saveId);
- broadcast({ type: "project-updated", project });
+ do {
+ summarizeRequested = false;
+ for (;;) {
+ const project = await service.summarizeNextSave();
+ if (project === undefined) {
+ break;
+ }
+ if (project) {
+ broadcast({ type: "project-updated", project });
+ }
+ }
+ } while (summarizeRequested);
} catch (err) {
const message =
- err instanceof Error ? err.message : "Background save analysis failed";
+ err instanceof Error ? err.message : "Checkpoint analysis failed";
broadcast({ type: "error", message });
+ } finally {
+ summarizing = false;
}
}
@@ -173,7 +194,7 @@ const watcher = new ProjectWatcher({
await service.handleWatchedAlsChange(projectId, changedPaths);
if (save) {
broadcast({ type: "auto-saved", projectId, save });
- void backfillSaveAnalysis(projectId, save.id);
+ void summarizePendingSaves();
}
if (save || stateChanged) {
broadcast({ type: "project-updated", project });
@@ -243,6 +264,7 @@ async function reconcileWatchers(): Promise {
await service.reconcileStorage();
await service.syncRoots();
await reconcileWatchers();
+void summarizePendingSaves();
setInterval(() => {
void service
@@ -525,19 +547,23 @@ Bun.serve({
}
}
- // REST: compute changes for a save (backfill)
- // e.g. /api/projects/:id/saves/:saveId/changes
+ // GET /api/projects/:id/saves/:saveId/analysis
if (
- url.pathname.match(/^\/api\/projects\/[^/]+\/saves\/[^/]+\/changes$/) &&
- req.method === "POST"
+ url.pathname.match(/^\/api\/projects\/[^/]+\/saves\/[^/]+\/analysis$/) &&
+ req.method === "GET"
) {
const parts = url.pathname.split("/");
const projectId = parts[3]!;
const saveId = parts[5]!;
try {
- const { changes } = await service.computeChanges(projectId, saveId);
- await broadcastSnapshot();
- return jsonResponse(req, { changes });
+ const { analysis, project } = await service.getSaveAnalysis(
+ projectId,
+ saveId,
+ );
+ if (project) {
+ broadcast({ type: "project-updated", project });
+ }
+ return jsonResponse(req, { analysis });
} catch (err) {
const status = err instanceof AppError ? err.status : 500;
const message = err instanceof Error ? err.message : "Unknown error";
@@ -794,6 +820,7 @@ Bun.serve({
if (result) {
broadcast(result);
}
+ void summarizePendingSaves();
ws.send(
JSON.stringify({
type: "command-ack",
diff --git a/packages/server/src/set-compare.test.ts b/packages/server/src/set-compare.test.ts
new file mode 100644
index 0000000..bfc7fcf
--- /dev/null
+++ b/packages/server/src/set-compare.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, test } from "bun:test";
+import { parseLiveSetXml } from "./live-set";
+import { analyzeSets, formatBars } from "./set-compare";
+import { alsXml, type FixtureSet, type FixtureTrack } from "./test-support/als";
+
+const drums: FixtureTrack = {
+ clips: [
+ { end: 16, sample: "kick.wav", start: 0 },
+ { end: 32, sample: "kick.wav", start: 16 },
+ ],
+ id: "10",
+ name: "1-Drums",
+ type: "audio",
+};
+
+const lead: FixtureTrack = {
+ clips: [
+ {
+ end: 16,
+ notes: [
+ { key: 60, time: 0 },
+ { key: 64, time: 1 },
+ ],
+ start: 0,
+ },
+ ],
+ devices: [{ id: "100", params: { Feedback: 0.5 }, tag: "Echo" }],
+ id: "20",
+ name: "2-Lead",
+ type: "midi",
+};
+
+function set(fields: Partial = {}): FixtureSet {
+ return { tempo: 120, tracks: [drums, lead], ...fields };
+}
+
+function analyze(before: FixtureSet | null, after: FixtureSet) {
+ return analyzeSets(
+ before ? parseLiveSetXml(alsXml(before)) : null,
+ parseLiveSetXml(alsXml(after)),
+ before ? "previous" : null,
+ );
+}
+
+function withTrack(fixture: FixtureSet, id: string, patch: Partial) {
+ return {
+ ...fixture,
+ tracks: fixture.tracks.map((t) => (t.id === id ? { ...t, ...patch } : t)),
+ };
+}
+
+describe("reading a Live Set", () => {
+ test("finds arrangement clips on audio tracks", () => {
+ const parsed = parseLiveSetXml(alsXml(set()));
+ expect(parsed.tracks[0]?.clips.map((c) => [c.start, c.end, c.sample])).toEqual([
+ [0, 16, "kick.wav"],
+ [16, 32, "kick.wav"],
+ ]);
+ });
+
+ test("converts fader gain to dB", () => {
+ const parsed = parseLiveSetXml(
+ alsXml(withTrack(set(), "10", { volume: 0.707_945_8 })),
+ );
+ expect(parsed.tracks[0]?.mixer.volumeDb).toBe(-3);
+ });
+});
+
+describe("comparing versions", () => {
+ test("describes the first checkpoint by its shape", () => {
+ const result = analyze(null, set());
+ expect(result.summary.first).toBe(true);
+ expect(result.summary.headline).toBe("First save · 2 tracks, 8 bars at 120 BPM");
+ expect(result.summary.shape.some((v) => v > 0)).toBe(true);
+ });
+
+ test("reports an unchanged save as having no musical changes", () => {
+ const result = analyze(set(), set());
+ expect(result.summary.weight).toBe("none");
+ expect(result.summary.headline).toBe("No musical changes");
+ expect(result.summary.regions).toEqual([]);
+ });
+
+ test("locates a new part in bars", () => {
+ const after = withTrack(set(), "20", {
+ clips: [
+ ...(lead.clips ?? []),
+ { end: 32, notes: [{ key: 67, time: 0 }], start: 16 },
+ ],
+ });
+ const result = analyze(set(), after);
+ expect(result.summary.weight).toBe("major");
+ expect(result.summary.headline).toBe("Lead: new part in bars 5–8");
+ expect(result.summary.regions).toEqual([{ color: 0, end: 32, start: 16 }]);
+ const added = result.tracks[1]?.clips.find((c) => c.status === "added");
+ expect(added?.notes?.added).toEqual([{ d: 1, k: 67, t: 0 }]);
+ });
+
+ test("shows which notes changed inside an edited clip", () => {
+ const after = withTrack(set(), "20", {
+ clips: [
+ {
+ end: 16,
+ notes: [
+ { key: 60, time: 0 },
+ { key: 65, time: 2 },
+ ],
+ start: 0,
+ },
+ ],
+ });
+ const clip = analyze(set(), after).tracks[1]?.clips[0];
+ expect(clip?.status).toBe("edited");
+ expect(clip?.notes).toEqual({
+ added: [{ d: 1, k: 65, t: 2 }],
+ kept: [{ d: 1, k: 60, t: 0 }],
+ removed: [{ d: 1, k: 64, t: 1 }],
+ });
+ });
+
+ test("names device and mixer changes", () => {
+ const after = withTrack(set(), "20", {
+ devices: [
+ { enabled: false, id: "100", params: { Feedback: 0.8 }, tag: "Echo" },
+ { id: "101", tag: "Eq8" },
+ ],
+ volume: 0.5,
+ });
+ const changes = analyze(set(), after).tracks[1]?.changes;
+ expect(changes).toContainEqual({ device: "Echo", type: "device-off" });
+ expect(changes).toContainEqual({
+ device: "Echo",
+ params: [{ from: 0.5, name: "Feedback", to: 0.8 }],
+ type: "device-settings",
+ });
+ expect(changes).toContainEqual({ device: "EQ Eight", type: "device-added" });
+ expect(changes).toContainEqual({ from: 0, to: -6, type: "volume" });
+ });
+
+ test("lists every change in searchable words", () => {
+ const after = withTrack(set({ tempo: 128 }), "20", { volume: 0.5 });
+ const summary = analyze(set(), after).summary;
+ expect(summary.headline).toBe("Tempo 120 → 128 BPM");
+ expect(summary.changes).toEqual(["Tempo 120 → 128 BPM", "Lead quieter (−6.0 dB)"]);
+ });
+
+ test("tells moved clips from new ones", () => {
+ const after = withTrack(set(), "20", {
+ clips: [{ ...lead.clips![0]!, end: 48, start: 32 }],
+ });
+ const clip = analyze(set(), after).tracks[1]?.clips[0];
+ expect(clip?.status).toBe("moved");
+ expect(clip?.movedFrom).toBe(0);
+ });
+
+ test("a tempo change alone does not mark audio clips as edited", () => {
+ const after = withTrack(set({ tempo: 140 }), "10", {
+ clips: [
+ { end: 14.5, loopEnd: 16, sample: "kick.wav", start: 0 },
+ { end: 30.5, loopEnd: 16, sample: "kick.wav", start: 16 },
+ ],
+ });
+ const result = analyze(set(), after);
+ expect(result.summary.headline).toBe("Tempo 120 → 140 BPM");
+ expect(result.tracks[0]?.clips.every((c) => c.status === "same")).toBe(true);
+ });
+
+ test("ignores Live renumbering default track names", () => {
+ const after = withTrack(set(), "10", { name: "3-Drums" });
+ expect(analyze(set(), after).summary.weight).toBe("none");
+ });
+
+ test("keeps removed tracks visible as removed", () => {
+ const result = analyze(set(), set({ tracks: [drums] }));
+ expect(result.summary.headline).toBe("Removed Lead");
+ expect(result.tracks.at(-1)?.status).toBe("removed");
+ });
+});
+
+test("formats bar ranges", () => {
+ expect(formatBars(0, 4, 4)).toBe("bar 1");
+ expect(formatBars(16, 32, 4)).toBe("bars 5–8");
+});
diff --git a/packages/server/src/set-compare.ts b/packages/server/src/set-compare.ts
new file mode 100644
index 0000000..95f8dc8
--- /dev/null
+++ b/packages/server/src/set-compare.ts
@@ -0,0 +1,671 @@
+/**
+ * set-compare.ts – Explain what changed between two versions of a Live Set
+ * in the terms a producer uses: which part of the song, which track, which
+ * notes, which device, how many dB.
+ */
+
+import type { Clip, LiveSet, Note, Track } from "./live-set";
+import {
+ ANALYSIS_VERSION,
+ type ChangeWeight,
+ type ClipAnalysis,
+ type MiniNote,
+ type SaveAnalysis,
+ type SaveSummary,
+ type SetChange,
+ type TrackAnalysis,
+ type TrackChange,
+} from "./types";
+
+const SHAPE_BUCKETS = 64;
+const MAX_NOTES_PER_CLIP = 512;
+const MAX_TOUCHED = 8;
+const DB_EPSILON = 0.25;
+const PARAM_EPSILON = 1e-6;
+/** Clip edges move by fractions of a beat when Live re-derives warped clips. */
+const POSITION_EPSILON = 0.01;
+
+// ── Bars ────────────────────────────────────────────────────────────
+
+export function beatsPerBar(timeSignature: string): number {
+ const [numerator, denominator] = timeSignature.split("/").map(Number);
+ if (!(numerator && denominator)) {
+ return 4;
+ }
+ return (numerator * 4) / denominator;
+}
+
+export function formatBars(start: number, end: number, perBar: number): string {
+ const first = Math.floor(start / perBar) + 1;
+ const last = Math.max(first, Math.ceil(end / perBar));
+ return first === last ? `bar ${first}` : `bars ${first}–${last}`;
+}
+
+function formatDb(db: number): string {
+ if (!Number.isFinite(db)) {
+ return "−∞ dB";
+ }
+ return `${db > 0 ? "+" : db < 0 ? "−" : ""}${Math.abs(db).toFixed(1)} dB`;
+}
+
+// ── Names ───────────────────────────────────────────────────────────
+
+/** Live numbers default track names ("3-Audio") and renumbers them on insert. */
+const withoutTrackNumber = (name: string) => name.replace(/^\d+-/, "");
+
+/**
+ * How a producer refers to a track: without Live's auto-number ("4-Serum 2"
+ * → "Serum 2") and without the "[2026-01-10 084550]" suffix Live adds to
+ * consolidated audio.
+ */
+export function displayName(name: string): string {
+ return (
+ withoutTrackNumber(name)
+ .replace(/\s*\[\d{4}-\d{2}-\d{2} \d{6}\]$/, "")
+ .trim() || name
+ );
+}
+
+// ── Clips ───────────────────────────────────────────────────────────
+
+const noteKey = (note: Note) => `${note.key}@${note.time.toFixed(4)}`;
+
+function mini(note: Note): MiniNote {
+ return { d: note.duration, k: note.key, t: note.time };
+}
+
+function diffNotes(before: Note[], after: Note[]): ClipAnalysis["notes"] {
+ const previous = new Map(before.map((note) => [noteKey(note), note]));
+ const added: MiniNote[] = [];
+ const kept: MiniNote[] = [];
+ for (const note of after) {
+ const old = previous.get(noteKey(note));
+ if (old && Math.abs(old.duration - note.duration) < PARAM_EPSILON) {
+ kept.push(mini(note));
+ previous.delete(noteKey(note));
+ } else {
+ added.push(mini(note));
+ }
+ }
+ const removed = [...previous.values()].map(mini);
+ return {
+ added: added.slice(0, MAX_NOTES_PER_CLIP),
+ kept: kept.slice(0, MAX_NOTES_PER_CLIP),
+ removed: removed.slice(0, MAX_NOTES_PER_CLIP),
+ };
+}
+
+function clipView(
+ clip: Clip,
+ status: ClipAnalysis["status"],
+ extra: Partial = {},
+): ClipAnalysis {
+ return {
+ color: clip.color,
+ end: clip.end,
+ kind: clip.kind,
+ movedFrom: null,
+ name: clip.name,
+ notes: null,
+ sample: clip.sample,
+ start: clip.start,
+ status,
+ ...extra,
+ };
+}
+
+const overlaps = (a: Clip, b: Clip) => a.start < b.end && b.start < a.end;
+
+/**
+ * Pair clips across versions: identical → same, same content elsewhere → moved,
+ * overlapping in place → edited, the rest → added / removed.
+ */
+function compareClips(
+ before: Clip[],
+ after: Clip[],
+ tempoChanged: boolean,
+): ClipAnalysis[] {
+ const remaining = new Set(before);
+ const result: ClipAnalysis[] = [];
+ const unmatched: Clip[] = [];
+
+ for (const clip of after) {
+ // Unwarped audio keeps its length in seconds, so its length in beats
+ // follows the tempo; that is not an edit.
+ const same = [...remaining].find(
+ (old) =>
+ old.contentHash === clip.contentHash &&
+ Math.abs(old.start - clip.start) < POSITION_EPSILON &&
+ (Math.abs(old.end - clip.end) < POSITION_EPSILON ||
+ (tempoChanged && clip.kind === "audio")),
+ );
+ if (same) {
+ remaining.delete(same);
+ result.push(clipView(clip, "same"));
+ } else {
+ unmatched.push(clip);
+ }
+ }
+
+ const stillUnmatched: Clip[] = [];
+ for (const clip of unmatched) {
+ const moved = [...remaining]
+ .filter(
+ (old) =>
+ old.contentHash === clip.contentHash &&
+ Math.abs(old.start - clip.start) >= POSITION_EPSILON,
+ )
+ .sort(
+ (a, b) => Math.abs(a.start - clip.start) - Math.abs(b.start - clip.start),
+ )[0];
+ if (moved) {
+ remaining.delete(moved);
+ result.push(clipView(clip, "moved", { movedFrom: moved.start }));
+ } else {
+ stillUnmatched.push(clip);
+ }
+ }
+
+ for (const clip of stillUnmatched) {
+ const edited = [...remaining].find(
+ (old) => old.kind === clip.kind && overlaps(old, clip),
+ );
+ if (edited) {
+ remaining.delete(edited);
+ result.push(
+ clipView(clip, "edited", {
+ notes: clip.kind === "midi" ? diffNotes(edited.notes, clip.notes) : null,
+ }),
+ );
+ } else {
+ result.push(
+ clipView(clip, "added", {
+ notes: clip.kind === "midi" ? diffNotes([], clip.notes) : null,
+ }),
+ );
+ }
+ }
+
+ for (const old of remaining) {
+ result.push(clipView(old, "removed"));
+ }
+ return result.sort((a, b) => a.start - b.start);
+}
+
+// ── Devices, mixer, automation ──────────────────────────────────────
+
+function compareDevices(before: Track, after: Track): TrackChange[] {
+ const changes: TrackChange[] = [];
+ const previous = new Map(before.devices.map((d) => [d.id, d]));
+ for (const device of after.devices) {
+ const old = previous.get(device.id);
+ if (!old || old.className !== device.className) {
+ changes.push({ device: device.name, type: "device-added" });
+ continue;
+ }
+ previous.delete(device.id);
+ if (old.enabled !== device.enabled) {
+ changes.push({
+ device: device.name,
+ type: device.enabled ? "device-on" : "device-off",
+ });
+ }
+ if (old.stateHash === device.stateHash) {
+ if (old.pluginStateHash !== device.pluginStateHash) {
+ changes.push({ device: device.name, type: "device-state" });
+ }
+ continue;
+ }
+ const params = Object.entries(device.params)
+ .filter(
+ ([name, to]) =>
+ name in old.params && Math.abs((old.params[name] ?? 0) - to) > PARAM_EPSILON,
+ )
+ .map(([name, to]) => ({ from: old.params[name] ?? 0, name, to }));
+ const onlyToggled = old.enabled !== device.enabled && params.length === 0;
+ if (!onlyToggled) {
+ changes.push({ device: device.name, params, type: "device-settings" });
+ }
+ }
+ for (const old of previous.values()) {
+ changes.push({ device: old.name, type: "device-removed" });
+ }
+ return changes;
+}
+
+function compareMixer(before: Track, after: Track): TrackChange[] {
+ const changes: TrackChange[] = [];
+ const a = before.mixer;
+ const b = after.mixer;
+ const dbMoved = (from: number, to: number) =>
+ Number.isFinite(from) !== Number.isFinite(to) ||
+ (Number.isFinite(to) && Math.abs(to - from) >= DB_EPSILON);
+ if (dbMoved(a.volumeDb, b.volumeDb)) {
+ changes.push({ from: a.volumeDb, to: b.volumeDb, type: "volume" });
+ }
+ if (Math.abs(a.pan - b.pan) > 0.01) {
+ changes.push({ from: a.pan, to: b.pan, type: "pan" });
+ }
+ b.sends.forEach((to, index) => {
+ const from = a.sends[index];
+ if (from !== undefined && dbMoved(from, to)) {
+ changes.push({ from, index, to, type: "send" });
+ }
+ });
+ if (a.muted !== b.muted) {
+ changes.push({ type: b.muted ? "muted" : "unmuted" });
+ }
+ if (a.soloed !== b.soloed) {
+ changes.push({ type: b.soloed ? "soloed" : "unsoloed" });
+ }
+ return changes;
+}
+
+function compareAutomation(before: Track, after: Track): TrackChange[] {
+ const changes: TrackChange[] = [];
+ const previous = new Map(before.automation.map((lane) => [lane.target, lane]));
+ for (const lane of after.automation) {
+ const old = previous.get(lane.target);
+ previous.delete(lane.target);
+ if (!old) {
+ changes.push({ status: "added", target: lane.target, type: "automation" });
+ } else if (old.hash !== lane.hash) {
+ changes.push({ status: "edited", target: lane.target, type: "automation" });
+ }
+ }
+ for (const old of previous.values()) {
+ changes.push({ status: "removed", target: old.target, type: "automation" });
+ }
+ return changes;
+}
+
+// ── Tracks ──────────────────────────────────────────────────────────
+
+function depthOf(track: Track, byId: Map): number {
+ let depth = 0;
+ let parent = track.groupId ? byId.get(track.groupId) : undefined;
+ while (parent && depth < 8) {
+ depth += 1;
+ parent = parent.groupId ? byId.get(parent.groupId) : undefined;
+ }
+ return depth;
+}
+
+function trackView(
+ track: Track,
+ depth: number,
+ status: TrackAnalysis["status"],
+ clips: ClipAnalysis[],
+ changes: TrackChange[] = [],
+): TrackAnalysis {
+ return {
+ changes,
+ clips,
+ color: track.color,
+ depth,
+ id: track.id,
+ name: track.name,
+ status,
+ type: track.type,
+ };
+}
+
+function compareTracks(before: LiveSet | null, after: LiveSet): TrackAnalysis[] {
+ const tempoChanged = before !== null && before.tempo !== after.tempo;
+ const afterById = new Map(after.tracks.map((t) => [t.id, t]));
+ const beforeById = new Map(before?.tracks.map((t) => [t.id, t]) ?? []);
+ const result: TrackAnalysis[] = [];
+
+ for (const track of after.tracks) {
+ const depth = depthOf(track, afterById);
+ const old = beforeById.get(track.id);
+ if (!before) {
+ result.push(trackView(track, depth, "same", track.clips.map((c) => clipView(c, "same"))));
+ continue;
+ }
+ if (!old || old.type !== track.type) {
+ result.push(
+ trackView(
+ track,
+ depth,
+ "added",
+ track.clips.map((clip) =>
+ clipView(clip, "added", {
+ notes: clip.kind === "midi" ? diffNotes([], clip.notes) : null,
+ }),
+ ),
+ ),
+ );
+ continue;
+ }
+ beforeById.delete(track.id);
+ const changes: TrackChange[] = [
+ ...(withoutTrackNumber(old.name) === withoutTrackNumber(track.name)
+ ? []
+ : [{ from: old.name, type: "renamed" as const }]),
+ ...compareDevices(old, track),
+ ...compareMixer(old, track),
+ ...compareAutomation(old, track),
+ ...(old.sessionClipCount === track.sessionClipCount
+ ? []
+ : [
+ {
+ from: old.sessionClipCount,
+ to: track.sessionClipCount,
+ type: "session-clips" as const,
+ },
+ ]),
+ ];
+ const clips = compareClips(old.clips, track.clips, tempoChanged);
+ const clipsChanged = clips.some((clip) => clip.status !== "same");
+ const meaningful = changes.some((c) => c.type !== "device-state");
+ result.push(
+ trackView(
+ track,
+ depth,
+ meaningful || clipsChanged ? "changed" : "same",
+ clips,
+ changes,
+ ),
+ );
+ }
+
+ // Removed tracks stay visible (ghosted) at the end.
+ for (const old of beforeById.values()) {
+ result.push(
+ trackView(
+ old,
+ 0,
+ "removed",
+ old.clips.map((clip) => clipView(clip, "removed")),
+ ),
+ );
+ }
+ return result;
+}
+
+function compareSet(before: LiveSet | null, after: LiveSet): SetChange[] {
+ if (!before) {
+ return [];
+ }
+ const changes: SetChange[] = [];
+ if (Math.abs(before.tempo - after.tempo) > 0.001) {
+ changes.push({ from: before.tempo, to: after.tempo, type: "tempo" });
+ }
+ if (before.timeSignature !== after.timeSignature) {
+ changes.push({
+ from: before.timeSignature,
+ to: after.timeSignature,
+ type: "time-signature",
+ });
+ }
+ const key = (l: { name: string; time: number }) => `${l.time}:${l.name}`;
+ const beforeKeys = new Set(before.locators.map(key));
+ const afterKeys = new Set(after.locators.map(key));
+ for (const locator of after.locators) {
+ if (!beforeKeys.has(key(locator))) {
+ changes.push({ ...locator, type: "locator-added" });
+ }
+ }
+ for (const locator of before.locators) {
+ if (!afterKeys.has(key(locator))) {
+ changes.push({ ...locator, type: "locator-removed" });
+ }
+ }
+ return changes;
+}
+
+// ── Summary ─────────────────────────────────────────────────────────
+
+function lengthOf(set: LiveSet): number {
+ let end = 0;
+ for (const track of set.tracks) {
+ for (const clip of track.clips) {
+ end = Math.max(end, clip.end);
+ }
+ }
+ for (const locator of set.locators) {
+ end = Math.max(end, locator.time);
+ }
+ return end;
+}
+
+/** How busy each slice of the arrangement is: fraction of clip-holding tracks playing. */
+function silhouette(set: LiveSet, length: number): number[] {
+ const shape = new Array(SHAPE_BUCKETS).fill(0);
+ const playable = set.tracks.filter((t) => t.clips.length > 0);
+ if (length <= 0 || playable.length === 0) {
+ return shape;
+ }
+ const size = length / SHAPE_BUCKETS;
+ for (const track of playable) {
+ const covered = new Array(SHAPE_BUCKETS).fill(false);
+ for (const clip of track.clips) {
+ if (clip.disabled) {
+ continue;
+ }
+ const first = Math.max(0, Math.floor(clip.start / size));
+ const last = Math.min(SHAPE_BUCKETS - 1, Math.ceil(clip.end / size) - 1);
+ for (let i = first; i <= last; i++) {
+ covered[i] = true;
+ }
+ }
+ covered.forEach((on, i) => {
+ if (on) {
+ shape[i]! += 1;
+ }
+ });
+ }
+ return shape.map((count) => Math.round((count / playable.length) * 100) / 100);
+}
+
+function changedRegions(tracks: TrackAnalysis[]): SaveSummary["regions"] {
+ const regions: SaveSummary["regions"] = [];
+ for (const track of tracks) {
+ for (const clip of track.clips) {
+ if (clip.status === "same") {
+ continue;
+ }
+ regions.push({ color: track.color, end: clip.end, start: clip.start });
+ if (clip.movedFrom !== null) {
+ const length = clip.end - clip.start;
+ regions.push({
+ color: track.color,
+ end: clip.movedFrom + length,
+ start: clip.movedFrom,
+ });
+ }
+ }
+ }
+ regions.sort((a, b) => a.start - b.start);
+ // Merge touching regions of the same colour to keep the stored list small.
+ const merged: SaveSummary["regions"] = [];
+ for (const region of regions) {
+ const last = merged.findLast((r) => r.color === region.color);
+ if (last && region.start <= last.end) {
+ last.end = Math.max(last.end, region.end);
+ } else {
+ merged.push({ ...region });
+ }
+ }
+ return merged.slice(0, 48);
+}
+
+function rangeOf(clips: ClipAnalysis[]): { end: number; start: number } {
+ return {
+ end: Math.max(...clips.map((c) => c.end)),
+ start: Math.min(...clips.map((c) => c.start)),
+ };
+}
+
+/** Ranked phrases; the first few become the headline. */
+function phrases(
+ tracks: TrackAnalysis[],
+ setChanges: SetChange[],
+ perBar: number,
+): string[] {
+ const out: { rank: number; text: string }[] = [];
+ const push = (rank: number, text: string) => out.push({ rank, text });
+
+ for (const change of setChanges) {
+ if (change.type === "tempo") {
+ push(0, `Tempo ${change.from} → ${change.to} BPM`);
+ } else if (change.type === "time-signature") {
+ push(0, `Time signature ${change.from} → ${change.to}`);
+ } else if (change.type === "locator-added") {
+ push(6, `Marker “${change.name || "untitled"}” added`);
+ }
+ }
+
+ for (const track of tracks) {
+ const name = displayName(track.name);
+ if (track.status === "added") {
+ push(1, `New track ${name}`);
+ continue;
+ }
+ if (track.status === "removed") {
+ push(1, `Removed ${name}`);
+ continue;
+ }
+ const added = track.clips.filter((c) => c.status === "added");
+ const removed = track.clips.filter((c) => c.status === "removed");
+ const edited = track.clips.filter((c) => c.status === "edited");
+ const moved = track.clips.filter((c) => c.status === "moved");
+ if (added.length > 0) {
+ const r = rangeOf(added);
+ push(2, `${name}: new ${added[0]!.kind === "midi" ? "part" : "audio"} in ${formatBars(r.start, r.end, perBar)}`);
+ }
+ if (edited.length > 0) {
+ const r = rangeOf(edited);
+ const noteEdits = edited.some(
+ (c) => c.notes && (c.notes.added.length > 0 || c.notes.removed.length > 0),
+ );
+ push(2, `${name}: ${noteEdits ? "notes" : "clips"} edited in ${formatBars(r.start, r.end, perBar)}`);
+ }
+ if (removed.length > 0) {
+ const r = rangeOf(removed);
+ push(3, `${name}: cleared ${formatBars(r.start, r.end, perBar)}`);
+ }
+ if (moved.length > 0) {
+ push(3, `${name}: clips moved`);
+ }
+ for (const change of track.changes) {
+ switch (change.type) {
+ case "device-state":
+ // Opaque plugin data; may change without the user touching anything.
+ break;
+ case "renamed":
+ push(5, `${displayName(change.from)} renamed to ${name}`);
+ break;
+ case "device-added":
+ push(3, `${change.device} added on ${name}`);
+ break;
+ case "device-removed":
+ push(3, `${change.device} removed from ${name}`);
+ break;
+ case "device-on":
+ case "device-off":
+ push(4, `${change.device} on ${name} turned ${change.type === "device-on" ? "on" : "off"}`);
+ break;
+ case "device-settings":
+ push(
+ 5,
+ change.params.length > 0
+ ? `${change.device} on ${name}: ${change.params.map((p) => p.name).slice(0, 2).join(", ")}`
+ : `${change.device} on ${name} tweaked`,
+ );
+ break;
+ case "volume":
+ push(
+ 4,
+ `${name} ${change.to > change.from ? "louder" : "quieter"} (${formatDb(change.to - change.from)})`,
+ );
+ break;
+ case "muted":
+ case "unmuted":
+ push(4, `${name} ${change.type}`);
+ break;
+ case "automation":
+ push(4, `${name}: ${change.target} automation ${change.status}`);
+ break;
+ default:
+ push(6, `${name} mix tweaked`);
+ }
+ }
+ }
+ return out
+ .sort((a, b) => a.rank - b.rank)
+ .map((p) => p.text)
+ .filter((text, index, all) => all.indexOf(text) === index);
+}
+
+function weigh(tracks: TrackAnalysis[], setChanges: SetChange[]): ChangeWeight {
+ const structural =
+ setChanges.some((c) => c.type === "tempo" || c.type === "time-signature") ||
+ tracks.some(
+ (t) =>
+ t.status === "added" ||
+ t.status === "removed" ||
+ t.clips.some((c) => c.status !== "same"),
+ );
+ if (structural) {
+ return "major";
+ }
+ return setChanges.length > 0 ||
+ tracks.some((t) => t.changes.some((c) => c.type !== "device-state"))
+ ? "minor"
+ : "none";
+}
+
+// ── Entry point ─────────────────────────────────────────────────────
+
+export function analyzeSets(
+ before: LiveSet | null,
+ after: LiveSet,
+ baseSaveId: string | null,
+): SaveAnalysis {
+ const perBar = beatsPerBar(after.timeSignature);
+ const tracks = compareTracks(before, after);
+ const setChanges = compareSet(before, after);
+ const length = Math.max(lengthOf(after), before ? lengthOf(before) : 0);
+ const weight = before ? weigh(tracks, setChanges) : "major";
+ const lines = phrases(tracks, setChanges, perBar);
+ const bars = Math.ceil(lengthOf(after) / perBar);
+ const playable = after.tracks.filter((t) => t.type !== "group" && t.type !== "return");
+
+ const headline = !before
+ ? `First save · ${playable.length} tracks, ${bars} bars at ${after.tempo} BPM`
+ : weight === "none" || lines.length === 0
+ ? "No musical changes"
+ : lines[0]!;
+
+ const summary: SaveSummary = {
+ baseSaveId,
+ beatsPerBar: perBar,
+ changes: before && weight !== "none" ? lines : [],
+ first: !before,
+ headline,
+ lengthBeats: length,
+ regions: before ? changedRegions(tracks) : [],
+ shape: silhouette(after, length),
+ touched: tracks
+ .filter((t) => t.status !== "same")
+ .slice(0, MAX_TOUCHED)
+ .map((t) => ({ color: t.color, name: displayName(t.name) })),
+ trackCount: playable.length,
+ version: ANALYSIS_VERSION,
+ weight,
+ };
+
+ return {
+ baseSaveId,
+ beatsPerBar: perBar,
+ lengthBeats: length,
+ locators: after.locators,
+ setChanges,
+ summary,
+ tempo: after.tempo,
+ timeSignature: after.timeSignature,
+ tracks,
+ };
+}
diff --git a/packages/server/src/smart-naming.ts b/packages/server/src/smart-naming.ts
deleted file mode 100644
index 753539d..0000000
--- a/packages/server/src/smart-naming.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * smart-naming.ts – Generate human-readable save labels from diffs.
- *
- * Consumes SetDiff + ChangeSummary and produces a short descriptive label
- * like "Added Bass track, tempo 120→128" or "3 files changed".
- * Fallback: "Auto-save {timestamp}" when no meaningful diff.
- */
-
-import type { ChangeSummary, SetDiff } from "./types";
-
-/** Build a concise label from a semantic diff and file changes. */
-export function formatDiffAsLabel(
- setDiff?: SetDiff,
- changes?: ChangeSummary,
-): string {
- const parts: string[] = [];
-
- if (setDiff) {
- // Tempo change
- if (setDiff.tempoChange) {
- parts.push(`tempo ${setDiff.tempoChange.from}→${setDiff.tempoChange.to}`);
- }
- // Time signature
- if (setDiff.timeSignatureChange) {
- parts.push(
- `time sig ${setDiff.timeSignatureChange.from}→${setDiff.timeSignatureChange.to}`,
- );
- }
- // Arrangement length
- if (setDiff.arrangementLengthChange) {
- const from = formatBeats(setDiff.arrangementLengthChange.from);
- const to = formatBeats(setDiff.arrangementLengthChange.to);
- parts.push(`length ${from}→${to}`);
- }
- // Added tracks
- for (const t of setDiff.addedTracks.slice(0, 2)) {
- parts.push(`added ${t.name}`);
- }
- if (setDiff.addedTracks.length > 2) {
- parts.push(`+${setDiff.addedTracks.length - 2} more tracks`);
- }
- // Removed tracks
- for (const t of setDiff.removedTracks.slice(0, 2)) {
- parts.push(`removed ${t.name}`);
- }
- if (setDiff.removedTracks.length > 2) {
- parts.push(`+${setDiff.removedTracks.length - 2} more removed`);
- }
- // Track reorder
- if (setDiff.tracksReordered) {
- parts.push("tracks reordered");
- }
- // Scene count
- if (setDiff.sceneCountChange) {
- const delta = setDiff.sceneCountChange.to - setDiff.sceneCountChange.from;
- parts.push(
- `${delta > 0 ? "+" : ""}${delta} scene${Math.abs(delta) === 1 ? "" : "s"}`,
- );
- }
- // Locator count
- if (setDiff.locatorCountChange) {
- const delta =
- setDiff.locatorCountChange.to - setDiff.locatorCountChange.from;
- parts.push(
- `${delta > 0 ? "+" : ""}${delta} locator${Math.abs(delta) === 1 ? "" : "s"}`,
- );
- }
- // Modified tracks (summarize)
- if (setDiff.modifiedTracks.length > 0) {
- const names = setDiff.modifiedTracks.slice(0, 2).map((t) => t.name);
- const suffix =
- setDiff.modifiedTracks.length > 2
- ? ` +${setDiff.modifiedTracks.length - 2} more`
- : "";
- parts.push(`edited ${names.join(", ")}${suffix}`);
- }
- }
-
- // File-level changes as fallback when no semantic diff
- if (parts.length === 0 && changes) {
- const total =
- changes.addedFiles.length +
- changes.removedFiles.length +
- changes.modifiedFiles.length;
- if (total > 0) {
- parts.push(`${total} file${total === 1 ? "" : "s"} changed`);
- }
- }
-
- if (parts.length === 0) {
- return autoTimestamp();
- }
- // Capitalize first part, join with ", "
- const label = parts.join(", ");
- return label.charAt(0).toUpperCase() + label.slice(1);
-}
-
-/** Format beats as bars (assuming 4/4 for simplicity). */
-function formatBeats(beats: number): string {
- const bars = Math.round(beats / 4);
- return `${bars} bar${bars === 1 ? "" : "s"}`;
-}
-
-function autoTimestamp(): string {
- const d = new Date();
- const pad = (n: number) => String(n).padStart(2, "0");
- return `Auto-save ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
diff --git a/packages/server/src/state-repository.test.ts b/packages/server/src/state-repository.test.ts
index b6a1ef4..a436ff0 100644
--- a/packages/server/src/state-repository.test.ts
+++ b/packages/server/src/state-repository.test.ts
@@ -104,39 +104,20 @@ function stateWithAnalysis(message: string): AppState {
previewStatus: "none",
previewUpdatedAt: null,
projectHash: "hash",
- setDiff: {
- addedTracks: [],
- arrangementLengthChange: null,
- locatorCountChange: null,
- modifiedTracks: [
- {
- addedClips: [],
- addedDevices: [],
- clipCountDelta: 0,
- colorChanged: false,
- deviceToggles: [],
- mixerChanges: [],
- name: "Track",
- removedClips: [],
- removedDevices: [],
- type: "audio",
- },
- ],
- removedTracks: [],
- sceneCountChange: null,
- tempoChange: null,
- timeSignatureChange: null,
- tracksReordered: false,
+ summary: {
+ baseSaveId: null,
+ beatsPerBar: 4,
+ changes: [],
+ first: true,
+ headline: "1 tracks · 4 bars at 120 BPM",
+ lengthBeats: 16,
+ regions: [],
+ shape: [1],
+ touched: [],
+ trackCount: 1,
+ version: 1,
+ weight: "major",
},
- trackSummary: [
- {
- clipCount: 0,
- color: 1,
- name: "Track",
- trackCount: 1,
- type: "audio",
- },
- ],
},
],
updatedAt: createdAt,
@@ -243,6 +224,23 @@ describe("StateRepository", () => {
expect((await repository.load()).state.activity[0]?.message).toBe("second");
});
+ test("drops pre-v1 analysis fields when loading older state", async () => {
+ const root = await mkdtemp(join(tmpdir(), "echoform-state-repo-"));
+ roots.push(root);
+ const legacy = stateWithAnalysis("legacy");
+ const save = legacy.projects[0]!.saves[0] as any;
+ delete save.summary;
+ save.setDiff = { addedTracks: [] };
+ save.trackSummary = [];
+ await writeFile(join(root, "state.json"), JSON.stringify(legacy));
+
+ const loaded = await new StateRepository(root).load();
+
+ const loadedSave = loaded.state.projects[0]!.saves[0] as any;
+ expect(loadedSave.setDiff).toBeUndefined();
+ expect(loadedSave.trackSummary).toBeUndefined();
+ });
+
test("rejects semantically invalid activity and recovers the previous state", async () => {
const root = await mkdtemp(join(tmpdir(), "echoform-state-repo-"));
roots.push(root);
@@ -314,23 +312,9 @@ describe("StateRepository", () => {
},
],
[
- "required set diff fields",
- (save: any) => {
- delete save.setDiff.tracksReordered;
- },
- ],
- [
- "nested track diffs",
- (save: any) => {
- save.setDiff.modifiedTracks[0].deviceToggles = [
- { name: "Device", enabled: "yes" },
- ];
- },
- ],
- [
- "track summaries",
+ "checkpoint summaries",
(save: any) => {
- delete save.trackSummary[0].trackCount;
+ delete save.summary.headline;
},
],
[
diff --git a/packages/server/src/state-repository.ts b/packages/server/src/state-repository.ts
index 39bb60e..8496633 100644
--- a/packages/server/src/state-repository.ts
+++ b/packages/server/src/state-repository.ts
@@ -61,108 +61,6 @@ function isOptionalBoolean(value: unknown): value is boolean | undefined {
return value === undefined || typeof value === "boolean";
}
-function isOptionalString(value: unknown): value is string | undefined {
- return value === undefined || isString(value);
-}
-
-function isNumberChange(value: unknown): boolean {
- return (
- isRecord(value) &&
- hasOnlyKeys(value, ["from", "to"]) &&
- isFiniteNumber(value.from) &&
- isFiniteNumber(value.to)
- );
-}
-
-function isStringChange(value: unknown): boolean {
- return (
- isRecord(value) &&
- hasOnlyKeys(value, ["from", "to"]) &&
- isString(value.from) &&
- isString(value.to)
- );
-}
-
-function isNullableNumberChange(value: unknown): boolean {
- return value === null || isNumberChange(value);
-}
-
-function isNamedType(value: unknown): boolean {
- return (
- isRecord(value) &&
- hasOnlyKeys(value, ["name", "type"]) &&
- isString(value.name) &&
- isString(value.type)
- );
-}
-
-function isTrackDiff(value: unknown): boolean {
- return (
- isRecord(value) &&
- hasOnlyKeys(value, [
- "addedClips",
- "addedDevices",
- "clipCountDelta",
- "colorChanged",
- "deviceToggles",
- "mixerChanges",
- "name",
- "removedClips",
- "removedDevices",
- "renamedFrom",
- "type",
- ]) &&
- isStringArray(value.addedClips) &&
- isStringArray(value.addedDevices) &&
- isFiniteNumber(value.clipCountDelta) &&
- typeof value.colorChanged === "boolean" &&
- Array.isArray(value.deviceToggles) &&
- value.deviceToggles.every(
- (toggle) =>
- isRecord(toggle) &&
- hasOnlyKeys(toggle, ["name", "enabled"]) &&
- isString(toggle.name) &&
- typeof toggle.enabled === "boolean",
- ) &&
- isStringArray(value.mixerChanges) &&
- isString(value.name) &&
- isStringArray(value.removedClips) &&
- isStringArray(value.removedDevices) &&
- isOptionalString(value.renamedFrom) &&
- isString(value.type)
- );
-}
-
-function isSetDiff(value: unknown): boolean {
- return (
- isRecord(value) &&
- hasOnlyKeys(value, [
- "addedTracks",
- "arrangementLengthChange",
- "locatorCountChange",
- "modifiedTracks",
- "removedTracks",
- "sceneCountChange",
- "tempoChange",
- "timeSignatureChange",
- "tracksReordered",
- ]) &&
- Array.isArray(value.addedTracks) &&
- value.addedTracks.every(isNamedType) &&
- isNullableNumberChange(value.arrangementLengthChange) &&
- isNullableNumberChange(value.locatorCountChange) &&
- Array.isArray(value.modifiedTracks) &&
- value.modifiedTracks.every(isTrackDiff) &&
- Array.isArray(value.removedTracks) &&
- value.removedTracks.every(isNamedType) &&
- isNullableNumberChange(value.sceneCountChange) &&
- (value.tempoChange === null || isNumberChange(value.tempoChange)) &&
- (value.timeSignatureChange === null ||
- isStringChange(value.timeSignatureChange)) &&
- typeof value.tracksReordered === "boolean"
- );
-}
-
function isChangeSummary(value: unknown): boolean {
return (
isRecord(value) &&
@@ -179,25 +77,15 @@ function isChangeSummary(value: unknown): boolean {
);
}
-function isTrackSummaryItem(value: unknown): boolean {
+/** Stored per-save analysis; recomputed when its version is outdated. */
+function isSaveSummary(value: unknown): boolean {
return (
isRecord(value) &&
- hasOnlyKeys(value, [
- "children",
- "clipCount",
- "color",
- "name",
- "trackCount",
- "type",
- ]) &&
- (value.children === undefined ||
- (Array.isArray(value.children) &&
- value.children.every(isTrackSummaryItem))) &&
- isFiniteNumber(value.clipCount) &&
- isFiniteNumber(value.color) &&
- isString(value.name) &&
- isFiniteNumber(value.trackCount) &&
- ["audio", "midi", "return", "group"].includes(String(value.type))
+ isString(value.headline) &&
+ isFiniteNumber(value.version) &&
+ Array.isArray(value.shape) &&
+ Array.isArray(value.regions) &&
+ Array.isArray(value.touched)
);
}
@@ -238,10 +126,7 @@ function isSave(value: unknown): value is Save {
isNullableString(value.previewMime) &&
isNullableString(value.previewRequestedAt) &&
isNullableString(value.previewUpdatedAt) &&
- (value.setDiff === undefined || isSetDiff(value.setDiff)) &&
- (value.trackSummary === undefined ||
- (Array.isArray(value.trackSummary) &&
- value.trackSummary.every(isTrackSummaryItem))) &&
+ (value.summary === undefined || isSaveSummary(value.summary)) &&
["activeSetPath", "modifiedAt"].every((key) => isString(metadata[key])) &&
isStringArray(metadata.setFiles) &&
["audioFiles", "fileCount", "sizeBytes"].every(
@@ -390,6 +275,11 @@ export function validateAppState(value: unknown): AppState {
}
}
}
+ for (const save of project.saves as unknown as Record[]) {
+ // Pre-v1 analysis fields; replaced by `summary`.
+ delete save.setDiff;
+ delete save.trackSummary;
+ }
if (project.saves.some((save) => !ideaIds.has(save.ideaId))) {
throw new StateRepositoryError(
`Project ${project.id} contains a save for a missing idea.`,
diff --git a/packages/server/src/test-support/als.ts b/packages/server/src/test-support/als.ts
new file mode 100644
index 0000000..1854565
--- /dev/null
+++ b/packages/server/src/test-support/als.ts
@@ -0,0 +1,130 @@
+/** Build minimal Live 12-shaped .als files for tests. */
+
+import { writeFile } from "node:fs/promises";
+import { gzipSync } from "node:zlib";
+
+export interface FixtureNote {
+ duration?: number;
+ key: number;
+ time: number;
+}
+
+export interface FixtureClip {
+ end: number;
+ /** Clip-internal loop end; defaults to the clip's length in beats. */
+ loopEnd?: number;
+ name?: string;
+ notes?: FixtureNote[];
+ sample?: string;
+ start: number;
+}
+
+export interface FixtureDevice {
+ enabled?: boolean;
+ id: string;
+ params?: Record;
+ tag: string;
+}
+
+export interface FixtureTrack {
+ clips?: FixtureClip[];
+ color?: number;
+ devices?: FixtureDevice[];
+ groupId?: string;
+ id: string;
+ name: string;
+ type: "audio" | "midi" | "group" | "return";
+ volume?: number; // linear gain, 1 = 0 dB
+}
+
+export interface FixtureSet {
+ locators?: { name: string; time: number }[];
+ tempo?: number;
+ tracks: FixtureTrack[];
+}
+
+const TAGS = {
+ audio: "AudioTrack",
+ group: "GroupTrack",
+ midi: "MidiTrack",
+ return: "ReturnTrack",
+} as const;
+
+function clipXml(clip: FixtureClip, kind: "midi" | "audio", id: number): string {
+ const tag = kind === "midi" ? "MidiClip" : "AudioClip";
+ const notes = (clip.notes ?? [])
+ .map(
+ (n, i) => ` `,
+ )
+ .join("");
+ const body =
+ kind === "midi"
+ ? `${notes} `
+ : ` `;
+ return `<${tag} Id="${id}" Time="${clip.start}">
+
+
+
+
+
+
+
+ ${body}
+ ${tag}>`;
+}
+
+function deviceXml(device: FixtureDevice): string {
+ const params = Object.entries(device.params ?? {})
+ .map(
+ ([name, value], i) =>
+ `<${name}> ${name}>`,
+ )
+ .join("");
+ return `<${device.tag} Id="${device.id}"> ${params}${device.tag}>`;
+}
+
+function trackXml(track: FixtureTrack): string {
+ const tag = TAGS[track.type];
+ const clips = (track.clips ?? [])
+ .map((clip, i) => clipXml(clip, track.type === "midi" ? "midi" : "audio", i))
+ .join("");
+ const holder =
+ track.type === "midi"
+ ? `${clips} `
+ : `${clips} `;
+ return `<${tag} Id="${track.id}">
+
+
+
+
+
+
+
+
+
+ ${holder}
+ ${(track.devices ?? []).map(deviceXml).join("")}
+
+ ${tag}>`;
+}
+
+export function alsXml(set: FixtureSet): string {
+ const locators = (set.locators ?? [])
+ .map((l, i) => ` `)
+ .join("");
+ return `
+
+
+ ${set.tracks.map(trackXml).join("")}
+
+
+
+
+ ${locators}
+
+ `;
+}
+
+export async function writeAls(path: string, set: FixtureSet): Promise {
+ await writeFile(path, gzipSync(alsXml(set)));
+}
diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts
index 106263b..37de78b 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -80,8 +80,110 @@ export interface Save {
previewStatus: PreviewStatus;
previewUpdatedAt: string | null;
projectHash: string;
- setDiff?: SetDiff; // semantic diff of the .als XML vs. previous save
- trackSummary?: TrackSummaryItem[]; // lightweight track list for visual thumbnails
+ /** Musical summary vs. the previous checkpoint of the same set. */
+ summary?: SaveSummary;
+}
+
+// ── Set analysis ────────────────────────────────────────────────────
+
+/** Bump when analysis output changes so stored summaries are recomputed. */
+export const ANALYSIS_VERSION = 2;
+
+export type ChangeWeight = "none" | "minor" | "major";
+
+/** Compact, stored per checkpoint: enough to draw and describe a timeline row. */
+export interface SaveSummary {
+ /** The checkpoint this one was compared with; stale once that changes. */
+ baseSaveId: string | null;
+ beatsPerBar: number;
+ /** Every change in words, most important first; searchable. */
+ changes: string[];
+ /** The most important change, short: "Serum 2: new part in bars 9–16". */
+ headline: string;
+ /** Where in the song things changed, in beats, coloured by track. */
+ regions: { color: number; end: number; start: number }[];
+ /** Arrangement density in fixed buckets (0–1), the song's silhouette. */
+ shape: number[];
+ lengthBeats: number;
+ /** Tracks that changed, in arrangement order. */
+ touched: { color: number; name: string }[];
+ trackCount: number;
+ version: number;
+ weight: ChangeWeight;
+ /** No earlier checkpoint to compare against. */
+ first: boolean;
+}
+
+export type ClipStatus = "same" | "added" | "removed" | "moved" | "edited";
+
+export interface MiniNote {
+ d: number; // duration, beats
+ k: number; // MIDI key
+ t: number; // time, beats from clip start
+}
+
+export interface ClipAnalysis {
+ color: number;
+ end: number;
+ kind: "midi" | "audio";
+ movedFrom: number | null;
+ name: string;
+ /** For added or edited MIDI clips: notes to draw a piano roll diff. */
+ notes: { added: MiniNote[]; kept: MiniNote[]; removed: MiniNote[] } | null;
+ sample: string | null;
+ start: number;
+ status: ClipStatus;
+}
+
+export type TrackChange =
+ | { type: "renamed"; from: string }
+ | { type: "device-added" | "device-removed"; device: string }
+ | { type: "device-on" | "device-off"; device: string }
+ /** Only the plugin's opaque state changed; may not be a user edit. */
+ | { type: "device-state"; device: string }
+ | {
+ type: "device-settings";
+ device: string;
+ params: { name: string; from: number; to: number }[];
+ }
+ | { type: "volume"; from: number; to: number }
+ | { type: "pan"; from: number; to: number }
+ | { type: "send"; index: number; from: number; to: number }
+ | { type: "muted" | "unmuted" | "soloed" | "unsoloed" }
+ | {
+ type: "automation";
+ target: string;
+ status: "added" | "removed" | "edited";
+ }
+ | { type: "session-clips"; from: number; to: number };
+
+export interface TrackAnalysis {
+ changes: TrackChange[];
+ clips: ClipAnalysis[];
+ color: number;
+ depth: number;
+ id: string;
+ name: string;
+ status: "same" | "added" | "removed" | "changed";
+ type: "audio" | "midi" | "return" | "group";
+}
+
+export type SetChange =
+ | { type: "tempo"; from: number; to: number }
+ | { type: "time-signature"; from: string; to: string }
+ | { type: "locator-added" | "locator-removed"; name: string; time: number };
+
+/** Full comparison of one checkpoint with the one before it, served on demand. */
+export interface SaveAnalysis {
+ baseSaveId: string | null;
+ beatsPerBar: number;
+ lengthBeats: number;
+ locators: { name: string; time: number }[];
+ setChanges: SetChange[];
+ summary: SaveSummary;
+ tempo: number;
+ timeSignature: string;
+ tracks: TrackAnalysis[];
}
export type PreviewStatus = "none" | "pending" | "ready" | "missing" | "error";
@@ -95,15 +197,6 @@ export interface PreviewRequestResult {
status: PreviewStatus;
}
-export interface TrackSummaryItem {
- children?: TrackSummaryItem[];
- clipCount: number;
- color: number; // Ableton color palette index
- name: string;
- trackCount: number; // groups include nested descendants
- type: "audio" | "midi" | "return" | "group";
-}
-
export interface ProjectMetadata {
activeSetPath: string;
audioFiles: number;
@@ -120,32 +213,6 @@ export interface ChangeSummary {
sizeDelta: number; // bytes gained or lost vs. previous save
}
-export interface SetDiff {
- addedTracks: { name: string; type: string }[];
- arrangementLengthChange: { from: number; to: number } | null;
- locatorCountChange: { from: number; to: number } | null;
- modifiedTracks: TrackDiff[];
- removedTracks: { name: string; type: string }[];
- sceneCountChange: { from: number; to: number } | null;
- tempoChange: { from: number; to: number } | null;
- timeSignatureChange: { from: string; to: string } | null;
- tracksReordered: boolean;
-}
-
-export interface TrackDiff {
- addedClips: string[];
- addedDevices: string[];
- clipCountDelta: number;
- colorChanged: boolean;
- deviceToggles: { name: string; enabled: boolean }[];
- mixerChanges: string[];
- name: string;
- removedClips: string[];
- removedDevices: string[];
- renamedFrom?: string;
- type: string;
-}
-
export interface CompareResult {
leftIdea: Idea;
leftSave: Save;