mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-02 16:45:15 -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:
@@ -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