Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/apps/voice_notes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def transcribe_voice_note_task(self, note_id: int, language: str = 'auto') -> No
duration = AudioProcessingService.get_audio_duration(note.audio_file)
if duration:
note.duration = timedelta(seconds=duration)
# Persist duration before the (slow) transcription call so the UI can
# show the clip length and a meaningful estimate while it processes.
note.save(update_fields=['duration'])

result = transcription_service.transcribe_audio(note.audio_file, language)

Expand Down
56 changes: 48 additions & 8 deletions frontend/src/pages/NoteDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,26 @@ const NoteDetailPage: React.FC = () => {
() => voiceNotesAPI.get(noteId),
{
enabled: !!noteId,
// Poll while transcription is in flight so the page updates itself; stop
// once it reaches a terminal state.
refetchInterval: (data) => (data?.data?.status === 'processing' ? 2500 : false),
onError: () => {
toast.error('Failed to load voice note');
},
}
);

// Elapsed-time ticker for the processing indicator.
const isProcessing = noteData?.data?.status === 'processing';
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isProcessing) return;
const start = Date.now();
setElapsed(0);
const timer = setInterval(() => setElapsed((Date.now() - start) / 1000), 500);
return () => clearInterval(timer);
}, [isProcessing]);

const { data: foldersData } = useQuery(['folders'], () => foldersAPI.list());
const folders = foldersData?.data || [];

Expand Down Expand Up @@ -349,14 +363,40 @@ const NoteDetailPage: React.FC = () => {
)}
</div>

{note.status === 'processing' && (
<div className="flex items-center justify-center py-8">
<LoadingSpinner className="mr-2" />
<span className="text-on-surface-variant text-sm">
Transcribing your audio... This may take a few minutes.
</span>
</div>
)}
{note.status === 'processing' && (() => {
// Optimistic progress: ramps smoothly toward 90% and completes when
// the poll reports done. Deepgram's batch API gives no true %, so this
// conveys activity + elapsed time without claiming a false figure.
const progress = Math.min(90, 90 * (1 - Math.exp(-elapsed / 8)));
const clip = note.duration ? formatTimestamp(parseDurationToSeconds(note.duration)) : null;
return (
<div className="py-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-on-surface">Transcribing your audio…</span>
<span className="text-xs font-mono text-on-surface-variant uppercase tracking-wider">
{formatTimestamp(elapsed)} elapsed
</span>
</div>
<div
className="w-full bg-surface-container-lowest rounded-full h-2 overflow-hidden"
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Transcription progress"
>
<div
className="bg-gradient-to-r from-primary to-primary-container h-2 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-on-surface-variant mt-3">
Transcribing speech and identifying speakers{clip ? ` in your ${clip} recording` : ''}.
Short clips finish in a few seconds; longer recordings take a bit more.
</p>
</div>
);
})()}

{note.status === 'failed' && (
<div className="text-center py-8">
Expand Down
Loading