Skip to content

Fix streaming playback and HLS lifetime in gr.Audio and gr.Video - #13808

Open
hysts wants to merge 19 commits into
mainfrom
fix/audio-stream-second-run-silent
Open

Fix streaming playback and HLS lifetime in gr.Audio and gr.Video#13808
hysts wants to merge 19 commits into
mainfrom
fix/audio-stream-second-run-silent

Conversation

@hysts

@hysts hysts commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Description

A gr.Audio(streaming=True) output plays the first run and is silent on every run after it. Setting waveform_options=gr.WaveformOptions(show_recording_waveform=False) works around it.

stream_active was the flag that kept the player from re-attaching a source for every chunk of a run, since each chunk re-sends the same playlist URL. Its only reset lived in load_audio(), which is called from an effect gated on waveform_ready || !waveform_options.show_recording_waveform. A stream deliberately never renders the waveform branch, so waveform_ready can never become true, and with the default show_recording_waveform=True neither side of that guard holds. load_audio() never ran, stream_active stayed true after the first run, and every later run returned early without attaching anything.

The guard picked up waveform_ready in #12779 (the Svelte 5 migration). Before that the load ran on every URL change, which is what kept stream_active in sync. #13728 later added the !show_recording_waveform escape hatch for the disabled-waveform player, which is why that setting works around the bug today.

The underlying problem is that the stream's lifetime was coordinated by manual flags (stream_active, active_hls) written from four separate places: an attach function, a URL-keyed reset effect, an error callback, and unmount teardown. This bug was one missed interaction between those sites, and review of earlier revisions of this PR kept finding others. So instead of patching the flag, the fix makes a single $effect own the media element's source in both gr.Audio and gr.Video: it attaches one HLS instance per playlist URL, and its teardown destroys that instance when the URL changes, when the value is cleared, and when the component unmounts. In gr.Audio the same effect assigns a plain file value too, since a stream and a file are the same resource. Since a run re-sends its own URL with every chunk and the value object is new on each chunk, the effect depends only on equality-stable values (url, is_stream), so chunks of the same run never restart the stream. A fatal unrecoverable HLS error destroys the instance and playback stays stopped for that run, as before; the next run brings a new URL and the effect re-attaches naturally (Hls.destroy() is idempotent in hls.js 1.6.15, so the teardown destroying it again is safe, and a test covers that).

One caveat on that run boundary: the playlist URL carries a per-run key which is currently id(iterator), and #13811 fixes sequential runs colliding on a reused address. Where they do collide the URL is identical between runs, so this effect correctly keeps the source it already has and the second run is silent for a backend reason this branch cannot see. #13807 needs both changes to be closed for good.

