mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -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,
|
Scissors,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
@@ -21,6 +23,8 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
@@ -31,6 +35,7 @@ import {
|
|||||||
useSetStoryItemVersion,
|
useSetStoryItemVersion,
|
||||||
useSplitStoryItem,
|
useSplitStoryItem,
|
||||||
useTrimStoryItem,
|
useTrimStoryItem,
|
||||||
|
useUpdateStoryItemVolume,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
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 {
|
interface StoryTrackEditorProps {
|
||||||
storyId: string;
|
storyId: string;
|
||||||
items: StoryItemDetail[];
|
items: StoryItemDetail[];
|
||||||
@@ -157,6 +222,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const duplicateItem = useDuplicateStoryItem();
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
const setItemVersion = useSetStoryItemVersion();
|
const setItemVersion = useSetStoryItemVersion();
|
||||||
|
const updateVolume = useUpdateStoryItemVolume();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||||
|
|
||||||
@@ -1045,6 +1111,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
StoryItemVersionUpdate,
|
StoryItemVersionUpdate,
|
||||||
|
StoryItemVolumeUpdate,
|
||||||
StoryResponse,
|
StoryResponse,
|
||||||
TranscriptionResponse,
|
TranscriptionResponse,
|
||||||
VoiceProfileCreate,
|
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(
|
async splitStoryItem(
|
||||||
storyId: string,
|
storyId: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
|
|||||||
@@ -392,11 +392,16 @@ export interface StoryItemDetail {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
instruct?: string;
|
||||||
engine?: string;
|
engine?: string;
|
||||||
|
volume: number;
|
||||||
generation_created_at: string;
|
generation_created_at: string;
|
||||||
versions?: GenerationVersionResponse[];
|
versions?: GenerationVersionResponse[];
|
||||||
active_version_id?: string;
|
active_version_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryItemVolumeUpdate {
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoryItemVersionUpdate {
|
export interface StoryItemVersionUpdate {
|
||||||
version_id: string | null;
|
version_id: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
StoryItemVersionUpdate,
|
StoryItemVersionUpdate,
|
||||||
|
StoryItemVolumeUpdate,
|
||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
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() {
|
export function useSplitStoryItem() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,13 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
const source = audioContext.createBufferSource();
|
||||||
source.buffer = buffer;
|
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 = {
|
const activeSource: ActiveSource = {
|
||||||
source,
|
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")
|
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
|
||||||
if "version_id" not in columns:
|
if "version_id" not in columns:
|
||||||
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
|
_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:
|
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class StoryItem(Base):
|
|||||||
track = Column(Integer, nullable=False, default=0)
|
track = Column(Integer, nullable=False, default=0)
|
||||||
trim_start_ms = Column(Integer, nullable=False, default=0)
|
trim_start_ms = Column(Integer, nullable=False, default=0)
|
||||||
trim_end_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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ class StoryItemDetail(BaseModel):
|
|||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
instruct: Optional[str]
|
instruct: Optional[str]
|
||||||
engine: Optional[str] = None
|
engine: Optional[str] = None
|
||||||
|
volume: float = 1.0
|
||||||
generation_created_at: datetime
|
generation_created_at: datetime
|
||||||
# Versions available for this generation
|
# Versions available for this generation
|
||||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
@@ -674,6 +675,17 @@ class StoryItemVersionUpdate(BaseModel):
|
|||||||
version_id: Optional[str] = None # null = use generation default
|
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):
|
class EffectConfig(BaseModel):
|
||||||
"""A single effect in an effects chain."""
|
"""A single effect in an effects chain."""
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,20 @@ async def trim_story_item(
|
|||||||
return 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])
|
@router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail])
|
||||||
async def split_story_item(
|
async def split_story_item(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from ..models import (
|
|||||||
StoryItemBatchUpdate,
|
StoryItemBatchUpdate,
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
|
StoryItemVolumeUpdate,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
StoryItemVersionUpdate,
|
StoryItemVersionUpdate,
|
||||||
)
|
)
|
||||||
@@ -70,6 +71,7 @@ def _build_item_detail(
|
|||||||
seed=generation.seed,
|
seed=generation.seed,
|
||||||
instruct=generation.instruct,
|
instruct=generation.instruct,
|
||||||
engine=generation.engine,
|
engine=generation.engine,
|
||||||
|
volume=getattr(item, "volume", 1.0),
|
||||||
generation_created_at=generation.created_at,
|
generation_created_at=generation.created_at,
|
||||||
versions=versions,
|
versions=versions,
|
||||||
active_version_id=active_version_id,
|
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)
|
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(
|
async def split_story_item(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
@@ -530,6 +563,7 @@ async def split_story_item(
|
|||||||
track=item.track,
|
track=item.track,
|
||||||
trim_start_ms=absolute_split_ms,
|
trim_start_ms=absolute_split_ms,
|
||||||
trim_end_ms=current_trim_end,
|
trim_end_ms=current_trim_end,
|
||||||
|
volume=getattr(item, "volume", 1.0),
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -603,6 +637,7 @@ async def duplicate_story_item(
|
|||||||
track=original_item.track,
|
track=original_item.track,
|
||||||
trim_start_ms=current_trim_start,
|
trim_start_ms=current_trim_start,
|
||||||
trim_end_ms=current_trim_end,
|
trim_end_ms=current_trim_end,
|
||||||
|
volume=getattr(original_item, "volume", 1.0),
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -858,6 +893,11 @@ async def export_story_audio(
|
|||||||
else:
|
else:
|
||||||
trimmed_audio = audio[trim_start_sample:]
|
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
|
# Store audio with its timecode info
|
||||||
start_time_ms = item.start_time_ms
|
start_time_ms = item.start_time_ms
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user