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
+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."""