chore: refactor user turn strategies

This commit is contained in:
Abhishek Kumar 2026-06-30 14:21:53 +05:30
parent 01d4d07638
commit 69bb7be38d
10 changed files with 308 additions and 80 deletions

3
.vscode/launch.json vendored
View file

@ -22,8 +22,7 @@
"args": [
"api.app:app",
"--reload",
"--host", "0.0.0.0",
"--port", "8000"
"--host", "0.0.0.0"
],
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/api/.env",

View file

@ -102,6 +102,8 @@ DEFAULT_USER_TURN_STOP_TIMEOUT = 5.0
EXTERNAL_TURN_USER_STOP_TIMEOUT = 30.0
DEFAULT_TURN_START_STRATEGY = "default"
DEFAULT_TURN_START_MIN_WORDS = 3
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS = 1.0
DEFAULT_SMART_TURN_STOP_SECS = 2.0
def _resolve_user_turn_stop_timeout(
@ -121,6 +123,17 @@ def _resolve_turn_start_min_words(run_configs: dict) -> int:
)
def _resolve_provisional_vad_pause_secs(run_configs: dict) -> float:
return max(
0.1,
float(
run_configs.get(
"provisional_vad_pause_secs", DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
)
),
)
def _create_non_realtime_user_turn_start_strategies(
run_configs: dict, *, uses_external_turns: bool
):
@ -138,7 +151,11 @@ def _create_non_realtime_user_turn_start_strategies(
]
if turn_start_strategy == "provisional_vad":
return [ProvisionalVADUserTurnStartStrategy()]
return [
ProvisionalVADUserTurnStartStrategy(
pause_secs=_resolve_provisional_vad_pause_secs(run_configs)
)
]
if uses_external_turns:
# The STT emits its own turn boundaries and owns interruptions. Local
@ -150,6 +167,29 @@ def _create_non_realtime_user_turn_start_strategies(
return [VADUserTurnStartStrategy()]
def _create_non_realtime_user_turn_stop_strategies(
run_configs: dict, *, uses_external_turns: bool
):
"""Return user turn stop strategies for non-realtime pipelines."""
if uses_external_turns:
return [ExternalUserTurnStopStrategy()]
if run_configs.get("turn_stop_strategy") == "turn_analyzer":
smart_turn_params = SmartTurnParams(
stop_secs=run_configs.get(
"smart_turn_stop_secs", DEFAULT_SMART_TURN_STOP_SECS
)
)
return [
TurnAnalyzerUserTurnStopStrategy(
turn_analyzer=LocalSmartTurnAnalyzerV3(params=smart_turn_params)
)
]
return [SpeechTimeoutUserTurnStopStrategy()]
def _create_realtime_user_turn_config(provider: str):
"""Return user turn strategies and optional local VAD for realtime providers."""
@ -500,8 +540,6 @@ async def _run_pipeline_impl(
# Extract configurations from the version's workflow_configurations
max_call_duration_seconds = 300 # Default 5 minutes
max_user_idle_timeout = 10.0 # Default 10 seconds
smart_turn_stop_secs = 2.0 # Default 2 seconds for incomplete turn timeout
turn_stop_strategy = "transcription" # Default to transcription-based detection
keyterms = None # Dictionary words for STT boosting
if run_configs:
@ -511,12 +549,6 @@ async def _run_pipeline_impl(
if "max_user_idle_timeout" in run_configs:
max_user_idle_timeout = run_configs["max_user_idle_timeout"]
if "smart_turn_stop_secs" in run_configs:
smart_turn_stop_secs = run_configs["smart_turn_stop_secs"]
if "turn_stop_strategy" in run_configs:
turn_stop_strategy = run_configs["turn_stop_strategy"]
if "dictionary" in run_configs:
dictionary = run_configs["dictionary"]
if dictionary and isinstance(dictionary, str):
@ -780,43 +812,20 @@ async def _run_pipeline_impl(
turn_start_strategy = run_configs.get(
"turn_start_strategy", DEFAULT_TURN_START_STRATEGY
)
turn_start_strategy_names = [
strategy.__class__.__name__ for strategy in user_turn_start_strategies
]
turn_start_min_words = (
_resolve_turn_start_min_words(run_configs)
if turn_start_strategy == "min_words"
else None
)
logger.info(
f"[run {workflow_run_id}] Non-realtime interrupt strategy "
f"requested={turn_start_strategy} "
f"effective={turn_start_strategy_names} "
f"min_words={turn_start_min_words} "
f"uses_external_turns={uses_external_turns}"
)
if uses_external_turns:
user_turn_strategies = UserTurnStrategies(
start=user_turn_start_strategies,
stop=[ExternalUserTurnStopStrategy()],
)
elif turn_stop_strategy == "turn_analyzer":
# Smart Turn Analyzer: best for longer responses with natural pauses
smart_turn_params = SmartTurnParams(stop_secs=smart_turn_stop_secs)
user_turn_strategies = UserTurnStrategies(
start=user_turn_start_strategies,
stop=[
TurnAnalyzerUserTurnStopStrategy(
turn_analyzer=LocalSmartTurnAnalyzerV3(params=smart_turn_params)
)
],
)
else:
# Transcription-based (default): best for short 1-2 word responses
user_turn_strategies = UserTurnStrategies(
start=user_turn_start_strategies,
stop=[SpeechTimeoutUserTurnStopStrategy()],
)
user_turn_stop_strategies = _create_non_realtime_user_turn_stop_strategies(
run_configs,
uses_external_turns=uses_external_turns,
)
user_turn_strategies = UserTurnStrategies(
start=user_turn_start_strategies,
stop=user_turn_stop_strategies,
)
user_turn_stop_timeout = _resolve_user_turn_stop_timeout(
run_configs,

View file

@ -10,14 +10,18 @@ from pipecat.turns.user_start.vad_user_turn_start_strategy import (
from pipecat.turns.user_stop import (
ExternalUserTurnStopStrategy,
SpeechTimeoutUserTurnStopStrategy,
TurnAnalyzerUserTurnStopStrategy,
)
import api.services.pipecat.run_pipeline as run_pipeline_module
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.run_pipeline import (
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
DEFAULT_USER_TURN_STOP_TIMEOUT,
EXTERNAL_TURN_USER_STOP_TIMEOUT,
_create_non_realtime_user_turn_start_strategies,
_create_non_realtime_user_turn_stop_strategies,
_create_realtime_user_turn_config,
_resolve_user_turn_stop_timeout,
)
@ -181,6 +185,55 @@ def test_non_realtime_can_use_provisional_vad_start_strategy():
assert len(strategies) == 1
assert isinstance(strategies[0], ProvisionalVADUserTurnStartStrategy)
assert strategies[0]._pause_secs == DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
def test_non_realtime_provisional_vad_uses_configured_pause_secs():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "provisional_vad", "provisional_vad_pause_secs": 0.4},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ProvisionalVADUserTurnStartStrategy)
assert strategies[0]._pause_secs == 0.4
def test_non_realtime_uses_external_stop_for_external_turn_stt():
strategies = _create_non_realtime_user_turn_stop_strategies(
{},
uses_external_turns=True,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ExternalUserTurnStopStrategy)
def test_non_realtime_default_uses_speech_timeout_stop():
strategies = _create_non_realtime_user_turn_stop_strategies(
{},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], SpeechTimeoutUserTurnStopStrategy)
def test_non_realtime_can_use_turn_analyzer_stop_strategy(monkeypatch):
monkeypatch.setattr(
run_pipeline_module,
"LocalSmartTurnAnalyzerV3",
lambda *, params: params,
)
strategies = _create_non_realtime_user_turn_stop_strategies(
{"turn_stop_strategy": "turn_analyzer", "smart_turn_stop_secs": 1.5},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], TurnAnalyzerUserTurnStopStrategy)
assert strategies[0]._turn_analyzer.stop_secs == 1.5
def test_external_turn_stt_uses_longer_stop_timeout():

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit c3a2245a2d32e5f28982f60e2cb99f9aebc0e1fe
Subproject commit 0a4126d3174a7e2298ea69f99ead629e8ba704a9

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.IQaxuC56sd
# timestamp: 2026-06-29T10:55:38+00:00
# filename: dograh-openapi-XXXXXX.json.mFeCVL0pIi
# timestamp: 2026-06-30T08:46:04+00:00
from __future__ import annotations

View file

@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch";
import {
AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
TURN_START_STRATEGY_OPTIONS,
TurnStartStrategy,
@ -54,6 +55,9 @@ export const ConfigurationsDialog = ({
const [turnStartMinWords, setTurnStartMinWords] = useState<number>(
workflowConfigurations?.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS
);
const [provisionalVadPauseSecs, setProvisionalVadPauseSecs] = useState<number>(
workflowConfigurations?.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations?.turn_stop_strategy || 'transcription'
);
@ -75,6 +79,7 @@ export const ConfigurationsDialog = ({
smart_turn_stop_secs: smartTurnStopSecs,
turn_start_strategy: turnStartStrategy,
turn_start_min_words: turnStartMinWords,
provisional_vad_pause_secs: provisionalVadPauseSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
}, name);
@ -96,6 +101,7 @@ export const ConfigurationsDialog = ({
setSmartTurnStopSecs(workflowConfigurations?.smart_turn_stop_secs || 2);
setTurnStartStrategy(workflowConfigurations?.turn_start_strategy || 'default');
setTurnStartMinWords(workflowConfigurations?.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS);
setProvisionalVadPauseSecs(workflowConfigurations?.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS);
setTurnStopStrategy(workflowConfigurations?.turn_stop_strategy || 'transcription');
setContextCompactionEnabled(workflowConfigurations?.context_compaction_enabled ?? false);
}
@ -287,6 +293,31 @@ export const ConfigurationsDialog = ({
</p>
</div>
)}
{turnStartStrategy === 'provisional_vad' && (
<div className="space-y-2">
<Label htmlFor="provisional_vad_pause_secs" className="text-xs">
Provisional Pause (seconds)
</Label>
<Input
id="provisional_vad_pause_secs"
type="number"
step="0.1"
min="0.1"
max="5"
value={provisionalVadPauseSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.1) {
setProvisionalVadPauseSecs(value);
}
}}
/>
<p className="text-xs text-muted-foreground">
Seconds to pause bot audio while waiting for transcript confirmation. Default: {DEFAULT_PROVISIONAL_VAD_PAUSE_SECS}
</p>
</div>
)}
</div>
{/* Context Management Section */}

View file

@ -52,6 +52,7 @@ interface WorkflowRunResponse {
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
const WAVEFORM_BAR_COUNT = 96;
type SplitTrackPlaybackMode = 'both' | 'user' | 'bot';
function formatDuration(seconds?: number | null) {
if (seconds == null || Number.isNaN(seconds)) return 'N/A';
@ -124,19 +125,38 @@ async function loadWaveformPeaks(url: string) {
}
}
function getAudioDuration(audio: HTMLAudioElement | null) {
return audio && Number.isFinite(audio.duration) ? audio.duration : 0;
}
function getAudioTimelineState(audios: HTMLAudioElement[]) {
const duration = Math.max(0, ...audios.map((audio) => getAudioDuration(audio)));
const currentTime = Math.max(0, ...audios.map((audio) => audio.currentTime));
return { duration, currentTime };
}
function syncAudioCurrentTime(audio: HTMLAudioElement, startTime: number) {
const duration = getAudioDuration(audio);
audio.currentTime = Math.min(startTime, duration || startTime);
}
function WaveformLane({
peaks,
track,
position,
isActive,
}: {
peaks: number[] | null;
track: 'user' | 'bot';
position: 'top' | 'bottom';
isActive: boolean;
}) {
return (
<div
className={cn(
'absolute left-3 right-3 flex gap-0.5',
isActive ? 'opacity-85' : 'opacity-25',
position === 'top' ? 'top-5 h-12 items-end' : 'bottom-5 h-12 items-start'
)}
>
@ -145,7 +165,7 @@ function WaveformLane({
<span
key={`${track}-${index}`}
className={cn(
'min-h-1 flex-1 rounded-full opacity-85',
'min-h-1 flex-1 rounded-full',
track === 'user' ? 'bg-sky-500' : 'bg-emerald-500'
)}
style={{ height: `${Math.round(peak * 100)}%` }}
@ -178,6 +198,21 @@ function SplitTracksSection({
const [isLoading, setIsLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [playbackMode, setPlaybackMode] = useState<SplitTrackPlaybackMode>('both');
const getPlaybackAudios = (mode: SplitTrackPlaybackMode) => {
const audios: HTMLAudioElement[] = [];
if (mode !== 'bot' && userAudioRef.current) {
audios.push(userAudioRef.current);
}
if (mode !== 'user' && botAudioRef.current) {
audios.push(botAudioRef.current);
}
return audios;
};
useEffect(() => {
let isActive = true;
@ -190,6 +225,7 @@ function SplitTracksSection({
setPeaks({ user: null, bot: null });
setIsPlaying(false);
setProgress(0);
setPlaybackMode('both');
setIsLoading(true);
async function loadTracks() {
@ -234,12 +270,17 @@ function SplitTracksSection({
let frameId: number;
const updateProgress = () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
const userDuration = Number.isFinite(userAudio?.duration) ? userAudio?.duration ?? 0 : 0;
const botDuration = Number.isFinite(botAudio?.duration) ? botAudio?.duration ?? 0 : 0;
const duration = Math.max(userDuration, botDuration);
const currentTime = Math.max(userAudio?.currentTime ?? 0, botAudio?.currentTime ?? 0);
const activeAudios: HTMLAudioElement[] = [];
if (playbackMode !== 'bot' && userAudioRef.current) {
activeAudios.push(userAudioRef.current);
}
if (playbackMode !== 'user' && botAudioRef.current) {
activeAudios.push(botAudioRef.current);
}
const { duration, currentTime } = getAudioTimelineState(activeAudios);
setProgress(duration > 0 ? Math.min(1, currentTime / duration) : 0);
frameId = window.requestAnimationFrame(updateProgress);
@ -247,7 +288,7 @@ function SplitTracksSection({
frameId = window.requestAnimationFrame(updateProgress);
return () => window.cancelAnimationFrame(frameId);
}, [isPlaying]);
}, [isPlaying, playbackMode]);
const pauseTracks = () => {
userAudioRef.current?.pause();
@ -256,38 +297,68 @@ function SplitTracksSection({
};
const handleTrackEnded = () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
const userDone = !userAudio || userAudio.ended;
const botDone = !botAudio || botAudio.ended;
const activeAudios = getPlaybackAudios(playbackMode);
const activeTracksDone = activeAudios.length > 0 && activeAudios.every((audio) => audio.ended);
if (userDone && botDone) {
if (activeTracksDone) {
setIsPlaying(false);
setProgress(1);
}
};
const handlePlaybackModeChange = async (nextMode: SplitTrackPlaybackMode) => {
if (nextMode === playbackMode) return;
const { currentTime } = getAudioTimelineState(getPlaybackAudios(playbackMode));
const nextAudios = getPlaybackAudios(nextMode);
const { duration } = getAudioTimelineState(nextAudios);
const startTime = duration > 0 && currentTime >= duration - 0.1 ? 0 : currentTime;
userAudioRef.current?.pause();
botAudioRef.current?.pause();
nextAudios.forEach((audio) => syncAudioCurrentTime(audio, startTime));
setPlaybackMode(nextMode);
setProgress(duration > 0 ? Math.min(1, startTime / duration) : 0);
if (!isPlaying) return;
if (nextAudios.length === 0) {
setIsPlaying(false);
return;
}
try {
await Promise.all(nextAudios.map((audio) => audio.play()));
setIsPlaying(true);
} catch (error) {
pauseTracks();
console.error('Error switching split track playback:', error);
}
};
const handleTrackButtonClick = (track: 'user' | 'bot') => {
const nextMode = playbackMode === track ? 'both' : track;
void handlePlaybackModeChange(nextMode);
};
const togglePlayback = async () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
if (!userAudio || !botAudio || !signedUrls.user || !signedUrls.bot) return;
const playbackAudios = getPlaybackAudios(playbackMode);
if (!canPlay || playbackAudios.length === 0) return;
if (isPlaying) {
pauseTracks();
return;
}
const userDuration = Number.isFinite(userAudio.duration) ? userAudio.duration : 0;
const botDuration = Number.isFinite(botAudio.duration) ? botAudio.duration : 0;
const duration = Math.max(userDuration, botDuration);
const currentTime = Math.max(userAudio.currentTime, botAudio.currentTime);
const { duration, currentTime } = getAudioTimelineState(playbackAudios);
const startTime = duration > 0 && currentTime >= duration - 0.1 ? 0 : currentTime;
userAudio.currentTime = Math.min(startTime, userDuration || startTime);
botAudio.currentTime = Math.min(startTime, botDuration || startTime);
userAudioRef.current?.pause();
botAudioRef.current?.pause();
playbackAudios.forEach((audio) => syncAudioCurrentTime(audio, startTime));
try {
await Promise.all([userAudio.play(), botAudio.play()]);
await Promise.all(playbackAudios.map((audio) => audio.play()));
setIsPlaying(true);
} catch (error) {
pauseTracks();
@ -295,8 +366,16 @@ function SplitTracksSection({
}
};
const canPlay = Boolean(signedUrls.user && signedUrls.bot);
const canPlay =
playbackMode === 'both'
? Boolean(signedUrls.user && signedUrls.bot)
: playbackMode === 'user'
? Boolean(signedUrls.user)
: Boolean(signedUrls.bot);
const progressPercent = Math.round(progress * 1000) / 10;
const userTrackActive = playbackMode !== 'bot';
const botTrackActive = playbackMode !== 'user';
const playbackTargetLabel = playbackMode === 'both' ? 'split tracks' : `${playbackMode} track`;
return (
<Card className="border-border">
@ -319,16 +398,42 @@ function SplitTracksSection({
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-sky-600">
<div className="flex items-center gap-2" role="group" aria-label="Playback tracks">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={userTrackActive}
aria-label={playbackMode === 'user' ? 'Play both tracks' : 'Play user track only'}
onClick={() => handleTrackButtonClick('user')}
className={cn(
'gap-1.5',
userTrackActive
? 'border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300'
: 'text-muted-foreground opacity-60'
)}
>
<UserRound className="h-4 w-4" />
User
</span>
</Button>
<span className="h-4 w-px bg-border" />
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={botTrackActive}
aria-label={playbackMode === 'bot' ? 'Play both tracks' : 'Play bot track only'}
onClick={() => handleTrackButtonClick('bot')}
className={cn(
'gap-1.5',
botTrackActive
? 'border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'text-muted-foreground opacity-60'
)}
>
<Bot className="h-4 w-4" />
Bot
</span>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
@ -360,15 +465,15 @@ function SplitTracksSection({
variant={isPlaying ? 'default' : 'outline'}
onClick={togglePlayback}
disabled={!canPlay}
aria-label={isPlaying ? 'Pause split tracks' : 'Play split tracks'}
aria-label={isPlaying ? `Pause ${playbackTargetLabel}` : `Play ${playbackTargetLabel}`}
className="h-10 w-10 shrink-0"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="relative h-36 min-w-0 flex-1 overflow-hidden rounded-lg border border-border/70 bg-background">
<div className="absolute left-3 right-3 top-1/2 h-px bg-border/80" />
<WaveformLane peaks={peaks.user} track="user" position="top" />
<WaveformLane peaks={peaks.bot} track="bot" position="bottom" />
<WaveformLane peaks={peaks.user} track="user" position="top" isActive={userTrackActive} />
<WaveformLane peaks={peaks.bot} track="bot" position="bottom" isActive={botTrackActive} />
{canPlay && (
<div className="pointer-events-none absolute inset-x-3 inset-y-3">
<div

View file

@ -45,6 +45,7 @@ import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
type AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
DEFAULT_WORKFLOW_CONFIGURATIONS,
@ -289,6 +290,9 @@ function GeneralSection({
const [turnStartMinWords, setTurnStartMinWords] = useState(
workflowConfigurations.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS,
);
const [provisionalVadPauseSecs, setProvisionalVadPauseSecs] = useState(
workflowConfigurations.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations.turn_stop_strategy || "transcription",
);
@ -314,10 +318,11 @@ function GeneralSection({
smartTurnStopSecs !== (workflowConfigurations.smart_turn_stop_secs || 2) ||
turnStartStrategy !== (workflowConfigurations.turn_start_strategy || "default") ||
turnStartMinWords !== (workflowConfigurations.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS) ||
provisionalVadPauseSecs !== (workflowConfigurations.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS) ||
turnStopStrategy !== (workflowConfigurations.turn_stop_strategy || "transcription") ||
contextCompactionEnabled !== (workflowConfigurations.context_compaction_enabled ?? false)
);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
useUnsavedChanges("general", isDirty);
@ -391,6 +396,7 @@ function GeneralSection({
smart_turn_stop_secs: smartTurnStopSecs,
turn_start_strategy: turnStartStrategy,
turn_start_min_words: turnStartMinWords,
provisional_vad_pause_secs: provisionalVadPauseSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
},
@ -648,6 +654,28 @@ function GeneralSection({
</p>
</div>
)}
{turnStartStrategy === "provisional_vad" && (
<div className="space-y-2">
<Label htmlFor="provisional_vad_pause_secs" className="text-xs">
Provisional Pause (seconds)
</Label>
<Input
id="provisional_vad_pause_secs"
type="number"
step="0.1"
min="0.1"
max="5"
value={provisionalVadPauseSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.1) setProvisionalVadPauseSecs(value);
}}
/>
<p className="text-xs text-muted-foreground">
Seconds to pause bot audio while waiting for transcript confirmation. Default: {DEFAULT_PROVISIONAL_VAD_PAUSE_SECS}
</p>
</div>
)}
</div>
<Separator />

View file

@ -11,6 +11,7 @@ export interface AmbientNoiseConfiguration {
export type TurnStopStrategy = 'transcription' | 'turn_analyzer';
export type TurnStartStrategy = 'default' | 'min_words' | 'provisional_vad';
export const DEFAULT_TURN_START_MIN_WORDS = 3;
export const DEFAULT_PROVISIONAL_VAD_PAUSE_SECS = 1.0;
export const TURN_START_STRATEGY_OPTIONS: Array<{
value: TurnStartStrategy;
@ -87,6 +88,7 @@ export interface WorkflowConfigurations {
smart_turn_stop_secs: number; // Timeout in seconds for incomplete turn detection
turn_start_strategy: TurnStartStrategy; // Strategy for detecting start of user turn/interruption
turn_start_min_words: number; // Minimum transcribed words required for minimum-word interruptions
provisional_vad_pause_secs: number; // Seconds to pause bot output while awaiting transcript confirmation
turn_stop_strategy: TurnStopStrategy; // Strategy for detecting end of user turn
dictionary?: string; // Comma-separated words for voice agent to listen for
voicemail_detection?: VoicemailDetectionConfiguration;
@ -106,6 +108,7 @@ export const DEFAULT_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
smart_turn_stop_secs: 2, // 2 seconds
turn_start_strategy: 'default', // Default to platform-chosen user turn start detection
turn_start_min_words: DEFAULT_TURN_START_MIN_WORDS,
provisional_vad_pause_secs: DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
turn_stop_strategy: 'transcription', // Default to transcription-based detection
dictionary: ''
};