← All AI Engineer talks

AI Engineer Summit 2023

Storyteller: Building Multi-modal Apps with TS & ModelFusion - Lars Grammel, PhD

Read the talk

Storyteller: Streaming a Multimodal Story from Voice Input to Playback

A spoken topic becomes an illustrated, multivoice children’s story by combining parallel generation, partial structure parsing, and playback before the full story is ready.

From a talk by Lars Grammel

Before you start: Familiarity with TypeScript, React state, and asynchronous iteration will help with the implementation details.

A forest in trouble, told in several voices

How do you turn a child’s spoken topic into an audio story with a narrator and distinct character voices? Storyteller, Lars Grammel’s TypeScript application, uses ModelFusion, the AI orchestration library he developed, to generate preschool stories about two minutes long from voice input. Its opening sample follows Benny into a troubled forest: the narrator describes brown leaves and unusually quiet animals, Benny asks what is wrong, and another character explains that the trees are dying. The voices make the dialogue audible as a conversation rather than a single narrator reading every line.

A story card titled “Benny and the Magical Forest Rescue” shows a bear in a boat and audio controls. OpenAI, LMNT, ElevenLabs, and stability.ai appear beside their respective capabilities.
Storyteller’s illustrated audio story alongside its model providers.
0:000:20
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

0:00 · section reference included

Make the first result visible

Storyteller has a React client and a custom Fastify server. Its design has three challenges: responsiveness, quality, and consistency. The interaction begins with a small screen containing a Record Topic button. Holding the button records audio; releasing it sends an audio buffer to the server for transcription with OpenAI Whisper. Grammel reports about 1.5 seconds to transcribe a short topic in this application.

As soon as the transcription becomes available, the server sends it back through server-sent events. The client updates React state and renders the result, giving the user visible evidence that the request is progressing. Meanwhile, the server starts generating the story outline. Client feedback does not have to wait for the next generation stage to finish.

1:001:13
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

1:00 · section reference included

Use the outline to start parallel work

The outline is generated with gpt-3.5-turbo-instruct, chosen for its speed. Grammel reports about four seconds to generate a story outline. Once that shared story foundation exists, three branches can proceed in parallel: title generation, illustration generation, and generation and narration of the audio story. These branches share an input, but one branch’s completion need not hold up the others.

A client-server diagram shows audio recording flowing into topic transcription and story-outline generation, then branching into three generation tasks.
The story outline branches into title, image-prompt, and audio-story generation.

The title branch uses gpt-3.5-turbo-instruct again. When the title is ready, the server sends an event and the client renders it. This adds another concrete result to the screen while the more expensive branches continue.

2:142:28
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

2:14 · section reference included

Derive the illustration from the story

The illustration branch first needs an image prompt. To keep the picture consistent with the story, GPT-4 receives the whole story and extracts representative keywords. Those keywords become the prompt for Stability AI’s Stable Diffusion XL. The text model therefore bridges the narrative and the image model: the image is grounded in the story’s content rather than generated independently from the initial spoken topic.

The server stores the generated image as a virtual file and sends its path to the client in an event. The browser then retrieves the image through a regular URL request from its image element. Events carry the notification and location; ordinary media requests carry the generated asset.

2:573:17
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

2:57 · section reference included

Stream completed passages, not just tokens

With the illustration appearing in the UI, the largest remaining task is the full audio story. A more complex prompt extends the story into a structure containing dialogue and speakers. GPT-4 runs at a low temperature to preserve the original story. Grammel reports about one and a half minutes for full structured-story generation with low-temperature GPT-4. Waiting for that entire result before beginning narration would make the interactive client feel stalled. These timings describe his application in the recording; input lengths, model snapshots, network conditions, and trial counts are not specified.

The useful streaming unit is a finished story passage. A stream of characters alone cannot tell the speech pipeline whether a passage has its complete text and speaker information. The application needs partial parsing of the generated structure, followed by a decision about which parts are ready to narrate. ModelFusion supplies an iterable of partially parsed result fragments; the application retains responsibility for identifying finished passages. A partially parsed object is not automatically a completed, validated passage.

A TypeScript consumer can separate these responsibilities explicitly. Here, completedPrefix represents the application’s completion check: it returns only the stable, finished prefix of the story. submit hands each newly finished passage to the narration pipeline without resubmitting earlier passages.

typescript

