Reactで、声の高さを変えずに音声や動画の再生速度を変える方法

​​React​Playwright,TypeScript,Web Audio API

React で音声や動画の再生速度を変えるには、audio 要素や video 要素の playbackRate に倍率を入れます。声の高さは、preservesPitch が true(初期値)のあいだは変わりません。playbackRate は JSX の props では渡せないので、ref で要素を受け取って useEffect の中で入れます。preservesPitch も同じように入れます。音声を倍速再生やゆっくり再生で聞けるプレーヤーを作るときに使えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。音声は ffmpeg で作った12秒の正弦波(440Hz、Opus の WebM)で、ブラウザは Playwright で画面の幅を 375px にして開いています。Firefox と Safari、video 要素、人の声を録音した音声は試していません。

速さと声の高さを切り替えるコンポーネント

速さのボタンを6つと、「声の高さを変えない」のチェックボックスを並べました。選んだ値は state に持ち、useEffect で audio 要素に入れます。

tsx
import { useEffect, useRef, useState } from 'react';

const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];

type Props = { src: string };

export function SpeedPlayer({ src }: Props) {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [rate, setRate] = useState(1);
  const [keepPitch, setKeepPitch] = useState(true);

  // 選んだ速さを audio 要素に入れる。defaultPlaybackRate にも入れておくと、src が変わっても戻らない
  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return;
    audio.defaultPlaybackRate = rate;
    audio.playbackRate = rate;
  }, [rate]);

  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return;
    audio.preservesPitch = keepPitch;
  }, [keepPitch]);

  return (
    <div className="player">
      {/* 操作部品のメニューなど、ほかの場所で速さが変わったときは onRateChange で表示を合わせる */}
      <audio
        ref={audioRef}
        src={src}
        controls
        preload="metadata"
        onRateChange={(e) => setRate(e.currentTarget.playbackRate)}
      />
      <div className="rates" role="group" aria-label="再生速度">
        {RATES.map((r) => (
          <button key={r} type="button" aria-pressed={r === rate} onClick={() => setRate(r)}>
            {r}倍
          </button>
        ))}
      </div>
      <label className="pitch">
        <input type="checkbox" checked={keepPitch} onChange={(e) => setKeepPitch(e.target.checked)} />
        声の高さを変えない
      </label>
    </div>
  );
}
音声のコントロールの下に「0.5倍」から「2倍」までの6つのボタンが並び、「1.5倍」のボタンだけ枠と文字が水色の太字になっている。その下に、チェックが入った「声の高さを変えない」のチェックボックスがある
1.5倍で再生したあと止めたところ。選んでいるボタンは aria-pressed で色を変えています

playbackRate は、ふつうの速さを 1.0 とした倍率です(MDN の playbackRate の説明)。preservesPitch は、速さを変えたときに音の高さを補正するかどうかを決める真偽値で、初期値は true です(MDN の preservesPitch の説明)。どちらも audio と video に共通の HTMLMediaElement のプロパティなので、video 要素でも同じように書けます。

preservesPitch は、MDN の互換性の表では Chrome 86、Firefox 101、Safari 17.2 から使えます。それより前の Safari には、webkit を付けた名前(webkitPreservesPitch)があると書かれています。TypeScript の DOM の型定義(typescript 7.0.2)には preservesPitch が入っていて、型の追加なしでコンパイルできました。

JSX に playbackRate を書いても速さは変わらない

<audio playbackRate={2} /> のように props で渡す書き方は使えません。React はこれを要素のプロパティとして扱わないためです。検証環境で試すと、playbackRate は 1 のままで、要素には小文字の playbackrate という HTML の属性が付いただけでした。開発用ビルドでは、コンソールに次のエラーが出ます。

text
props に書いた場合 {"playbackRate":1,"attributes":["src","controls","playbackrate"]}
React does not recognize the `playbackRate` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `playbackrate` instead. If you accidentally passed it from a parent component, remove it from the DOM element.

TypeScript でも、audio 要素の props に playbackRate がないため、型エラーになります。そこで、上のコンポーネントでは ref で受け取った要素に、useEffect の中から値を入れています。

440Hz の音で、声の高さが変わらないことを確かめる

速さと preservesPitch の組み合わせごとに、出ている音の高さを測りました。audio 要素の音を Web Audio API の AnalyserNode につなぎ、いちばん強く出ている周波数を読んでいます。前の設定の音が混ざらないように、組み合わせごとにページを開き直し、速さのボタンを押して(false のときはチェックも外して)から再生しました。

速さごとに2本の棒が並ぶグラフ。水色の preservesPitch: true はどの速さでも439Hz、灰色の false は0.5倍で221Hz、2倍で880Hzと、速さに比例して高くなる
速さごとに測った音の高さ(Hz)。元の音は440Hzです

preservesPitch が true のときは、0.5倍でも2倍でも 439Hz のままでした(周波数を一定の幅で区切って読んでいるため、440Hz の音は 439 と出ています)。false にすると、0.5倍で 221Hz、2倍で 880Hz になり、音の高さが速さに比例して変わりました。

text
速さ 2 倍・声の高さを変えない true {"playbackRate":2,"preservesPitch":true,"peakHz":[439,439,439],"advPerSec":2}
速さ 2 倍・声の高さを変えない false {"playbackRate":2,"preservesPitch":false,"peakHz":[880,880,880],"advPerSec":2}

