Expand speech playback speed controls

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-29 20:27:58 -04:00
parent 347ab73d87
commit 53db71d0a1
3 changed files with 64 additions and 23 deletions

View File

@@ -245,10 +245,18 @@ mod tests {
fn persisted_speed_round_trips_and_rejects_invalid_values() {
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join(SETTINGS_FILE);
save_to_path(&path, 1.25).expect("save");
assert_eq!(load_from_path(&path).expect("load"), 1.25);
save_to_path(&path, playback_speed_dsp::MIN_PLAYBACK_SPEED).expect("save minimum");
assert_eq!(
load_from_path(&path).expect("load minimum"),
playback_speed_dsp::MIN_PLAYBACK_SPEED
);
save_to_path(&path, playback_speed_dsp::MAX_PLAYBACK_SPEED).expect("save maximum");
assert_eq!(
load_from_path(&path).expect("load maximum"),
playback_speed_dsp::MAX_PLAYBACK_SPEED
);
std::fs::write(&path, br#"{"speed":2.0}"#).expect("invalid fixture");
std::fs::write(&path, br#"{"speed":4.1}"#).expect("invalid fixture");
assert!(load_from_path(&path).is_err());
}

View File

@@ -5,9 +5,9 @@
//! the exact same DSP boundary.
/// Slowest supported generated-speech playback speed.
pub const MIN_PLAYBACK_SPEED: f32 = 0.75;
pub const MIN_PLAYBACK_SPEED: f32 = 0.25;
/// Fastest supported generated-speech playback speed.
pub const MAX_PLAYBACK_SPEED: f32 = 1.5;
pub const MAX_PLAYBACK_SPEED: f32 = 4.0;
/// Default generated-speech playback speed.
pub const DEFAULT_PLAYBACK_SPEED: f32 = 1.0;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getTtsPlaybackSpeed,
setTtsPlaybackSpeed,
@@ -12,12 +12,15 @@ import {
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
const DEFAULT_SPEED = 1;
const MIN_SPEED = 0.75;
const MAX_SPEED = 1.5;
const SLIDER_MIN_SPEED = 0.5;
const SLIDER_MAX_SPEED = 2;
const INPUT_MIN_SPEED = 0.25;
const INPUT_MAX_SPEED = 4;
const SPEED_STEP = 0.05;
export function SpeechPlaybackSettings() {
const [speed, setSpeed] = useState(DEFAULT_SPEED);
const [speedInput, setSpeedInput] = useState(String(DEFAULT_SPEED));
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const active = useRef(true);
@@ -26,6 +29,11 @@ export function SpeechPlaybackSettings() {
const flushPromise = useRef<Promise<void> | null>(null);
const hasLocalIntent = useRef(false);
const setDisplayedSpeed = useCallback((nextSpeed: number) => {
setSpeed(nextSpeed);
setSpeedInput(String(nextSpeed));
}, []);
useEffect(() => {
active.current = true;
let loadActive = true;
@@ -40,7 +48,7 @@ export function SpeechPlaybackSettings() {
flushPromise,
hasLocalIntent,
},
{ setSpeed },
{ setSpeed: setDisplayedSpeed },
savedSpeed,
);
}
@@ -61,7 +69,7 @@ export function SpeechPlaybackSettings() {
hasLocalIntent,
});
};
}, []);
}, [setDisplayedSpeed]);
const commitSpeed = (nextSpeed: number) => {
commitPlaybackSpeed(
@@ -72,11 +80,20 @@ export function SpeechPlaybackSettings() {
flushPromise,
hasLocalIntent,
},
{ persist: setTtsPlaybackSpeed, setError, setSpeed },
{ persist: setTtsPlaybackSpeed, setError, setSpeed: setDisplayedSpeed },
nextSpeed,
);
};
const commitTypedSpeed = () => {
const nextSpeed = Number(speedInput);
if (Number.isFinite(nextSpeed)) {
commitSpeed(nextSpeed);
} else {
setSpeedInput(String(speed));
}
};
return (
<SettingsOptionGroup>
<SettingsOptionRow className="items-start">
@@ -89,17 +106,31 @@ export function SpeechPlaybackSettings() {
>
Speech playback speed
</label>
<p className="text-sm font-normal text-muted-foreground">
Changes generated speech playback without changing voice pitch.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
aria-live="polite"
className="min-w-10 text-right text-sm tabular-nums"
>
{speed.toFixed(2)}x
</span>
<div className="flex items-center gap-1">
<input
aria-label="Speech playback speed value"
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-right text-sm tabular-nums"
disabled={!loaded}
max={INPUT_MAX_SPEED}
min={INPUT_MIN_SPEED}
onBlur={commitTypedSpeed}
onChange={(event) => setSpeedInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitTypedSpeed();
event.currentTarget.blur();
}
}}
step={SPEED_STEP}
type="number"
value={speedInput}
/>
<span aria-hidden="true" className="text-sm">
×
</span>
</div>
<Button
disabled={!loaded || speed === DEFAULT_SPEED}
onClick={() => commitSpeed(DEFAULT_SPEED)}
@@ -116,10 +147,12 @@ export function SpeechPlaybackSettings() {
className="mt-3 w-full accent-primary"
disabled={!loaded}
id="speech-playback-speed"
max={MAX_SPEED}
min={MIN_SPEED}
max={SLIDER_MAX_SPEED}
min={SLIDER_MIN_SPEED}
onBlur={(event) => commitSpeed(Number(event.currentTarget.value))}
onChange={(event) => setSpeed(Number(event.currentTarget.value))}
onChange={(event) =>
setDisplayedSpeed(Number(event.currentTarget.value))
}
onKeyUp={(event) => commitSpeed(Number(event.currentTarget.value))}
onPointerUp={(event) =>
commitSpeed(Number(event.currentTarget.value))