diff --git a/app/src/components/AudioBars.tsx b/app/src/components/AudioBars.tsx new file mode 100644 index 00000000..69601ccc --- /dev/null +++ b/app/src/components/AudioBars.tsx @@ -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 ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ); +} diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx index bec4b791..525cdb39 100644 --- a/app/src/components/CapturesTab/CapturesTab.tsx +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -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(null); const [search, setSearch] = useState(''); const [showRefined, setShowRefined] = useState(true); - const [playAsVoiceId, setPlayAsVoiceId] = useState(null); - const [playbackState, setPlaybackState] = useState('idle'); + const [launchedPlayAsId, setLaunchedPlayAsId] = useState(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' ? ( <> - + {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} > diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 0aa26e07..aeeae4ec 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -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 ( -
- {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
- ); -} - -// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { const { t } = useTranslation(); const [page, setPage] = useState(0); diff --git a/app/src/components/ServerTab/CapturesPage.tsx b/app/src/components/ServerTab/CapturesPage.tsx index 4c2cb4ff..b11aaaa4 100644 --- a/app/src/components/ServerTab/CapturesPage.tsx +++ b/app/src/components/ServerTab/CapturesPage.tsx @@ -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() { >
{defaultVoice ? ( - <> -
- {defaultVoice.name} - + {defaultVoice.name} ) : ( {voices.length === 0 @@ -545,12 +518,6 @@ export function CapturesPage() { onClick={() => update({ default_playback_voice_id: v.id })} className="gap-2.5 py-2" > -
{v.name}
{v.description ? ( diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 1f746911..53bcef4f 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -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}}" }, diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index 7e307b83..f9531531 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -29,7 +29,6 @@ "snippetEmpty": "(文字起こしなし)", "noTranscriptError": "このキャプチャにはまだ文字起こしがありません", "captureCardLabel": "キャプチャ · {{when}}", - "playerVoiceLabel": "{{voice}} · {{captureId}}", "header": { "modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}" }, diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index 376eff9b..4df9eadc 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -29,7 +29,6 @@ "snippetEmpty": "(暂无转录)", "noTranscriptError": "此次捕获尚无转录文本", "captureCardLabel": "捕获 · {{when}}", - "playerVoiceLabel": "{{voice}} · {{captureId}}", "header": { "modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}" }, diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index 5cd0cf9f..c7388ae1 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -29,7 +29,6 @@ "snippetEmpty": "(無轉錄文字)", "noTranscriptError": "此擷取尚無轉錄文字", "captureCardLabel": "擷取 · {{when}}", - "playerVoiceLabel": "{{voice}} · {{captureId}}", "header": { "modelSummary": "Whisper {{stt}} · Qwen3 · {{llm}}" }, diff --git a/backend/routes/generations.py b/backend/routes/generations.py index a79e8492..5b837905 100644 --- a/backend/routes/generations.py +++ b/backend/routes/generations.py @@ -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() diff --git a/backend/services/task_queue.py b/backend/services/task_queue.py index 9177a5c6..3ec42377 100644 --- a/backend/services/task_queue.py +++ b/backend/services/task_queue.py @@ -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: