Skip to content

@remotion/player: support preservesPitch prop#7277

Open
mvanhorn wants to merge 2 commits into
remotion-dev:mainfrom
mvanhorn:feat/7070-player-preserves-pitch
Open

@remotion/player: support preservesPitch prop#7277
mvanhorn wants to merge 2 commits into
remotion-dev:mainfrom
mvanhorn:feat/7070-player-preserves-pitch

Conversation

@mvanhorn
Copy link
Copy Markdown

@mvanhorn mvanhorn commented May 5, 2026

Summary

<Player> and <Thumbnail> now accept a preservesPitch?: boolean prop that controls whether audio pitch is preserved when playbackRate is changed.

Default is true, matching HTMLMediaElement.preservesPitch's browser default. Existing consumers see no behavior change.

Why this matters

#7070 (dvschuyl) describes a playback speed picker built on <Player> where audio keeps its original pitch when sped up or slowed down. Browsers default to pitch preservation, but <Player> never sets the property, so users can't opt out for tape-style "chipmunk" or "deep voice" effects. @jtheis85 confirmed the same ask in the Remotion Discord.

HTMLMediaElement.preservesPitch is a standard browser property supported in every modern browser. Surfacing it as a Player prop is the minimal change.

Implementation

The prop flows through the existing PlaybackRateContext (extended with preservesPitch and setPreservesPitch) so both Player-managed and Studio-managed compositions get the same behavior. use-media-playback.ts applies it to the media element in the same useLayoutEffect that sets playbackRate, on the same dependency array, which keeps the assignments in lock-step.

Testing

Validator unit tests cover:

  • preservesPitch={true}, {false}, and undefined all render
  • non-boolean values throw with the same shape as playbackRate validation

HTMLMediaElement.preservesPitch is a browser-native audio effect and does not have a visual representation, so no screenshot/video evidence applies. The existing <Thumbnail> mock and the test-only wrap-sequence-context helper were updated to satisfy the new context type.

Fixes #7070

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bugs Ready Ready Preview, Comment May 16, 2026 0:26am
remotion Error Error May 16, 2026 0:26am

Request Review

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Important

Small, well-scoped change. Two minor consistency nits on the validator, plus a couple of non-blocking observations.

The implementation is clean: the prop threads through PlaybackRateContext, lands in the same useLayoutEffect as playbackRate in use-media-playback.ts, and defaults to true so existing consumers see no behavior change. Tests cover the expected shapes.

Non-blocking observations (not inline because they concern areas outside the diff):

  • The PR description mentions <Thumbnail> accepts preservesPitch, but Thumbnail.tsx only hardcodes the context value — it does not expose the prop. This is functionally fine (thumbnails have no audio playback), but the PR description is slightly inaccurate.
  • @remotion/media's <Audio> / <Video> use a WebCodecs-based pipeline (packages/media/src/video/video-for-preview.tsx) and do not consume preservesPitch. Only the native-element path (<Html5Audio>, <Html5Video>, and the core <Audio>/<Video> through useMediaPlayback) will honor the prop. Worth a docs note that the prop applies to native HTMLMediaElement playback.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +1 to +9
export const validatePreservesPitch = (preservesPitch: unknown) => {
if (preservesPitch === undefined) {
return;
}

if (typeof preservesPitch !== 'boolean') {
throw new Error(
`'preservesPitch' must be a boolean but got '${typeof preservesPitch}' instead`,
);
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.

Two consistency nits with the rest of the Player validators (controls, autoPlay, loop, showVolumeControls, etc. in Player.tsx:270-327):

  1. Other boolean-prop messages read "must be a boolean or undefined but got ...". The early return on undefined makes this technically equivalent, but the user-facing message is inconsistent.
  2. Other validators throw TypeError; this throws plain Error.

If you adopt the suggestion, update the matching regex in validate-prop.test.tsx:244 to /'preservesPitch' must be a boolean or undefined but got 'string' instead/.

Suggested change
export const validatePreservesPitch = (preservesPitch: unknown) => {
if (preservesPitch === undefined) {
return;
}
if (typeof preservesPitch !== 'boolean') {
throw new Error(
`'preservesPitch' must be a boolean but got '${typeof preservesPitch}' instead`,
);
export const validatePreservesPitch = (preservesPitch: unknown) => {
if (preservesPitch === undefined) {
return;
}
if (typeof preservesPitch !== 'boolean') {
throw new TypeError(
`'preservesPitch' must be a boolean or undefined but got '${typeof preservesPitch}' instead`,
);
}
};

Comment thread packages/player/src/Player.tsx Outdated
Comment on lines +370 to +372
useEffect(() => {
setCurrentPreservesPitch(preservesPitch);
}, [preservesPitch]);
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.

Minor: this duplicates the shape of the playbackRate sync effect directly above. Not blocking, but could be folded into a single effect since both props mirror external state into local state the same way:

useEffect(() => {
  setCurrentPlaybackRate(playbackRate);
  setCurrentPreservesPitch(preservesPitch);
}, [playbackRate, preservesPitch]);

Feel free to keep the split if you prefer one-prop-per-effect for clarity.

@mvanhorn
Copy link
Copy Markdown
Author

mvanhorn commented May 5, 2026

Updated in e01085f to match the peer-validator convention from Player.tsx:270-327: TypeError and must be a boolean or undefined but got 'X' instead. Test regex updated.

Kept the two useEffect hooks split per your "feel free to keep the split" note. One-prop-per-effect reads cleaner here since each dependency array documents one prop's flow into local state.

Re: the non-blocking docs observations, agreed. <Thumbnail> doesn't expose the prop and the @remotion/media WebCodecs path doesn't honor it. Will tighten the PR description to clarify this applies only to native HTMLMediaElement playback (<Audio>/<Video> via useMediaPlayback).

Copy link
Copy Markdown
Member

@JonnyBurger JonnyBurger left a comment

Choose a reason for hiding this comment

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

Please re-implement this for preservePitch to be a prop for OffthreadVideo, Html5Audio, Html5Video, not for the Player. It doesn't belong there.

@mvanhorn
Copy link
Copy Markdown
Author

mvanhorn commented May 7, 2026

Re-implemented per your direction (force-pushed 2900257, single clean commit on top of base):

  • preservesPitch?: boolean is now a per-component prop on OffthreadVideo, Html5Video/Video, and Html5Audio/Audio. It threads through to HTMLMediaElement.preservesPitch via the same effect that already syncs playbackRate, so when the prop is omitted the browser default applies and nothing is written.
  • The Player-level prop, the PlaybackRateContext field, the player-level validator, and Thumbnail.tsx's default are all reverted - Player, Thumbnail, TimelineContext, and the docs/api.mdx entry are back to their pre-PR shape.
  • New per-component validator added in validate-media-props.ts matching the existing peer-validator message format ('preservesPitch' must be a boolean or undefined but got 'X' instead).
  • Tests cover default, true, false, and invalid input on each component plus the runtime sync to HTMLMediaElement.preservesPitch. All 64 tests in @remotion/core pass locally.
  • Docs moved from player/api.mdx to html5-audio.mdx, html5-video.mdx, and offthreadvideo.mdx.

props: {
volume: VolumeProp | undefined;
playbackRate: number | undefined;
preservesPitch?: boolean | undefined;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

don't mix ? and | undefined, just use undefined

Comment thread packages/docs/docs/html5-audio.mdx Outdated

Controls whether the audio pitch is preserved when [`playbackRate`](#playbackrate) is not `1`.

Accepts `true`, `false`, or `undefined`. Default: `undefined`, which uses the browser default for [`HTMLMediaElement.preservesPitch`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preservesPitch).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Disclose that this does not work during render. toneFrequency should be used for that

Comment thread packages/docs/docs/html5-video.mdx Outdated

Controls whether the audio pitch is preserved when [`playbackRate`](#playbackrate) is not `1`.

Accepts `true`, `false`, or `undefined`. Default: `undefined`, which uses the browser default for [`HTMLMediaElement.preservesPitch`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preservesPitch).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Disclose that this does not work during render. toneFrequency should be used for that

Comment thread packages/docs/docs/offthreadvideo.mdx Outdated

Controls whether the audio pitch is preserved when [`playbackRate`](#playbackrate) is not `1`.

Accepts `true`, `false`, or `undefined`. Default: `undefined`, which uses the browser default for [`HTMLMediaElement.preservesPitch`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preservesPitch).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Disclose that this does not work during render. toneFrequency should be used for that

@mvanhorn
Copy link
Copy Markdown
Author

Thanks @JonnyBurger. Pushed 7b63240:

  • Renamed preservesPitch to preservePitch (matching your spelling) across Audio, Video, OffthreadVideo and their internal Html5Audio/Html5Video variants. No preservesPitch/preservePitch references in packages/player/ (the original PR title was misleading; the prop was already only on the media components).
  • AudioForRendering/VideoForRendering no longer assign preservesPitch on the DOM element during render. Docs updated on html5-audio.mdx, html5-video.mdx, offthreadvideo.mdx: preservePitch is preview-only; toneFrequency remains the render-time control.
  • Default is true, matching HTMLMediaElement.preservesPitch. Validator updated; tests updated.

Verification: my sandbox could not reach the registry to run pnpm install, so I am relying on upstream CI to confirm. If you want me to flip any specific behavior (e.g., keep the undefined-as-untouched semantic) just let me know.

@JonnyBurger
Copy link
Copy Markdown
Member

@mvanhorn Tests are failing and the next version is now 4.0.462.
Otherwise, looks good to me!

mvanhorn added 2 commits May 15, 2026 17:11
`preservesPitch?: boolean` is now a per-component prop on OffthreadVideo,
Html5Video (Video), and Html5Audio (Audio), threading through to the
matching `HTMLMediaElement.preservesPitch` setter via the same effect that
syncs `playbackRate`. Default behavior is unchanged: when the prop is
omitted, the browser default applies and nothing is written.

The earlier approach (a Player-level prop wired through PlaybackRateContext
and the global useMediaPlayback consumer) is dropped per maintainer feedback.
Player, Thumbnail, TimelineContext, and the player-level validator are back
to their pre-PR state. validate-media-props gains a boolean validator that
matches the existing peer-validator message format.

Tests cover default, true, false, and invalid input on each of the three
component validators plus the runtime sync to HTMLMediaElement.preservesPitch.

Docs: removed the entry from packages/docs/docs/player/api.mdx and added
matching entries to html5-audio.mdx, html5-video.mdx, and offthreadvideo.mdx.

Fixes remotion-dev#7070
…w-only

@JonnyBurger asked to expose preservePitch on Audio/Video/OffthreadVideo,
matching his preferred spelling. The PR already touched the media components
(no Player wiring), so the change is a rename to preservePitch plus:

- Default preservePitch=true (matches HTMLMediaElement.preservesPitch default)
- Render path no longer assigns preservesPitch on the DOM element; toneFrequency
  remains the render-time pitch control
- Docs note that preservePitch only affects preview playback; render uses
  toneFrequency

Tests updated to use the new prop name and the true default. Verification
gap: sandbox could not run pnpm install (registry unreachable); upstream CI
will catch any regression.
@mvanhorn mvanhorn force-pushed the feat/7070-player-preserves-pitch branch from 7b63240 to f0b96f3 Compare May 16, 2026 00:25
@mvanhorn
Copy link
Copy Markdown
Author

Rebased onto latest main (force-pushed feat/7070-player-preserves-pitch — just the 2 PR commits clean on top, versions now reflect 4.0.462 from the upstream bump).

On the tests: root cause is that NODE_ENV=test sets getRemotionEnvironment().isRendering true, so the test render path goes through AudioForRendering.tsx which strips preservePitch ({preservePitch: _preservePitch, ...nativeProps}) by design - the layoutEffect that sets mediaRef.current.preservesPitch lives in AudioForPreview / useMediaPlayback, consistent with the "preview-only" scope from the last commit message.

Two options for landing the tests:

  1. Wrap each render in a RemotionEnvironmentContext.Provider that forces isRendering: false, so the preview path runs in tests.
  2. Drop the in-test assertion of ref.current.preservesPitch and assert at a level higher up (e.g. that useMediaPlayback was called with the right prop, or move to a Playwright/integration test).

Happy to push (1) as a follow-up commit if you confirm that's the shape you want - it's a small test-context wrapper and a few rewrites of the asserts. Or just lmk if you'd prefer to land this without the test fixture and revisit it separately.

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.

preservesPitch-support for React Player component

2 participants