peakHz は再生を始めて1秒後から0.5秒ごとに3回測った値、advPerSec は1秒あたりに進んだ再生位置の秒数です。音の高さが変わらない場合でも、再生位置は2倍の速さで進んでいます。HTML の仕様でも、playbackRate が 1.0 でなく、preservesPitch が true なら、元の音の高さを保つように補正すると決められています。

再生中にチェックを外した場合も試しました。2倍で再生している途中でチェックを外すと、1秒後には 439Hz から 880Hz に変わっていて、止めて再生し直す必要はありませんでした。

text
再生中(2倍・チェックあり) 439
再生中にチェックを外して1秒後 [880,false]

src を替えると速さが1倍に戻るので、defaultPlaybackRate にも入れる

上のコンポーネントでは、playbackRate と一緒に defaultPlaybackRate にも同じ値を入れています。これは、プレイリストのように src を替えたとき、速さが元に戻らないようにするためです。src が変わると、ブラウザは要素を読み込み直し、その途中で playbackRate を defaultPlaybackRate の値に戻します(HTML の仕様の、メディア要素を読み込む手順)。

比べるために、playbackRate だけを入れるコンポーネントも作りました。2倍を選んでから src を替えると、次のようになりました。出力の先頭の naive は、こちらで測った結果です。チェックボックスがないので、checked は null になっています。

text
naive 2倍にしたあと {"playbackRate":2,"defaultPlaybackRate":1,"preservesPitch":true,"pressed":"2倍","checked":null,"src":"tone.webm"}
naive src を変えたあと {"playbackRate":1,"defaultPlaybackRate":1,"preservesPitch":true,"pressed":"2倍","checked":null,"src":"tone2.webm"}

playbackRate だけを入れた場合は、src を替えたあと 1 に戻りました。画面では「2倍」のボタンが選ばれたままなので、表示と実際の速さが食い違います。state の rate は変わっていないため、useEffect も動き直しません。

defaultPlaybackRate にも入れた SpeedPlayer では、src を替えたあとも 2 のままでした。preservesPitch は読み込み直しても戻らず、チェックを外した状態(false)が続いていました。次の出力の先頭の switch は、SpeedPlayer で測った結果です。

text
switch 2倍にしたあと {"playbackRate":2,"defaultPlaybackRate":2,"preservesPitch":false,"pressed":"2倍","checked":false,"src":"tone.webm"}
switch src を変えたあと {"playbackRate":2,"defaultPlaybackRate":2,"preservesPitch":false,"pressed":"2倍","checked":false,"src":"tone2.webm"}

ブラウザ標準のメニューで変えた速さを、onRateChange で画面に合わせる

Chromium では、controls 属性で出るブラウザ標準のコントロールでも、︙のメニューから速さを変えられます。こうして要素の側で速さが変わると、ratechange イベントが起きます(MDN の ratechange イベントの説明)。React では、audio や video に onRateChange を書けば受け取れます(React の公式リファレンスにある、共通の props の一覧)。上のコンポーネントでは、そこで state を要素の値に合わせています。

メニューで 1.5 を選んだところ、playbackRate が 1.5 になり、画面でも「1.5倍」のボタンが選ばれた表示に変わりました。ほかのコードから audio.playbackRate = 1.25 と入れた場合も、同じように「1.25倍」に変わりました。playbackRate = 1.25 のほうは、再生中にチェックを外したページで続けて試したので、preservesPitch は false のままです。出力の先頭の「操作部品」は、ブラウザ標準のコントロールのことです。

text
操作部品のメニューで 1.5 を選んだあと {"playbackRate":1.5,"defaultPlaybackRate":1.5,"preservesPitch":true,"pressed":"1.5倍","checked":true,"src":"tone.webm"}
外から playbackRate = 1.25 にしたあと {"playbackRate":1.25,"defaultPlaybackRate":1.25,"preservesPitch":false,"pressed":"1.25倍","checked":false,"src":"tone.webm"}

defaultPlaybackRate も同じ値になっているのは、onRateChange で state が変わり、さきほどの useEffect が動いて入れ直したためです。

入れられる速さの範囲

playbackRate に、ブラウザが対応していない値を入れると NotSupportedError の例外が出ます(HTML の仕様の playbackRate の説明)。どこまで対応しているかは、仕様では決まっていません。検証環境の Chromium では、0.0625 と 16 は入り、0.05、17、-1 では例外が出て、値は前の 1 のままでした。0 は例外なく入りました。

text
playbackRate = 0.05 {"ok":false,"error":"NotSupportedError: Failed to set the 'playbackRate' property on 'HTMLMediaElement': The provided playback rate (0.05) is not in the supported playback range.","playbackRate":1}
playbackRate = 17 {"ok":false,"error":"NotSupportedError: Failed to set the 'playbackRate' property on 'HTMLMediaElement': The provided playback rate (17) is not in the supported playback range.","playbackRate":1}

入力欄などで好きな速さを入れられるようにする場合は、playbackRate に入れる処理を try と catch で囲み、例外を受け止めます。

また、MDN の playbackRate の説明には、速すぎたり遅すぎたりして役に立たない範囲では音が消されると書かれていて、例として Firefox(Gecko)は 0.25〜4 の外で音を消すとあります。この記事の6つのボタンは、すべてその範囲の中に収まるように選んでいます。

​Posted by 86