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,
+2
View File
@@ -128,6 +128,8 @@ def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
if "version_id" not in columns:
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
if "volume" not in columns:
_add_column(engine, "story_items", "volume FLOAT NOT NULL DEFAULT 1.0", "volume")
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
+1
View File
@@ -105,6 +105,7 @@ class StoryItem(Base):
track = Column(Integer, nullable=False, default=0)
trim_start_ms = Column(Integer, nullable=False, default=0)
trim_end_ms = Column(Integer, nullable=False, default=0)
volume = Column(Float, nullable=False, default=1.0)
created_at = Column(DateTime, default=datetime.utcnow)
+12
View File
@@ -598,6 +598,7 @@ class StoryItemDetail(BaseModel):
seed: Optional[int]
instruct: Optional[str]
engine: Optional[str] = None
volume: float = 1.0
generation_created_at: datetime
# Versions available for this generation
versions: Optional[List["GenerationVersionResponse"]] = None
@@ -674,6 +675,17 @@ class StoryItemVersionUpdate(BaseModel):
version_id: Optional[str] = None # null = use generation default
class StoryItemVolumeUpdate(BaseModel):
"""Request model for adjusting a story item's playback volume.
Linear gain. ``1.0`` is the original level, ``0.0`` is silent. Capped
above 1.0 so a too-aggressive boost can't blow out the mix or clip
the export.
"""
volume: float = Field(..., ge=0.0, le=2.0)
class EffectConfig(BaseModel):
"""A single effect in an effects chain."""
+14
View File
@@ -151,6 +151,20 @@ async def trim_story_item(
return item
@router.put("/stories/{story_id}/items/{item_id}/volume", response_model=models.StoryItemDetail)
async def update_story_item_volume(
story_id: str,
item_id: str,
data: models.StoryItemVolumeUpdate,
db: Session = Depends(get_db),
):
"""Set a story item's per-clip volume (linear gain, 0.02.0)."""
item = await stories.update_story_item_volume(story_id, item_id, data, db)
if item is None:
raise HTTPException(status_code=404, detail="Story item not found")
return item
@router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail])
async def split_story_item(
story_id: str,
+40
View File
@@ -20,6 +20,7 @@ from ..models import (
StoryItemBatchUpdate,
StoryItemMove,
StoryItemTrim,
StoryItemVolumeUpdate,
StoryItemSplit,
StoryItemVersionUpdate,
)
@@ -70,6 +71,7 @@ def _build_item_detail(
seed=generation.seed,
instruct=generation.instruct,
engine=generation.engine,
volume=getattr(item, "volume", 1.0),
generation_created_at=generation.created_at,
versions=versions,
active_version_id=active_version_id,
@@ -467,6 +469,37 @@ async def trim_story_item(
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def update_story_item_volume(
story_id: str,
item_id: str,
data: StoryItemVolumeUpdate,
db: Session,
) -> Optional[StoryItemDetail]:
"""Update a story item's playback volume (per-clip linear gain)."""
item = (
db.query(DBStoryItem)
.filter_by(id=item_id, story_id=story_id)
.first()
)
if not item:
return None
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation:
return None
item.volume = data.volume
story = db.query(DBStory).filter_by(id=story_id).first()
if story:
story.updated_at = datetime.utcnow()
db.commit()
db.refresh(item)
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def split_story_item(
story_id: str,
item_id: str,
@@ -530,6 +563,7 @@ async def split_story_item(
track=item.track,
trim_start_ms=absolute_split_ms,
trim_end_ms=current_trim_end,
volume=getattr(item, "volume", 1.0),
created_at=datetime.utcnow(),
)
@@ -603,6 +637,7 @@ async def duplicate_story_item(
track=original_item.track,
trim_start_ms=current_trim_start,
trim_end_ms=current_trim_end,
volume=getattr(original_item, "volume", 1.0),
created_at=datetime.utcnow(),
)
@@ -858,6 +893,11 @@ async def export_story_audio(
else:
trimmed_audio = audio[trim_start_sample:]
# Apply per-clip volume to the export mix.
volume = float(getattr(item, "volume", 1.0) or 1.0)
if volume != 1.0:
trimmed_audio = trimmed_audio * volume
# Store audio with its timecode info
start_time_ms = item.start_time_ms