@@ -53,6 +53,14 @@ interface DownloadProgress {
5353 percent : number ;
5454}
5555
56+ export const TTS_MIN_PLAYBACK_SPEED = 0.5 ;
57+ export const TTS_MAX_PLAYBACK_SPEED = 2.0 ;
58+ export const TTS_PLAYBACK_SPEED_STEP = 0.1 ;
59+
60+ const SUPERTONIC3_DEFAULT_PLAYBACK_SPEED = 1.0 ;
61+ const LEGACY_DEFAULT_PLAYBACK_SPEED = 1.2 ;
62+ const TTS_PLAYBACK_SPEED_STORAGE_KEY = "ttsPlaybackSpeed" ;
63+
5664interface TTSContextValue {
5765 status : TTSStatus ;
5866 error : string | null ;
@@ -65,13 +73,17 @@ interface TTSContextValue {
6573 isPreparing : boolean ;
6674 isPlaying : boolean ;
6775 currentPlayingId : string | null ;
76+ playbackSpeed : number ;
77+ hasCustomPlaybackSpeed : boolean ;
6878 isTauriEnv : boolean ;
6979
7080 checkStatus : ( ) => Promise < void > ;
7181 startDownload : ( ) => Promise < void > ;
7282 deleteModels : ( ) => Promise < void > ;
7383 speak : ( text : string , messageId : string ) => Promise < void > ;
7484 stop : ( ) => void ;
85+ setPlaybackSpeed : ( speed : number ) => void ;
86+ resetPlaybackSpeed : ( ) => void ;
7587 clearPlaybackError : ( ) => void ;
7688}
7789
@@ -101,6 +113,38 @@ function errorMessage(err: unknown, fallback: string): string {
101113 return fallback ;
102114}
103115
116+ function clampPlaybackSpeed ( speed : number ) : number {
117+ if ( ! Number . isFinite ( speed ) ) {
118+ return SUPERTONIC3_DEFAULT_PLAYBACK_SPEED ;
119+ }
120+ const clamped = Math . min ( TTS_MAX_PLAYBACK_SPEED , Math . max ( TTS_MIN_PLAYBACK_SPEED , speed ) ) ;
121+ return Number ( clamped . toFixed ( 2 ) ) ;
122+ }
123+
124+ function readPlaybackSpeedOverride ( ) : number | null {
125+ if ( typeof window === "undefined" ) {
126+ return null ;
127+ }
128+
129+ try {
130+ const stored = window . localStorage . getItem ( TTS_PLAYBACK_SPEED_STORAGE_KEY ) ;
131+ if ( ! stored ) {
132+ return null ;
133+ }
134+
135+ const speed = Number ( stored ) ;
136+ return Number . isFinite ( speed ) ? clampPlaybackSpeed ( speed ) : null ;
137+ } catch {
138+ return null ;
139+ }
140+ }
141+
142+ function defaultPlaybackSpeedForModel ( modelVersion : "supertonic3" | "legacy" | null ) : number {
143+ return modelVersion === "legacy"
144+ ? LEGACY_DEFAULT_PLAYBACK_SPEED
145+ : SUPERTONIC3_DEFAULT_PLAYBACK_SPEED ;
146+ }
147+
104148export function TTSProvider ( { children } : { children : ReactNode } ) {
105149 // Check Tauri environment - TTS is available on desktop and iOS (not Android)
106150 const isTauriEnv = isTauriDesktop ( ) || ( isTauri ( ) && isIOS ( ) ) ;
@@ -117,6 +161,12 @@ export function TTSProvider({ children }: { children: ReactNode }) {
117161 const [ isPlaying , setIsPlaying ] = useState ( false ) ;
118162 const [ currentPlayingId , setCurrentPlayingId ] = useState < string | null > ( null ) ;
119163 const [ playbackError , setPlaybackError ] = useState < string | null > ( null ) ;
164+ const [ playbackSpeedOverride , setPlaybackSpeedOverride ] = useState < number | null > ( ( ) =>
165+ readPlaybackSpeedOverride ( )
166+ ) ;
167+ const playbackSpeed =
168+ playbackSpeedOverride ?? clampPlaybackSpeed ( defaultPlaybackSpeedForModel ( modelVersion ) ) ;
169+ const hasCustomPlaybackSpeed = playbackSpeedOverride !== null ;
120170
121171 const requestIdRef = useRef ( 0 ) ;
122172 const synthesisInFlightRef = useRef ( false ) ;
@@ -137,6 +187,35 @@ export function TTSProvider({ children }: { children: ReactNode }) {
137187 }
138188 } , [ ] ) ;
139189
190+ const setPlaybackSpeed = useCallback ( ( speed : number ) => {
191+ const clamped = clampPlaybackSpeed ( speed ) ;
192+ setPlaybackSpeedOverride ( clamped ) ;
193+
194+ if ( typeof window === "undefined" ) {
195+ return ;
196+ }
197+
198+ try {
199+ window . localStorage . setItem ( TTS_PLAYBACK_SPEED_STORAGE_KEY , clamped . toString ( ) ) ;
200+ } catch {
201+ // Ignore
202+ }
203+ } , [ ] ) ;
204+
205+ const resetPlaybackSpeed = useCallback ( ( ) => {
206+ setPlaybackSpeedOverride ( null ) ;
207+
208+ if ( typeof window === "undefined" ) {
209+ return ;
210+ }
211+
212+ try {
213+ window . localStorage . removeItem ( TTS_PLAYBACK_SPEED_STORAGE_KEY ) ;
214+ } catch {
215+ // Ignore
216+ }
217+ } , [ ] ) ;
218+
140219 // Check TTS status from Rust backend
141220 const checkStatus = useCallback ( async ( ) => {
142221 if ( ! isTauriEnv ) {
@@ -330,11 +409,13 @@ export function TTSProvider({ children }: { children: ReactNode }) {
330409 const requestId = requestIdRef . current + 1 ;
331410 requestIdRef . current = requestId ;
332411 const startedAt = performance . now ( ) ;
412+ const speed = playbackSpeed ;
333413 try {
334414 debugTTS ( "speak start" , {
335415 messageId,
336416 rawChars : text . length ,
337- processedChars : processedText . length
417+ processedChars : processedText . length ,
418+ speed
338419 } ) ;
339420 synthesisInFlightRef . current = true ;
340421 setIsPreparing ( true ) ;
@@ -467,7 +548,8 @@ export function TTSProvider({ children }: { children: ReactNode }) {
467548 const result = await invoke < TTSSynthesizeResponse > ( "tts_synthesize_chunk" , {
468549 text : chunks [ chunkIndex ] ,
469550 chunkIndex : chunkIndex + 1 ,
470- chunkCount : chunks . length
551+ chunkCount : chunks . length ,
552+ speed
471553 } ) ;
472554
473555 if ( requestIdRef . current !== requestId ) {
@@ -621,7 +703,7 @@ export function TTSProvider({ children }: { children: ReactNode }) {
621703 stop ( ) ;
622704 }
623705 } ,
624- [ isTauriEnv , status , stop ]
706+ [ isTauriEnv , playbackSpeed , status , stop ]
625707 ) ;
626708
627709 const clearPlaybackError = useCallback ( ( ) => {
@@ -697,12 +779,16 @@ export function TTSProvider({ children }: { children: ReactNode }) {
697779 isPreparing,
698780 isPlaying,
699781 currentPlayingId,
782+ playbackSpeed,
783+ hasCustomPlaybackSpeed,
700784 isTauriEnv,
701785 checkStatus,
702786 startDownload,
703787 deleteModels,
704788 speak,
705789 stop,
790+ setPlaybackSpeed,
791+ resetPlaybackSpeed,
706792 clearPlaybackError
707793 } }
708794 >
0 commit comments