type Passage = {
  speaker: string;
  text: string;
};

async function consumeStory(
  fragments: AsyncIterable<unknown>,
  completedPrefix: (fragment: unknown) => readonly Passage[],
  submit: (passage: Passage) => void,
): Promise<void> {
  let submittedCount = 0;

  for await (const fragment of fragments) {
    const finished = completedPrefix(fragment);

    while (submittedCount < finished.length) {
      submit(finished[submittedCount]!);
      submittedCount += 1;
    }
  }
}

The completion check is the critical boundary: it must establish that a passage will no longer change, not merely that its current fields can be read. Each finished part can then enter narration while later parts are still being generated.

3:454:05
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

3:45 · section reference included

Give each speaker a consistent voice

Before synthesizing a passage, Storyteller resolves its speaker to a voice. The existing assignment determines how much work is needed:

Speaker stateNext action
NarratorUse the predefined narrator voice
Previously encountered characterReuse the assigned voice
New characterFind and assign a voice

Reusing assignments lets recurring characters proceed immediately and keeps their voices consistent across passages. A newly encountered speaker requires an additional selection step.

For a new character, the selection procedure is:

  1. Generate a voice description with GPT-3.5 Turbo, requesting structured gender and voice-description fields.
  2. Retrieve similar voices from a collection whose voice descriptions were embedded beforehand, filtering candidates by gender.
  3. Select a voice while ensuring that no two speakers receive the same voice.

The description provides a semantic query into the voice collection; the gender filter narrows the candidates; the uniqueness constraint preserves audible differences between characters. Once selection is complete, the passage can proceed to audio generation.

A flowchart routes a speaker with a voice ID directly to audio generation. The other path generates a voice description, retrieves similar voice IDs, and selects a voice ID before joining audio generation.
Voice selection feeds audio generation for each story part.
5:035:16
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

5:03 · section reference included

Start listening while generation continues

Storyteller supports LMNT and ElevenLabs for speech synthesis. The selected voice determines which provider synthesizes a passage. As with images, the server stores the resulting audio as a virtual file and sends its path to the client. The client reconstructs the URL and retrieves the file through a media element.

Playback begins when the first audio part is ready. The listener can hear that part while the server continues generating and narrating subsequent passages. Streaming thus changes the dependency that matters to the user: listening requires the first playable passage, rather than completion of the entire structured story and all of its audio.

5:546:07
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

5:54 · section reference included

Responsiveness across the whole pipeline

Storyteller’s loading state has multiple parts that update as results arrive. Backend streaming and parallel processing make those results available sooner, and passage-level audio lets listening overlap with the remaining work. Model choice also contributes: fast generation is a selection criterion for stages such as the outline. The resulting experience depends on the whole pipeline—visible progress, concurrent branches, completed-passage detection, and early playback—rather than the speed of a single model call. Grammel closes by pointing readers to the Storyteller and ModelFusion repositories for the application and its orchestration library.

6:366:48
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

6:36 · section reference included

Resources

From the talk

Updates since the talk

  • May 2024 announcement explaining ModelFusion's contribution to AI SDK Core and its text, structured-object and tool-call primitives.

