fix(captures): Play As autoplay + default voice + orphan recovery

- Hand /generate ids to the global SSE watcher so playback fires on completion. The mutation onSuccess was checking audio_path on a queued row, which is always empty — autoplay never ran.
- Bind the Play As voice selection to capture_settings.default_playback_voice_id, kept in sync with the Settings → Captures and Settings → MCP pickers. Picking from the split-button dropdown writes back to settings.
- Extract AudioBars from HistoryTable into a shared component; use it for the Play As generating state in place of Loader2.
- Stop the active-state hover from flashing white text when the button is in its lighter accent/10 fill.
- Drop the gradient avatar swatches from the Settings → Captures voice dropdown.
- Backend: when the gen worker exits without writing a terminal status (e.g. SQLite lock racing the failed-status write inside its own exception handler), the cancel endpoint now flips the row to failed instead of 409-ing. Worker also force-fails on its way out as a belt-and-suspenders.
This commit is contained in:
Jamie Pine
2026-04-24 19:49:36 -07:00
parent b97c565a45
commit 7ad91f5767
10 changed files with 123 additions and 104 deletions
+39
View File
@@ -0,0 +1,39 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils/cn';
export type AudioBarsMode = 'idle' | 'generating' | 'playing';
interface AudioBarsProps {
mode: AudioBarsMode;
className?: string;
barClassName?: string;
}
export function AudioBars({ mode, className, barClassName }: AudioBarsProps) {
const activeColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className={cn('flex items-center gap-[2px] h-5', className)}>
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', activeColor, barClassName)}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
+38 -34
View File
@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
@@ -71,6 +72,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
@@ -144,15 +146,18 @@ export function CapturesTab() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
const [playAsVoiceId, setPlayAsVoiceId] = useState<string | null>(null);
const [playbackState, setPlaybackState] = useState<PlaybackState>('idle');
const [launchedPlayAsId, setLaunchedPlayAsId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const audioUrl = usePlayerStore((s) => s.audioUrl);
const playerAudioId = usePlayerStore((s) => s.audioId);
const playerIsPlaying = usePlayerStore((s) => s.isPlaying);
const isPlayerVisible = !!audioUrl;
const { settings: captureSettings } = useCaptureSettings();
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
const pendingGenerationIds = useGenerationStore((s) => s.pendingGenerationIds);
const { settings: captureSettings, update: updateCaptureSettings } = useCaptureSettings();
const sttModel = captureSettings?.stt_model ?? 'turbo';
const llmModel = captureSettings?.llm_model ?? '0.6B';
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
@@ -188,13 +193,6 @@ export function CapturesTab() {
}
}, [captures, selectedId]);
// Default the Play-as voice to the first profile we see.
useEffect(() => {
if (!playAsVoiceId && profiles && profiles.length) {
setPlayAsVoiceId(profiles[0].id);
}
}, [profiles, playAsVoiceId]);
// Live sync from sibling Tauri webviews (the floating dictate window).
// ``capture:created`` carries the full row so we can seed the cache before
// the refetch lands and focus the new capture in one shot — without the
@@ -238,7 +236,15 @@ export function CapturesTab() {
}, [search, captures]);
const selected = captures.find((c) => c.id === selectedId) ?? null;
const playAsVoice = profiles?.find((p) => p.id === playAsVoiceId) ?? null;
// Source of truth is capture_settings.default_playback_voice_id, shared
// with Settings → Captures and the MCP global default. Stale ids (e.g.
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
profiles?.[0] ||
null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
@@ -263,35 +269,32 @@ export function CapturesTab() {
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| undefined;
const result = await apiClient.generateSpeech({
return apiClient.generateSpeech({
profile_id: voice.id,
text,
language,
engine,
});
return { capture, voice, result };
},
onSuccess: ({ capture, voice, result }) => {
if (result.audio_path && result.id) {
setAudioWithAutoPlay(
apiClient.getAudioUrl(result.id),
result.id,
voice.id,
t('captures.playerVoiceLabel', { voice: voice.name, captureId: capture.id.slice(0, 8) }),
);
setPlaybackState('playing');
}
onSuccess: (result) => {
// /generate is queue-based — it returns a generating row with an empty
// audio_path. Hand the id to the global SSE handler which polls
// /generation/{id}/status and triggers autoplay on completion.
setLaunchedPlayAsId(result.id);
addPendingGeneration(result.id);
},
onError: (err: Error) => {
setPlaybackState('idle');
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
},
});
// Pull playback state back to idle when the player closes out.
useEffect(() => {
if (!audioUrl) setPlaybackState('idle');
}, [audioUrl]);
const playbackState: PlaybackState = playAsMutation.isPending
? 'generating'
: launchedPlayAsId && pendingGenerationIds.has(launchedPlayAsId)
? 'generating'
: launchedPlayAsId && playerAudioId === launchedPlayAsId && playerIsPlaying
? 'playing'
: 'idle';
const handleUploadClick = () => uploadInputRef.current?.click();
@@ -416,8 +419,9 @@ export function CapturesTab() {
});
return;
}
if (voice && voice.id !== playAsVoiceId) setPlayAsVoiceId(voice.id);
setPlaybackState('generating');
if (voice && voice.id !== playAsVoiceId) {
updateCaptureSettings({ default_playback_voice_id: voice.id });
}
playAsMutation.mutate({ capture: selected, voice: target });
};
@@ -691,12 +695,12 @@ export function CapturesTab() {
className={cn(
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15',
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
>
{playbackState === 'generating' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<AudioBars mode="generating" className="h-3.5" />
{t('captures.actions.playAsGenerating')}
</>
) : playbackState === 'playing' ? (
@@ -723,7 +727,7 @@ export function CapturesTab() {
className={cn(
'rounded-l-none px-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 bg-accent/10 hover:bg-accent/15',
'border-accent/50 bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
disabled={!profiles || !profiles.length}
>
+1 -31
View File
@@ -16,6 +16,7 @@ import {
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
@@ -57,37 +58,6 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
// ─── Audio Bars ─────────────────────────────────────────────────────────────
function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className="flex items-center gap-[2px] h-5">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={`w-[3px] rounded-full ${barColor}`}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
export function HistoryTable() {
const { t } = useTranslation();
const [page, setPage] = useState(0);
+1 -34
View File
@@ -34,25 +34,6 @@ import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
import { SettingRow, SettingSection } from './SettingRow';
const VOICE_GRADIENTS = [
'from-blue-400 to-indigo-500',
'from-emerald-400 to-teal-500',
'from-purple-500 to-fuchsia-500',
'from-amber-400 to-rose-500',
'from-rose-400 to-pink-500',
'from-cyan-400 to-sky-500',
];
function voiceGradient(voiceId: string): string {
// Stable hash so the same voice always renders with the same gradient —
// avoids the avatar flicker that would happen if we picked by index.
let hash = 0;
for (let i = 0; i < voiceId.length; i += 1) {
hash = (hash * 31 + voiceId.charCodeAt(i)) | 0;
}
return VOICE_GRADIENTS[Math.abs(hash) % VOICE_GRADIENTS.length];
}
function ChordPreview({ keys }: { keys: string[] }) {
const { t } = useTranslation();
if (keys.length === 0) {
@@ -514,15 +495,7 @@ export function CapturesPage() {
>
<div className="flex items-center gap-2 min-w-0">
{defaultVoice ? (
<>
<div
className={cn(
'h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(defaultVoice.id),
)}
/>
<span className="truncate">{defaultVoice.name}</span>
</>
<span className="truncate">{defaultVoice.name}</span>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0
@@ -545,12 +518,6 @@ export function CapturesPage() {
onClick={() => update({ default_playback_voice_id: v.id })}
className="gap-2.5 py-2"
>
<div
className={cn(
'h-7 w-7 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10',
voiceGradient(v.id),
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
{v.description ? (
-1
View File
@@ -29,7 +29,6 @@
"snippetEmpty": "(no transcript)",
"noTranscriptError": "Capture has no transcript yet",
"captureCardLabel": "Capture · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"header": {
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
},
-1
View File
@@ -29,7 +29,6 @@
"snippetEmpty": "(文字起こしなし)",
"noTranscriptError": "このキャプチャにはまだ文字起こしがありません",
"captureCardLabel": "キャプチャ · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"header": {
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
},
@@ -29,7 +29,6 @@
"snippetEmpty": "(暂无转录)",
"noTranscriptError": "此次捕获尚无转录文本",
"captureCardLabel": "捕获 · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"header": {
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
},
@@ -29,7 +29,6 @@
"snippetEmpty": "(無轉錄文字)",
"noTranscriptError": "此擷取尚無轉錄文字",
"captureCardLabel": "擷取 · {{when}}",
"playerVoiceLabel": "{{voice}} · {{captureId}}",
"header": {
"modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}"
},
+13 -1
View File
@@ -215,7 +215,19 @@ async def cancel_generation(generation_id: str, db: Session = Depends(get_db)):
cancellation_state = cancel_generation_job(generation_id)
if cancellation_state is None:
raise HTTPException(status_code=409, detail="Generation is no longer cancellable")
# Row says active but the worker is no longer tracking it — the gen
# coroutine exited without writing a terminal status (most often a
# SQLite lock racing with the failed-status write inside the worker's
# exception handler). Fail the row here so the user can move on.
task_manager = get_task_manager()
task_manager.complete_generation(generation_id)
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=db,
error="Generation orphaned by worker",
)
return {"message": "Orphaned generation cleared"}
if cancellation_state == "queued":
task_manager = get_task_manager()
+31
View File
@@ -56,12 +56,43 @@ async def _generation_worker():
raise
except Exception:
traceback.print_exc()
await _force_fail_if_active(
job.generation_id,
"Worker exited without writing terminal status",
)
finally:
_running_generation_tasks.pop(job.generation_id, None)
_queued_generation_ids.discard(job.generation_id)
_generation_queue.task_done()
async def _force_fail_if_active(generation_id: str, error: str) -> None:
"""Best-effort recovery — flip an active row to failed if the worker
bailed before writing a terminal status. Catches the case where the gen
coroutine's own status-write raised (e.g. SQLite lock contention)."""
try:
from ..database import Generation as DBGeneration, get_db
from . import history
db = next(get_db())
try:
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if gen is None:
return
if (gen.status or "completed") not in ("loading_model", "generating"):
return
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=db,
error=error,
)
finally:
db.close()
except Exception:
traceback.print_exc()
def enqueue_generation(generation_id: str, coro):
"""Add a generation coroutine to the serial queue."""
if _generation_queue is None: