mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-17 05:40:42 -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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.0–2.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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user