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