feat(stories): per-clip volume control on the timeline

Each story item now carries a volume column (linear gain, default 1.0,
clamped 0.0–2.0 server-side). New PUT /stories/{}/items/{}/volume route
+ useUpdateStoryItemVolume hook + a Volume2 icon in the clip-edit
toolbar that opens a popover with a 0–200% slider. Local slider state
drives the visual during a drag; the persist fires once on
onValueCommit, mirroring the generation-page slider pattern.

Web Audio playback inserts a per-clip GainNode between source and
master so volume changes apply live without re-decoding the buffer
(source -> clipGain -> masterGain -> destination). Server-side
mixdown in export multiplies the trimmed clip by its volume before
summing into the timeline. Split + duplicate carry the volume forward
to the new clips so trimming a faded section keeps the level you set.

Migration adds the volume column with default 1.0 so existing rows
read as full volume.
This commit is contained in:
Jamie Pine
2026-04-25 02:18:56 -07:00
parent 935efedae0
commit 9f183e5832
10 changed files with 205 additions and 1 deletions
@@ -11,6 +11,8 @@ import {
Scissors,
Square,
Trash2,
Volume2,
VolumeX,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
@@ -21,6 +23,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
@@ -31,6 +35,7 @@ import {
useSetStoryItemVersion,
useSplitStoryItem,
useTrimStoryItem,
useUpdateStoryItemVolume,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -120,6 +125,66 @@ function ClipWaveform({
);
}
// Per-clip volume popover. Local state drives the slider during a drag so
// each pointer-move pixel doesn't fire a PATCH; commits on release.
function ClipVolumePopover({
storyId,
itemId,
volume,
onChange,
}: {
storyId: string;
itemId: string;
volume: number;
onChange: (value: number) => void;
}) {
const [localVolume, setLocalVolume] = useState(volume);
// Re-sync when the selected clip changes or the persisted value updates
// out-of-band (split/duplicate carry the value forward).
useEffect(() => {
setLocalVolume(volume);
}, [volume, itemId, storyId]);
const display = Math.round(localVolume * 100);
const Icon = localVolume === 0 ? VolumeX : Volume2;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={`Volume — ${display}%`}
aria-label="Adjust clip volume"
>
<Icon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Volume</span>
<span className="text-xs tabular-nums">{display}%</span>
</div>
<Slider
value={[localVolume * 100]}
onValueChange={([v]) => setLocalVolume(v / 100)}
onValueCommit={([v]) => onChange(v / 100)}
min={0}
max={200}
step={1}
aria-label="Clip volume"
/>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground tabular-nums">
<span>0%</span>
<span>100%</span>
<span>200%</span>
</div>
</PopoverContent>
</Popover>
);
}
interface StoryTrackEditorProps {
storyId: string;
items: StoryItemDetail[];
@@ -157,6 +222,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const updateVolume = useUpdateStoryItemVolume();
const { toast } = useToast();
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
@@ -1045,6 +1111,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Copy className="h-4 w-4" />
</Button>
{selectedItem && (
<ClipVolumePopover
storyId={storyId}
itemId={selectedItem.id}
volume={selectedItem.volume}
onChange={(value) =>
updateVolume.mutate(
{
storyId,
itemId: selectedItem.id,
data: { volume: value },
},
{
onError: (error) => {
toast({
title: 'Failed to update volume',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
},
)
}
/>
)}
<Button
variant="ghost"
size="icon"
+12
View File
@@ -30,6 +30,7 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
@@ -770,6 +771,17 @@ class ApiClient {
});
}
async updateStoryItemVolume(
storyId: string,
itemId: string,
data: StoryItemVolumeUpdate,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/volume`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(
storyId: string,
itemId: string,
+5
View File
@@ -392,11 +392,16 @@ export interface StoryItemDetail {
seed?: number;
instruct?: string;
engine?: string;
volume: number;
generation_created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface StoryItemVolumeUpdate {
volume: number;
}
export interface StoryItemVersionUpdate {
version_id: string | null;
}
+21
View File
@@ -9,6 +9,7 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
} from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
@@ -154,6 +155,26 @@ export function useTrimStoryItem() {
});
}
export function useUpdateStoryItemVolume() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemVolumeUpdate;
}) => apiClient.updateStoryItemVolume(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useSplitStoryItem() {
const queryClient = useQueryClient();
+7 -1
View File
@@ -264,7 +264,13 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(masterGainRef.current || audioContext.destination);
// Per-clip gain so each item can override its level independently
// of the master volume. Falls through 1.0 for any item without a
// saved value (older rows pre-migration).
const clipGain = audioContext.createGain();
clipGain.gain.value = typeof item.volume === 'number' ? item.volume : 1;
source.connect(clipGain);
clipGain.connect(masterGainRef.current || audioContext.destination);
const activeSource: ActiveSource = {
source,