jamiepine/voicebox · warning · HTTPException

Story has no audio items

Error message

Story has no audio items

What it means

Raised (HTTP 400) by GET /stories/{story_id}/export-audio when the story exists but stories.export_story_audio returns None/falsy. The service returns None when the story has no DBStoryItem rows at all, or none of its items join to a DBGeneration (so there is no audio to mix into a single file). The 400 (rather than 404) signals 'story exists but is empty'.

Source

Thrown at backend/routes/stories.py:222

    if item is None:
        raise HTTPException(status_code=404, detail="Story item or version not found")
    return item


@router.get("/stories/{story_id}/export-audio")
async def export_story_audio(
    story_id: str,
    db: Session = Depends(get_db),
):
    """Export story as single mixed audio file."""
    try:
        story = db.query(database.Story).filter_by(id=story_id).first()
        if not story:
            raise HTTPException(status_code=404, detail="Story not found")

        audio_bytes = await stories.export_story_audio(story_id, db)
        if not audio_bytes:
            raise HTTPException(status_code=400, detail="Story has no audio items")

        safe_name = "".join(c for c in story.name if c.isalnum() or c in (" ", "-", "_")).strip()
        if not safe_name:
            safe_name = "story"
        filename = f"{safe_name}.wav"

        return StreamingResponse(
            io.BytesIO(audio_bytes),
            media_type="audio/wav",
            headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
        )
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

View on GitHub (pinned to 51f49dea19)

Solutions

  1. Disable the export control client-side until the story has at least one item with audio.
  2. If hit unexpectedly, inspect the story items and confirm each item's generation_id resolves to an existing generation.
  3. Treat 400 here as 'add clips first', not a server fault.

Example fix

// before
<button onClick={() => exportStory(story.id)}>Export</button>

// after
<button disabled={story.item_count === 0} onClick={() => exportStory(story.id)}>Export</button>
Defensive patterns

Strategy: validation

Validate before calling

function canExport(story) {
  return Boolean(story && (story.item_count ?? 0) > 0);
}
if (!canExport(story)) { notify('Add at least one clip before exporting'); return; }

Type guard

function hasPlayableItems(story) {
  return Array.isArray(story.items) && story.items.some(i => i.generation_id);
}

Try / catch

try { await api.exportStoryAudio(storyId); }
catch (e) {
  if (e.status === 400) notify('This story has no audio to export');
  else throw e;
}

Prevention

When it happens

Trigger: Calling export on a freshly created story with no clips added, or a story whose items all reference deleted generations so the inner join yields zero rows.

Common situations: Export button enabled on an empty story; user deletes every generation but the story shell remains; importing a story that lost its generation references.

Related errors


AI-assisted analysis of jamiepine/voicebox@51f49dea19 (2026-08-12). Data as JSON: /api/errors/1f09ffc0ff454590. Report an issue: GitHub.