mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 14:50:38 -07:00
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user