Related fixes in the same lifecycle, kept in this PR because they share the defect surface and the reviewing context:

  • Video.svelte carried a verbatim copy of the HLS setup, on a different hls.js version: @gradio/audio pinned 1.5.13 exactly and @gradio/video asked for ^1.6.13, so the bundle shipped two copies and any fix had to be applied twice. Both components now stream through create_hls_stream from @gradio/utils/hls, and hls.js is pinned once, in @gradio/utils, at 1.6.15. That means the audio player moves up a minor version, which is worth a look from anyone who knows that range; the ManagedMediaSource attach described below is not new in it, since 1.5.13 has the same appendSource path. The helper is a subpath export, so packages that do not stream never pull hls.js into their bundles; the tradeoff is that hls.js now sits in @gradio/utils's install footprint for every dependent package.
  • waveform_ready was never reset when the waveform branch unmounted, so a component that had shown a normal file and then received a stream pushed the .m3u8 playlist into wavesurfer; the async decode failure then assigned that playlist to audio_player.src, replacing the MediaSource HLS had just attached (waveform_ready goes stale when a streaming value follows a regular file in gr.Audio #13810). The teardown now resets that state and clears the subtitle handlers registered on the destroyed instance, and handle_waveform_error ignores errors while the value is a stream.
  • A failed wavesurfer load left two problems behind. wavesurfer emits error synchronously before it rejects, and the event carries no URL, so a failure belonging to a file the value had already moved past still dropped the current file into the native fallback. And once the fallback was active, the failed file stayed attached to the native element, so a later file that decoded fine played behind the restored waveform with no way to stop it. Load failures now go through the load promise, the only path that knows which URL failed, and the native element is released when the waveform recovers. A media element error needs the event instead: it interrupts wavesurfer's wait for loadedmetadata, which has no reject path, so that load never settles and the promise never sees the error. The two are told apart by type, since wavesurfer passes the element's own MediaError straight through. Until this, a file whose bytes fetched but which the element could not parse reached neither path and left a blank waveform with no fallback and nothing in the console.
  • Releasing an HLS stream could discard the source that replaced it. hls.js leaves the element alone when src no longer holds its own object URL, but that check reads (media.querySelector('source') || media).src, and when it attaches through a ManagedMediaSource (Safari 17+ and iOS 17+, which is the default wherever the constructor exists) it keeps its object URL in a source child. The check matches there even after src has moved on, so the detach clears the element, and with show_recording_waveform=False a stream followed by a plain file left the player with nothing to play. One effect owning both removes the problem, because Svelte runs a teardown before the next body. gr.Video is not exposed to it: VideoPreview keys the player on the URL, so its teardown always runs on an element that is being discarded anyway.
  • Every stream teardown logged an unhandled promise rejection. The teardown's load() rejects a pending play() promise by spec, and the streaming call sites did not catch it. The same catch also covers playback blocked by an autoplay policy.
  • Every audio file was fetched and decoded twice, because load_audio() duplicated the wavesurfer load that the URL effect already performs. The wavesurfer load now happens in one place, and the other effect only assigns the native <audio> source when the waveform is off.
  • A streaming gr.Video output has shown nothing at all since the gradio 6 value-model change: the value moved from the {video, subtitles} envelope to a plain FileData (subtitles became a component prop), but Video.stream_output kept emitting the old envelope, so the frontend read value.url as undefined and rendered the empty state. The e2e specs covering streamed video are skipped, so CI never caught it. stream_output now returns a flat FileData the way gr.Audio's does. That also changes what a gradio_client caller of a streaming gr.Video endpoint receives, from {"video": "<path>"} to the bare path, which is the shape postprocess() already returns for non-streaming values.
  • A streamed gr.Video played by itself whatever autoplay said, because the manifest callback called play() unconditionally. That had no visible effect while the payload shape above kept the player empty, so fixing the shape is what would have turned it into a regression people could see. The callback is gated on the prop now, the way gr.Audio's is; the element already carried the autoplay attribute and the loaded action already acted on the prop, so this was the one place left that could override it. demo/stream_video_out sets autoplay=True explicitly, so nothing in the repo relied on the forced play.
  • Playback problems were being hidden from both ends. Every play() rejection was swallowed, and non-fatal HLS errors stopped being logged once both components started routing through the shared helper, although both earlier copies logged them. Rejections now go through a play_media helper in @gradio/utils that absorbs the two that are not the app's doing, a teardown's AbortError and an autoplay-policy block, and logs anything else; non-fatal HLS details go to console.debug, which keeps the symptom of a stream that connects and plays nothing reachable without filling the console at a one-second buffer.

Deferred as follow-ups rather than grown into this PR: a rewrite of the waveform's own lifetime management (it is entangled with the controls, subtitles, trim, and native-fallback flows), and bounding hls.js's fatal-error recovery, which retries a failing playlist or segment without a limit.

Closes: #13807

Before / after Spaces

Both Spaces stream audio and video. On the before Space the audio's second run goes silent and the video output never displays anything (the streamed-video value shape bug above).

AI Disclosure

We encourage the use of AI tooling in creating PRs, but the any non-trivial use of AI needs be disclosed. E.g. if you used Claude to write a first draft, you should mention that. Trivial tab-completion doesn't need to be disclosed. You should self-review all PRs, especially if they were generated with AI.

  • I used AI to investigate the root cause and implement the fix.
  • I did not use AI

🎯 PRs Should Target Issues

Before your create a PR, please check to see if there is an existing issue for this change. If not, please create an issue before you create this PR, unless the fix is very small.

Not adhering to this guideline will result in the PR being closed.

Testing and Formatting Your Code

  1. PRs will only be merged if tests pass on CI. We recommend at least running the backend tests locally, please set up your Gradio environment locally and run the backed tests: bash scripts/run_backend_tests.sh

  2. Please run these bash scripts to automatically format your code: bash scripts/format_backend.sh, and (if you made any changes to non-Python files) bash scripts/format_frontend.sh

`stream_active` keeps load_stream() from re-attaching a source for
every chunk of a run, since each chunk re-sends the same playlist
URL. Its only reset lived in load_audio(), which runs from an effect
gated on `waveform_ready || !show_recording_waveform`. A stream never
renders the waveform branch, so waveform_ready can never become true,
and with the default show_recording_waveform=True neither side of the
guard holds. The flag stayed true after the first run, so every later
run attached nothing and played silence.

Reset it in a small effect keyed on the URL instead, the way
Video.svelte already does. Each run streams to its own playlist URL,
so a change of URL is exactly what signals a new stream to attach.
The previous Hls instance is now destroyed too, instead of being left
bound to the same audio element.
@hysts hysts self-assigned this Sep 1, 2026
@hysts
hysts requested a lite review from Copilot September 1, 2026 11:02
@gradio-pr-bot

gradio-pr-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

🪼 branch checks and previews

Name Status URL
Spaces ready! Spaces preview
Website ready! Website preview
Storybook ready! Storybook preview
🦄 Changes detected! Details

Install Gradio from this PR

pip install https://huggingface.co/buckets/gradio/pypi-previews/resolve/ba5eb705409a3e9202995bbe4425bb17d941c4b2/gradio-6.26.0-py3-none-any.whl

Install Gradio Python Client from this PR

pip install "gradio-client @ git+https://github.com/gradio-app/gradio@ba5eb705409a3e9202995bbe4425bb17d941c4b2#subdirectory=client/python"

Import Gradio JS Client from this PR via CDN

import { Client } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/ba5eb705409a3e9202995bbe4425bb17d941c4b2/browser.js";

@gradio-pr-bot

gradio-pr-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

🦄 change detected

This Pull Request includes changes to the following packages.

Package Version
@gradio/audio patch
@gradio/utils patch
@gradio/video patch
gradio patch

  • Fix streaming playback and HLS lifetime in gr.Audio and gr.Video

Something isn't right?

  • Maintainers can change the version label to modify the version bump.
  • If the bot has failed to detect any changes, or if this pull request needs to update multiple packages to different versions or requires a more comprehensive changelog entry, maintainers can update the changelog file directly.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a frontend regression where gr.Audio(streaming=True) only plays the first streaming run by ensuring streaming state is reset when a new playlist URL indicates a new run, and by preventing HLS instances from accumulating across runs.

Changes:

  • Track the currently attached Hls instance in AudioPlayer and reset stream_active based on the derived url (new run == new URL).
  • Destroy the previous HLS instance before attaching a new one to avoid leaking MediaSource bindings.
  • Add a unit test to verify a new streaming run triggers a new HLS source attachment.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
js/audio/player/AudioPlayer.svelte Resets streaming state on URL changes and manages HLS instance lifecycle for streaming audio outputs.
js/audio/audio.test.ts Adds a regression test ensuring consecutive streaming runs reattach a new HLS source.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread js/audio/audio.test.ts
Comment on lines +830 to +834
beforeEach(() => {
load_source = vi
.spyOn(Hls.prototype, "loadSource")
.mockImplementation(() => {});
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vitest browser matrix in this repo is pinned to Chromium (js/spa/vite.config.ts, instances: [{ browser: "chromium" }]), so there is no CI-provider variance here and Hls.isSupported() is always true. If it were false the test would fail on the waitFor assertion rather than hang. Mocking it would also hide a real environment regression instead of surfacing it, and the non-HLS branch it would mask carries the same bug this PR fixes, so it deserves a real test rather than a mock.

Comment thread js/audio/player/AudioPlayer.svelte Outdated
Destroying it only when attaching the next stream left an instance
behind whenever the value stopped being a stream, and clearing the
value unmounts AudioPlayer entirely, so nothing ran at all there.

Move the teardown into the URL effect, above the load effect so the
detach cannot clear a source that effect has just set, and destroy
it in the onMount teardown for the unmount path.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.changeset/icy-cups-join.md:6

  • Repository policy says not to hand-author .changeset/*.md files because a GitHub Action generates them from the PR title, and an existing changeset will override the changelog entry. This file should be removed from the PR to avoid publishing an unintended/duplicated changelog entry (see AGENTS.md Pull Request Rules #7).
---
"@gradio/audio": patch
"gradio": patch
---

fix:Fix `gr.Audio(streaming=True)` playing only the first streaming run

hysts and others added 3 commits September 1, 2026 11:48
The new-run path is where the leak fix actually matters, but only the
clear-to-null path asserted on it.

The native branch that runs when Hls is unsupported carries the same
reset bug and was untested. Chromium always supports HLS, so the only
way in is to force isSupported() false; that is the opposite of
pinning it true, which would have hidden a regression on the path the
other tests already cover.
The Hls setup was duplicated verbatim in AudioPlayer.svelte and
Video.svelte, on two different hls.js versions (1.5.13 and 1.6.15), so
the bundle carried two copies and the teardown fix had landed in only
one of them. Both now go through attach_hls_stream in
@gradio/utils/hls, pinned to 1.6.15, and Video destroys its instance
on a source change and on unmount the way Audio already did. The
helper sits behind a subpath export so packages that do not stream
still never pull hls.js in.

The helper reports an unrecoverable error back to the caller. The old
code destroyed the instance there but left stream_active true and the
caller's reference pointing at the dead object, so the rest of that
run was silent and the next teardown destroyed it a second time.

stream_active becomes $state so the attach effect tracks it, matching
Video. As a plain let it was untracked, and correctness rested on the
reset effect being declared before the attach effect, which no test
would have caught.

A stale waveform is now torn down when its branch unmounts, and
neither load path feeds a stream URL to wavesurfer. Going from a file
value to a stream left waveform_ready true, so the playlist was loaded
into wavesurfer, and the async decode failure then assigned that
playlist to audio_player.src, replacing the MediaSource HLS had just
attached.
@hysts hysts changed the title Fix gr.Audio(streaming=True) playing only the first streaming run Fix streaming playback and HLS lifetime in gr.Audio and gr.Video Sep 1, 2026
@hysts
hysts requested a lite review from Copilot September 1, 2026 12:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

Comment on lines +1 to +8
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch
---

fix:Fix streaming playback and HLS lifetime in `gr.Audio` and `gr.Video`
The Hls and native stream sources were coordinated by manual flags
(stream_active, active_hls) written from an attach function, a
URL-keyed reset effect, an error callback and unmount teardown; every
regression in review was an interaction between two of those sites.
A single $effect now owns the stream in both components: it attaches
one instance per playlist URL and its teardown destroys it on a new
run, on clearing and on unmount. The effect depends only on
equality-stable deriveds, since the value object is new every chunk.

The helper loses the lifetime callbacks, registers its listeners
before loading (which can emit synchronously), and exports Hls and
is_hls_supported so the components no longer pin hls.js themselves.
A fatal unrecoverable error just destroys the instance; destroy() is
idempotent in hls.js 1.6.15, so the later teardown stays safe.

On the waveform side, the duplicate wavesurfer load in load_audio()
is gone (every file was fetched and decoded twice), the waveform
teardown clears the subtitle handlers registered on it, and
handle_waveform_error ignores errors while the value is a stream so a
late rejection cannot steer the native player onto the playlist.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (1)

.changeset/icy-cups-join.md:8

  • This repo’s PR rules explicitly say not to hand-author .changeset/*.md files because a GitHub Action generates them from the PR title; committing one will override the generated changelog entry. Please remove this changeset file from the PR.
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch
---

fix:Fix streaming playback and HLS lifetime in `gr.Audio` and `gr.Video`

The non-MSE fallback branch registered no teardown, so clearing the
value left the previous stream playing behind the empty state on the
always-mounted native element. The teardown now pauses and detaches
the source, but only while the src is still the one this effect set:
effects run in declaration order, so the next value's source may
already be in place (hls.js guards its detach the same way).

handle_waveform_error acted on the current value rather than the URL
whose load failed, so a slow file's late decode rejection could push
a newer, valid file onto the native fallback. The load effect now
captures the URL and the handler discards rejections the value has
moved past. The manifest-parsed callback also captures autoplay via
untrack up front instead of reading waveform_settings inside the
callback, and the remaining value?.* reads moved onto the
equality-stable deriveds.

Test fixtures now use an unroutable host instead of example.com,
since those URLs land on real media elements in browser mode.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (1)

.changeset/icy-cups-join.md:5

  • This repository’s PR rules say not to hand-author .changeset/*.md files because a GitHub Action generates them from the PR title; keeping this file will override the autogenerated changelog entry (and the summary line currently has a fix:Fix typo). Please delete this changeset file and let the bot generate it.
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch

Comment on lines 264 to 267
}
media.src = url;
if (untrack(() => waveform_settings.autoplay)) media.play();
});
gradio 6 changed gr.Video's value model from the {video, subtitles}
envelope to a plain FileData (subtitles became a component prop), but
stream_output kept emitting the old envelope. The frontend reads
value.url off the value directly, finds undefined, and renders the
empty state, so a streaming gr.Video output has shown nothing since
the change; the e2e specs covering it are skipped, so CI never saw
it. This also un-breaks the desired_output_format lookup in
handle_streaming_outputs, which reads orig_name from the top level of
the payload.

Match gr.Audio's stream_output shape and type the payload as
FileDataDict.
@hysts
hysts requested a lite review from Copilot September 1, 2026 15:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (1)

.changeset/icy-cups-join.md:5

  • This repository’s contributor guidelines explicitly say not to hand-author .changeset/*.md files because a GitHub Action generates them from the PR title, and an existing changeset will override the generated changelog entry (AGENTS.md:39). This changeset file should be removed from the PR so the automation can generate the correct entry.
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch

The stale-URL guard added in the previous round never ran on the path
that actually fires. wavesurfer's load() emits `error` synchronously
before it rejects, and the event carries no URL, so the listener
always won the race and dropped the current file into the native
fallback. Load failures now go through the load promise, which knows
which URL failed, and the event listener is left to the media element
errors it is the only source for.

The fallback also kept the failed file attached to the native element,
so a later file that decoded fine played behind the restored waveform
with no way to stop it. The element is released when the waveform
recovers.

Two smaller consequences of the previous round: the stream teardown
paused the media element, which dispatches a `pause` the app never
caused, where `load()` stops playback on its own; and autoplay was
snapshotted at attach time instead of read when the manifest parses.

Also clear the subtitle handlers when create_waveform() replaces the
instance, and keep the HLS console output for fatal errors only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

A hand-authored .changeset/*.md file is included, but repository policy requires leaving changesets to the automated Action generation.

Review details

Files not reviewed (1)

  • pnpm-lock.yaml: Generated file

Suppressed comments (1)

.changeset/icy-cups-join.md:5

  • Repository policy says not to add hand-authored .changeset/*.md files because an Action generates them from the PR title, and a manual one will override the changelog entry. This changeset should be removed so the bot-generated one is used instead (AGENTS.md:39).
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch
  • Files reviewed: 12/13 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Two reviews of the streaming work converged on the wavesurfer error
routing, and one found that the HLS teardown can discard the source
that replaces a stream.

The `error` listener was gated on a count of in-flight loads, on the
premise that a load failure reaches the load promise and the event
handles the rest. A media element error reaches neither. wavesurfer
reads the duration by waiting for `loadedmetadata`, an errored element
never fires it, and that wait has no reject path, so the load hangs:
the promise never rejects and the listener returns on the counter. A
file whose bytes fetch but which the element cannot parse therefore
left a blank waveform with no fallback, where main fell back to the
native player. Worse, the decrement lived in that hung promise's
`finally`, so the first such file disabled the listener for the rest of
the mount. Errors are now separated by type: `MediaError` is a media
element error and the only signal for one, everything else belongs to
the load promise, which knows which URL failed. The counter is gone.

The stream effect and the effect assigning a plain file both wrote the
native element's source, and correctness rested on hls.js skipping its
own cleanup when `src` no longer holds its object URL. That guard reads
`(media.querySelector('source') || media).src`, and when hls.js
attaches through a ManagedMediaSource (Safari 17+, iOS 17+, the default
whenever the constructor exists) it keeps its object URL in a `source`
child, so the guard matches and the detach clears the element even
though `src` moved on. With `show_recording_waveform=False`, a stream
followed by a file left the player empty. The two effects are now one,
since they own one resource, and Svelte runs a teardown before the next
body, so the release and the next assignment cannot happen out of
order. `gr.Video` is not exposed: `VideoPreview` keys the player on the
URL, so its teardown always runs on an element being discarded, and its
other consumers never stream.

`play()` also gets a `catch` at the three streaming call sites. The
teardown's `load()` rejects a pending play promise by spec, so every
stream teardown logged an unhandled rejection.

All three tests were confirmed to fail before the fix.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The PR adds a hand-authored .changeset file (against repo rules) and the updated Audio player effect still leaves the native file src attached when the value is cleared in the no-waveform path.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Files not reviewed (1)

  • pnpm-lock.yaml: Generated file

Suppressed comments (1)

.changeset/icy-cups-join.md:8

  • This repository’s PR rules prohibit hand-authoring .changeset/*.md files because a GitHub Action generates them from the PR title; committing one can silently override the intended changelog entry (see AGENTS.md:39). Please remove this changeset file from the PR.
---
"@gradio/audio": patch
"@gradio/utils": patch
"@gradio/video": patch
"gradio": patch
---

fix:Fix streaming playback and HLS lifetime in `gr.Audio` and `gr.Video`
  • Files reviewed: 12/13 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +311 to 313
if (!use_waveform) {
media.src = url;
}
A media element error carries no URL, so the listener attributes it to
whatever is attached. The residue is that an error belonging to a file
the value has moved past can still land on its successor: wavesurfer
keeps the old file on its element for the whole of the next file's
blob fetch, and the load effect has already cleared the flag by then,
so the handler runs to the end and points the native element at a file
that is loading fine. Nothing reset it afterwards, because the waveform
branch is hidden rather than unmounted while the fallback is active, so
the component stayed on the bare native element until the value
changed. The load promise's success branch is the only place that still
knows which URL loaded, so it now releases a fallback that belongs to
the URL it just loaded. A hung load never resolves, so it cannot
release the fallback it caused.

The `loaded` action in js/video/shared/utils.ts awaits `node.play()`
with nothing attached to the promise, on the same element the stream
effect owns, so it had the unhandled rejection this PR just fixed at
the other three call sites.

`Video.test.ts` stubbed `play()` with a `vi.fn()` returning undefined,
which now breaks on the `catch` the callers attach. It only passed
because `loadSource` is mocked and the manifest never parses.

Also two comment corrections: the media element error path cannot see
wavesurfer's `new Error("Media error")` substitute, since a real
element has its `error` set before the event fires, and only the stream
branches of the source-owning effect need a teardown.

The new test was confirmed to fail before the fix.
Streaming `gr.Video` played on its own regardless of the prop, because
the manifest callback called `play()` unconditionally. The call predates
this PR, but streamed video rendered nothing at all until the payload
shape was fixed here, so nothing about it was observable and there is no
behaviour anyone can be relying on. The element already carries the
`autoplay` attribute and `use:loaded` already acts on the prop, so the
callback was the one place left that could override it. It is now gated
the way the audio player's is.

The rest are review follow-ups in the same code:

Non-fatal HLS errors were dropped entirely. Both pre-PR copies logged
every error, and the non-fatal details are the symptom of a stream that
connects and plays nothing, which is the bug class this PR is about.
They go to `console.debug`, which keeps them out of the way at a
one-second buffer while leaving them reachable.

wavesurfer substitutes `new Error("Media error")` when the element
reports no error object. A real element sets `error` before firing, so
that should be unreachable, but the guard added here dropped it into
the same gap the guard exists to close: no fallback, no rejection, and
nothing in the console. Both shapes now route to the handler.

The non-MSE stream branch called `play()` although the element carries
`autoplay={waveform_settings.autoplay}`, so assigning the source is
already enough. The remaining three call sites go through `play_media`,
which absorbs the teardown's `AbortError` and an autoplay-policy block
and lets anything else reach the console, rather than each swallowing
every rejection.

`stream_output`'s payload shape was only pinned on the `value is None`
call, which returns before any chunk is converted. It is now pinned on
the chunk path as well.

Two test-hygiene fixes: both streaming suites restored their spies
before `cleanup()`, which is what unmounts and therefore what runs the
teardowns, so nothing could assert on unmount behaviour; and the
ManagedMediaSource stub replaced `destroy` without calling through,
leaving a live instance behind for the rest of the run.

The autoplay and substitute-error tests were confirmed to fail before
the fix.
The test added in the previous commit fed a real mp4 through the chunk
path, so it converted with ffmpeg and read the duration with ffprobe.
ffprobe died with SIGSEGV on the converted file on CI's build, and no
other test in the suite depends on either tool. What the test is for is
the payload shape and the chunk it returns, so it now passes a `.ts`
path, which needs no conversion, and patches the duration lookup.
`stream_output` built the `.ts` path with `value.replace(".mp4", ".ts")`,
which rewrites every occurrence, so a chunk under a directory whose name
ends in .mp4 was handed to ffmpeg as a path in a directory that does not
exist. `Path(value).with_suffix(".ts")` is what `Audio` already uses for
the same job.

`play_media`'s doc comment said unexpected rejections reach the console
while the code logged them at `console.debug`, which both Chrome and
Firefox hide by default, so a `NotSupportedError` produced the silent
failure the helper exists to prevent. It logs at `warn` now, and wraps
the call in `Promise.resolve`, since `play()` predates promises and can
return nothing in an old or embedded implementation.

The source-owning effect's comment explained the missing teardown on
the file branch with "the player unmounts with the value", which a
reader cannot check from that file. It now names the two parents that
make it true: `StaticAudio`'s `{#if value !== null}` and
`InteractiveAudio`'s else branch.

The path test was confirmed to fail before the fix.
@hysts
hysts marked this pull request as ready for review September 3, 2026 10:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audio(streaming=True): a second streaming run never plays

3 participants