Skip to content

Commit a400809

Browse files
Add local TTS speed preference
1 parent 7f56a98 commit a400809

3 files changed

Lines changed: 175 additions & 7 deletions

File tree

frontend/src-tauri/src/tts.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ const TTS_TOTAL_STEPS: usize = 10;
8989
const TTS_CHUNK_MAX_CHARS: usize = 450;
9090
const SUPERTONIC3_TTS_SPEED: f32 = 1.0;
9191
const LEGACY_TTS_SPEED: f32 = 1.2;
92+
const MIN_TTS_SPEED: f32 = 0.5;
93+
const MAX_TTS_SPEED: f32 = 2.0;
9294

9395
const AVAILABLE_LANGS: &[&str] = &[
9496
"en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hi", "hr", "hu",
@@ -227,6 +229,20 @@ fn default_tts_speed(model_version: ModelVersion) -> f32 {
227229
}
228230
}
229231

232+
fn sanitize_tts_speed(speed: f32) -> f32 {
233+
if speed.is_finite() {
234+
speed.clamp(MIN_TTS_SPEED, MAX_TTS_SPEED)
235+
} else {
236+
SUPERTONIC3_TTS_SPEED
237+
}
238+
}
239+
240+
fn resolve_tts_speed(model_version: ModelVersion, requested_speed: Option<f32>) -> f32 {
241+
requested_speed
242+
.map(sanitize_tts_speed)
243+
.unwrap_or_else(|| default_tts_speed(model_version))
244+
}
245+
230246
fn tts_trace_enabled() -> bool {
231247
std::env::var("MAPLE_TTS_TRACE")
232248
.map(|value| {
@@ -1384,6 +1400,7 @@ pub async fn tts_chunk_text(text: String) -> Result<TTSChunkTextResponse, String
13841400
#[tauri::command]
13851401
pub async fn tts_synthesize(
13861402
text: String,
1403+
speed: Option<f32>,
13871404
state: tauri::State<'_, Mutex<TTSState>>,
13881405
) -> Result<TTSSynthesizeResponse, String> {
13891406
let mut guard = state.lock().map_err(|e| e.to_string())?;
@@ -1395,7 +1412,7 @@ pub async fn tts_synthesize(
13951412
.ok_or("Voice style not loaded")?
13961413
.clone();
13971414
let tts = guard.tts.as_mut().ok_or("TTS engine not loaded")?;
1398-
let speed = default_tts_speed(tts.model_version);
1415+
let speed = resolve_tts_speed(tts.model_version, speed);
13991416
let model_version = tts.model_version;
14001417
let sample_rate = tts.sample_rate;
14011418
let trace = tts_trace_enabled();
@@ -1452,6 +1469,7 @@ pub async fn tts_synthesize_chunk(
14521469
text: String,
14531470
chunk_index: usize,
14541471
chunk_count: usize,
1472+
speed: Option<f32>,
14551473
state: tauri::State<'_, Mutex<TTSState>>,
14561474
) -> Result<TTSSynthesizeResponse, String> {
14571475
let mut guard = state.lock().map_err(|e| e.to_string())?;
@@ -1462,7 +1480,7 @@ pub async fn tts_synthesize_chunk(
14621480
.ok_or("Voice style not loaded")?
14631481
.clone();
14641482
let tts = guard.tts.as_mut().ok_or("TTS engine not loaded")?;
1465-
let speed = default_tts_speed(tts.model_version);
1483+
let speed = resolve_tts_speed(tts.model_version, speed);
14661484
let model_version = tts.model_version;
14671485
let sample_rate = tts.sample_rate;
14681486
let trace = tts_trace_enabled();
@@ -1617,6 +1635,23 @@ mod tests {
16171635
assert_eq!(default_tts_speed(ModelVersion::Legacy), 1.2);
16181636
}
16191637

1638+
#[test]
1639+
fn resolve_tts_speed_clamps_user_preference() {
1640+
assert_eq!(resolve_tts_speed(ModelVersion::Supertonic3, Some(0.1)), 0.5);
1641+
assert_eq!(resolve_tts_speed(ModelVersion::Supertonic3, Some(1.4)), 1.4);
1642+
assert_eq!(resolve_tts_speed(ModelVersion::Supertonic3, Some(3.0)), 2.0);
1643+
assert_eq!(
1644+
resolve_tts_speed(ModelVersion::Supertonic3, Some(f32::NAN)),
1645+
1.0
1646+
);
1647+
}
1648+
1649+
#[test]
1650+
fn resolve_tts_speed_uses_model_default_without_user_preference() {
1651+
assert_eq!(resolve_tts_speed(ModelVersion::Supertonic3, None), 1.0);
1652+
assert_eq!(resolve_tts_speed(ModelVersion::Legacy, None), 1.2);
1653+
}
1654+
16201655
#[test]
16211656
fn chunk_text_splits_long_sentence_by_words_when_needed() {
16221657
let chunks = chunk_text("Hello world. Bye.", 10);

frontend/src/components/TTSDownloadDialog.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@ import {
77
DialogTitle
88
} from "@/components/ui/dialog";
99
import { Button } from "@/components/ui/button";
10-
import { Volume2, Download, AlertCircle, Loader2, Check, Trash2 } from "lucide-react";
11-
import { useTTS } from "@/services/tts/TTSContext";
10+
import { Volume2, Download, AlertCircle, Loader2, Check, Trash2, RotateCcw } from "lucide-react";
11+
import {
12+
useTTS,
13+
TTS_MIN_PLAYBACK_SPEED,
14+
TTS_MAX_PLAYBACK_SPEED,
15+
TTS_PLAYBACK_SPEED_STEP
16+
} from "@/services/tts/TTSContext";
1217

1318
interface TTSDownloadDialogProps {
1419
open: boolean;
1520
onOpenChange: (open: boolean) => void;
1621
}
1722

23+
function formatPlaybackSpeed(speed: number): string {
24+
return `${speed.toFixed(1)}x`;
25+
}
26+
1827
export function TTSDownloadDialog({ open, onOpenChange }: TTSDownloadDialogProps) {
1928
const {
2029
status,
@@ -24,6 +33,10 @@ export function TTSDownloadDialog({ open, onOpenChange }: TTSDownloadDialogProps
2433
totalSizeMB,
2534
upgradeAvailable,
2635
modelVersion,
36+
playbackSpeed,
37+
hasCustomPlaybackSpeed,
38+
setPlaybackSpeed,
39+
resetPlaybackSpeed,
2740
startDownload,
2841
deleteModels
2942
} = useTTS();
@@ -167,6 +180,40 @@ export function TTSDownloadDialog({ open, onOpenChange }: TTSDownloadDialogProps
167180
TTS is ready! Click the speaker icon on any assistant message to listen.
168181
</p>
169182
</div>
183+
<div className="space-y-3 rounded-lg border p-3">
184+
<div className="flex items-center justify-between gap-3">
185+
<p className="text-sm font-medium">Speech speed</p>
186+
<span className="text-sm tabular-nums text-muted-foreground">
187+
{formatPlaybackSpeed(playbackSpeed)}
188+
</span>
189+
</div>
190+
<input
191+
type="range"
192+
min={TTS_MIN_PLAYBACK_SPEED}
193+
max={TTS_MAX_PLAYBACK_SPEED}
194+
step={TTS_PLAYBACK_SPEED_STEP}
195+
value={playbackSpeed}
196+
onChange={(event) => setPlaybackSpeed(Number(event.currentTarget.value))}
197+
className="h-2 w-full cursor-pointer accent-primary"
198+
aria-label="Speech speed"
199+
/>
200+
<div className="flex items-center justify-between text-xs text-muted-foreground">
201+
<span>{formatPlaybackSpeed(TTS_MIN_PLAYBACK_SPEED)}</span>
202+
<span>{formatPlaybackSpeed(TTS_MAX_PLAYBACK_SPEED)}</span>
203+
</div>
204+
<div className="flex justify-end">
205+
<Button
206+
variant="outline"
207+
size="sm"
208+
onClick={resetPlaybackSpeed}
209+
disabled={!hasCustomPlaybackSpeed}
210+
className="gap-2"
211+
>
212+
<RotateCcw className="h-4 w-4" />
213+
Reset
214+
</Button>
215+
</div>
216+
</div>
170217
{isUpgradeAvailable && modelVersion === "legacy" && (
171218
<div className="space-y-3 rounded-lg border p-3">
172219
<div className="flex items-start gap-3">

frontend/src/services/tts/TTSContext.tsx

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5664
interface 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+
104148
export 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

Comments
 (0)