Allow for player speed changes as well as native voice speed changes. Kokoro for example swallows parts of words when asked to go faster than maybe 1.3x. This fixes that, by simply speeding up the player instead. Since we're already using the html5 version of howler, by default it will pitch-correct the audio and it sounds quite good!
This commit is contained in:
parent
e6cd1d0264
commit
89833d596b
4 changed files with 141 additions and 36 deletions
|
|
@ -5,56 +5,106 @@ import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
|||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const SpeedControl = ({ setSpeedAndRestart }: {
|
||||
export const SpeedControl = ({
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart
|
||||
}: {
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
}) => {
|
||||
const { voiceSpeed } = useConfig();
|
||||
const [localSpeed, setLocalSpeed] = useState(voiceSpeed);
|
||||
const { voiceSpeed, audioPlayerSpeed } = useConfig();
|
||||
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
|
||||
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);
|
||||
|
||||
// Sync local speed with global state
|
||||
// Sync local speeds with global state
|
||||
useEffect(() => {
|
||||
setLocalSpeed(voiceSpeed);
|
||||
setLocalVoiceSpeed(voiceSpeed);
|
||||
}, [voiceSpeed]);
|
||||
|
||||
// Handler for slider change (updates local state only)
|
||||
const handleSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalSpeed(parseFloat(event.target.value));
|
||||
useEffect(() => {
|
||||
setLocalAudioSpeed(audioPlayerSpeed);
|
||||
}, [audioPlayerSpeed]);
|
||||
|
||||
// Handler for voice speed slider change (updates local state only)
|
||||
const handleVoiceSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalVoiceSpeed(parseFloat(event.target.value));
|
||||
}, []);
|
||||
|
||||
// Handler for slider release
|
||||
const handleSpeedChangeComplete = useCallback(() => {
|
||||
if (localSpeed !== voiceSpeed) {
|
||||
setSpeedAndRestart(localSpeed);
|
||||
// Handler for audio player speed slider change (updates local state only)
|
||||
const handleAudioSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalAudioSpeed(parseFloat(event.target.value));
|
||||
}, []);
|
||||
|
||||
// Handler for voice speed slider release
|
||||
const handleVoiceSpeedChangeComplete = useCallback(() => {
|
||||
if (localVoiceSpeed !== voiceSpeed) {
|
||||
setSpeedAndRestart(localVoiceSpeed);
|
||||
}
|
||||
}, [localSpeed, voiceSpeed, setSpeedAndRestart]);
|
||||
}, [localVoiceSpeed, voiceSpeed, setSpeedAndRestart]);
|
||||
|
||||
// Handler for audio player speed slider release
|
||||
const handleAudioSpeedChangeComplete = useCallback(() => {
|
||||
if (localAudioSpeed !== audioPlayerSpeed) {
|
||||
setAudioPlayerSpeedAndRestart(localAudioSpeed);
|
||||
}
|
||||
}, [localAudioSpeed, audioPlayerSpeed, setAudioPlayerSpeedAndRestart]);
|
||||
|
||||
// Display the currently active speed (prioritize audio player speed if different from 1.0)
|
||||
const displaySpeed = localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed;
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<span>{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
|
||||
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Native Model Speed */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Native model speed</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localVoiceSpeed}
|
||||
onChange={handleVoiceSpeedChange}
|
||||
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||
onKeyUp={handleVoiceSpeedChangeComplete}
|
||||
onTouchEnd={handleVoiceSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audio Player Speed */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Audio player speed</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localAudioSpeed}
|
||||
onChange={handleAudioSpeedChange}
|
||||
onMouseUp={handleAudioSpeedChangeComplete}
|
||||
onKeyUp={handleAudioSpeedChangeComplete}
|
||||
onTouchEnd={handleAudioSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localSpeed}
|
||||
onChange={handleSpeedChange}
|
||||
onMouseUp={handleSpeedChangeComplete}
|
||||
onKeyUp={handleSpeedChangeComplete}
|
||||
onTouchEnd={handleSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
skipBackward,
|
||||
isProcessing,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
skipToLocation,
|
||||
|
|
@ -33,7 +34,10 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
||||
{/* Speed control */}
|
||||
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
|
||||
<SpeedControl
|
||||
setSpeedAndRestart={setSpeedAndRestart}
|
||||
setAudioPlayerSpeedAndRestart={setAudioPlayerSpeedAndRestart}
|
||||
/>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{currentPage && numPages && (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type ConfigValues = {
|
|||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
|
|
@ -29,6 +30,7 @@ interface ConfigContextType {
|
|||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
|
|
@ -58,6 +60,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const [viewType, setViewType] = useState<ViewType>('single');
|
||||
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
|
||||
const [audioPlayerSpeed, setAudioPlayerSpeed] = useState<number>(1);
|
||||
const [voice, setVoice] = useState<string>('af_sarah');
|
||||
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
||||
const [epubTheme, setEpubTheme] = useState<boolean>(false);
|
||||
|
|
@ -83,6 +86,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const cachedBaseUrl = await getItem('baseUrl');
|
||||
const cachedViewType = await getItem('viewType');
|
||||
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
||||
const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed');
|
||||
const cachedVoice = await getItem('voice');
|
||||
const cachedSkipBlank = await getItem('skipBlank');
|
||||
const cachedEpubTheme = await getItem('epubTheme');
|
||||
|
|
@ -106,6 +110,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
// Set the other values with defaults
|
||||
setViewType((cachedViewType || 'single') as ViewType);
|
||||
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
||||
setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1'));
|
||||
setVoice(cachedVoice || 'af_sarah');
|
||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
||||
setEpubTheme(cachedEpubTheme === 'true');
|
||||
|
|
@ -136,6 +141,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (cachedTTSInstructions === null) {
|
||||
await setItem('ttsInstructions', '');
|
||||
}
|
||||
if (!cachedVoiceSpeed) {
|
||||
await setItem('voiceSpeed', '1');
|
||||
}
|
||||
if (!cachedAudioPlayerSpeed) {
|
||||
await setItem('audioPlayerSpeed', '1');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing:', error);
|
||||
|
|
@ -151,7 +162,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
* Updates multiple configuration values simultaneously
|
||||
* Only saves API credentials if they are explicitly set
|
||||
*/
|
||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
|
||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
|
||||
|
|
@ -205,6 +216,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
case 'voiceSpeed':
|
||||
setVoiceSpeed(value as number);
|
||||
break;
|
||||
case 'audioPlayerSpeed':
|
||||
setAudioPlayerSpeed(value as number);
|
||||
break;
|
||||
case 'voice':
|
||||
setVoice(value as string);
|
||||
break;
|
||||
|
|
@ -247,6 +261,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
viewType,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ interface TTSContextType {
|
|||
setText: (text: string, shouldPause?: boolean) => void;
|
||||
setCurrDocPages: (num: number | undefined) => void;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
||||
|
|
@ -98,6 +99,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
baseUrl: openApiBaseUrl,
|
||||
isLoading: configIsLoading,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice: configVoice,
|
||||
ttsModel: configTTSModel,
|
||||
ttsInstructions: configTTSInstructions,
|
||||
|
|
@ -141,6 +143,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||||
const [speed, setSpeed] = useState(voiceSpeed);
|
||||
const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed);
|
||||
const [voice, setVoice] = useState(configVoice);
|
||||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||||
|
|
@ -375,7 +378,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const updateVoiceAndSpeed = useCallback(() => {
|
||||
setVoice(configVoice);
|
||||
setSpeed(voiceSpeed);
|
||||
}, [configVoice, voiceSpeed]);
|
||||
setAudioSpeed(audioPlayerSpeed);
|
||||
}, [configVoice, voiceSpeed, audioPlayerSpeed]);
|
||||
|
||||
/**
|
||||
* Initializes configuration and fetches available voices
|
||||
|
|
@ -558,6 +562,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
html5: true,
|
||||
preload: true,
|
||||
pool: 5,
|
||||
rate: audioSpeed,
|
||||
onplay: () => {
|
||||
setIsProcessing(false);
|
||||
if ('mediaSession' in navigator) {
|
||||
|
|
@ -654,7 +659,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
advance();
|
||||
return null;
|
||||
}
|
||||
}, [isPlaying, advance, activeHowl, processSentence]);
|
||||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||
|
||||
const playAudio = useCallback(async () => {
|
||||
const howl = await playSentenceWithHowl(sentences[currentIndex]);
|
||||
|
|
@ -810,6 +815,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
});
|
||||
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
|
||||
|
||||
/**
|
||||
* Sets the audio player speed and restarts the playback
|
||||
*
|
||||
* @param {number} newSpeed - The new audio player speed to set
|
||||
*/
|
||||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
const wasPlaying = isPlaying;
|
||||
|
||||
// Set a flag to prevent double audio requests during config update
|
||||
setIsProcessing(true);
|
||||
|
||||
// First stop any current playback
|
||||
setIsPlaying(false);
|
||||
abortAudio(true); // Clear pending requests since speed changed
|
||||
setActiveHowl(null);
|
||||
|
||||
// Update audio speed and config
|
||||
setAudioSpeed(newSpeed);
|
||||
|
||||
// Update config after state changes
|
||||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||||
setIsProcessing(false);
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
});
|
||||
}, [abortAudio, updateConfigKey, isPlaying]);
|
||||
|
||||
/**
|
||||
* Provides the TTS context value to child components
|
||||
*/
|
||||
|
|
@ -831,6 +865,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setText,
|
||||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
|
|
@ -854,6 +889,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setText,
|
||||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
|
|
|
|||
Loading…
Reference in a new issue