Read the complete timestamped transcript
  1. 0:00

    [upbeat music] Hey, everyone. I'm presenting Storyteller, an app for generating short audio stories for preschool kids.

  2. 0:20

    Storyteller is implemented using TypeScript and Model Fusion, an AI orchestration library that I've been developing. It generates audio stories that are about two minutes long, and all it needs is a voice input.

  3. 0:34

    Here is an example of the kind of story it generates to give you an idea.

  4. 0:38

    One day, while they were playing, Benny noticed something strange. The forest wasn't as vibrant as before. The leaves were turning brown, and the animals seemed less cheerful. Worried, Benny asked his friends what was wrong.

  5. 0:51

    Friends, why do the trees look so sad? And why are you all so quiet today?

  6. 0:55

    Benny, the forest is in trouble. The trees are dying, and we don't know what to do.

  7. 1:00

    How does this work? Let's dive into the details of the Storyteller application. Storyteller is a client-server application. The client is written using React, and the server is a custom Fastify implementation.

  8. 1:13

    The main challenges were responsiveness, meaning getting results to the user as quickly as possible, uh, quality, and consistency. So when you start Storyteller, it's just a small screen that has a Record Topic button, and once you start pressing it, it starts recording.

  9. 1:32

    Um, the audio, when you release, gets sent to the server as a buffer, and there we transcribe it. For transcription, I'm using OpenAI Whisper. Um, it is really quick for a short topic, one point five seconds, and once it becomes available, an event goes back to the client.

  10. 1:52

    So the client-server communication works through an event stream, server-sent events, [clears throat] that are being sent back.

  11. 2:02

    The event arrives on the client, and the React state updates, updating the screen. Okay, so then the user knows something is going on. In parallel, I start generating the story outline.

  12. 2:14

    For this, I use gpt-3.5-turbo-instruct, which I found to be very fast. So it can generate a story outline in about four seconds, and once we have that, we can start a bunch of other tasks in parallel.

  13. 2:28

    Generating the title, generating the image, and generating and narrating the audio story all happen in parallel. I'll go through those one by one now.

  14. 2:41

    First, the title is generated. For this, OpenAI gpt-3.5-turbo-instruct is used again, giving a really quick result. Once the title is available, it's being sent to the client again as an event and rendered there.

  15. 2:57

    In parallel, the image generation runs. First, uh, there needs to be a prompt to actually generate the image, and here consistency is important. So we pass in the whole story into a GPT-4 prompt that then extracts relevant representative keywords for an image prompt from the story.

  16. 3:17

    That image prompt is passed into Stability AI Stable Diffusion XL,

  17. 3:24

    where an image is generated. The generated image is stored as a virtual file in the server, and then an event is sent to the client with a path to that file.

  18. 3:37

    The client can then, through a regular URL request, just retrieve the image as part of an image tag,

  19. 3:45

    and it shows up in the UI. Generating the full audio story is the most time-consuming piece of the puzzle. Here we have a complex prompt that takes in the story and creates a structure with dialogue and speakers and extends the story.

  20. 4:05

    We use GPT-4 here with a low temperature to retain the story, and the problem is it takes one and a half minutes, which is unacceptably long for an interactive client.

  21. 4:17

    So how can this be solved? The key idea is streaming the structure. That's a little bit more difficult than just streaming characters token by token. Um, we need to always partially parse the structure and then determine if there is a new passage that we can actually, uh, narrate and, uh, synthesize speech for.

  22. 4:41

    Model Fusion takes care of the partial parsing and returns an iterable over fragments of partially parsed results that the application needs to decide what to do with them. Here we determine which story part is finished so we can actually narrate it.

  23. 4:58

    So we narrate each story part as it's getting finished.

  24. 5:03

    For each story part, we need to determine which voice, uh, we use to narrate it. The narrator has a predefined voice, and for all the speakers where we already have voices, we can immediately proceed.

  25. 5:16

    However, when there's a new speaker, we need to figure out which voice to give it.

  26. 5:23

    The first step for this is to generate a voice description for the speaker.

  27. 5:29

    Here's a GPT-3.5 Turbo prompt that gives us a structured result with gender and a voice description, and we then use that, um, for retrieval, where we beforehand embedded all the voices based on their descriptions and now can just retrieve them filtered by gender.

  28. 5:46

    Um, then a voice is selected, making sure there are no speakers with the same voice, and finally, we can generate the audio.

  29. 5:54

    Here for the speech synthesis, Element and ElevenLabs are supported. Based on the voices that have been chosen, one of those providers is picked and the audio is synthesized.

  30. 6:07

    Similar to the images, we generate an audio file, and we store it virtually in the server and then send the path to the client, which reconstructs the URL and just retrieves it as a media element.

  31. 6:20

    Once the first audio is completed, the client can then start playing. And while this is ongoing, in the background you're listening, and in the background the server continues to generate more and more parts.

  32. 6:36

    And that's it. So let's recap how the main challenge of responsiveness is addressed here. We have a loading state that has multiple parts that are updated as more results become available.

  33. 6:48

    We use streaming and parallel processing in the back end to make results available as quickly as possible, and you can start listening while the processing is still going on.

  34. 6:58

    And finally, models are being chosen such that the processing time for, like, the generation, say, of the story is minimized.

  35. 7:08

    Cool. I hope you enjoyed my talk. Thank you for listening, and if you wanna find out more, you can find Storyteller and also Model Fusion on GitHub at github.com/lgrammel/storyteller and github.com/lgrammel/modelfusion. [upbeat music]