diff --git a/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts b/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts index cee69d39..f22533a3 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts @@ -92,7 +92,7 @@ export type UseAudioRecordingType = ReturnType; // ── Helpers ── -const blobToWav = async (blob: Blob): Promise => { +export const blobToWav = async (blob: Blob): Promise => { const ctx = new AudioContext(); const buf = await ctx.decodeAudioData(await blob.arrayBuffer()); await ctx.close(); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx index b6cf6742..f22bf1d5 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx @@ -4,6 +4,7 @@ import { UploadProgress } from './components/UploadProgress'; import { FileViewContainer } from './components/FileViewContainer'; import { TaskRunnerDialog } from './components/TaskRunnerDialog'; import { VideoDownloadDialog } from './components/VideoDownloadDialog'; +import { DictateDialog } from './components/DictateDialog'; import { useFileBrowserApp } from './useFileBrowserApp'; type DefaultSort = { @@ -32,6 +33,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa + ); }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx new file mode 100644 index 00000000..c200d6b3 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { toast } from 'sonner'; +import { blobToWav } from '../../../Chat/useAudioRecording'; +import type { UseFileBrowserAppType } from '../useFileBrowserApp'; + +type DictateDialogProps = { + fileBrowserManager: UseFileBrowserAppType; +}; + +export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => { + const { showDictate, setShowDictate, handleUpload } = fileBrowserManager; + const [recording, setRecording] = useState(false); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const canvasRef = useRef(null); + const analyserRef = useRef(null); + const audioCtxRef = useRef(null); + const animFrameRef = useRef(0); + const streamRef = useRef(null); + + const cleanup = () => { + cancelAnimationFrame(animFrameRef.current); + mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop()); + streamRef.current?.getTracks().forEach((t) => t.stop()); + audioCtxRef.current?.close(); + mediaRecorderRef.current = null; + streamRef.current = null; + audioCtxRef.current = null; + analyserRef.current = null; + chunksRef.current = []; + setRecording(false); + }; + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const audioCtx = new AudioContext(); + audioCtxRef.current = audioCtx; + const source = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + analyserRef.current = analyser; + + const recorder = new MediaRecorder(stream); + mediaRecorderRef.current = recorder; + chunksRef.current = []; + recorder.ondataavailable = (ev) => { + if (ev.data.size > 0) chunksRef.current.push(ev.data); + }; + recorder.start(250); + setRecording(true); + + drawSpectrum(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Could not access microphone'); + setShowDictate(false); + } + }; + + const drawSpectrum = () => { + const canvas = canvasRef.current; + const analyser = analyserRef.current; + if (!canvas || !analyser) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + const draw = () => { + animFrameRef.current = requestAnimationFrame(draw); + analyser.getByteFrequencyData(dataArray); + + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + const barWidth = (w / bufferLength) * 2; + let x = 0; + for (let i = 0; i < bufferLength; i++) { + const barHeight = (dataArray[i]! / 255) * h; + ctx.fillStyle = `hsl(174, 60%, ${40 + (dataArray[i]! / 255) * 30}%)`; + ctx.fillRect(x, h - barHeight, barWidth - 1, barHeight); + x += barWidth; + } + }; + + draw(); + }; + + const stopRecording = async () => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state === 'inactive') { + cleanup(); + return; + } + + try { + const blob = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000); + recorder.onstop = () => { + clearTimeout(timeout); + resolve(new Blob(chunksRef.current, { type: recorder.mimeType })); + chunksRef.current = []; + }; + recorder.stop(); + }); + + cancelAnimationFrame(animFrameRef.current); + recorder.stream.getTracks().forEach((t) => t.stop()); + streamRef.current?.getTracks().forEach((t) => t.stop()); + audioCtxRef.current?.close(); + + if (blob.size === 0) { + toast.error('No audio was captured'); + cleanup(); + setShowDictate(false); + return; + } + + const wav = await blobToWav(blob); + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const file = new File([wav], `dictation_${ts}.wav`, { type: 'audio/wav' }); + + setShowDictate(false); + setRecording(false); + await handleUpload([file]); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Recording failed'); + cleanup(); + setShowDictate(false); + } + }; + + useEffect(() => { + if (showDictate) { + startRecording(); + } + return () => { + if (!showDictate) cleanup(); + }; + }, [showDictate]); + + useEffect(() => { + if (!showDictate || !recording) return; + const handler = (ev: KeyboardEvent) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + stopRecording(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [showDictate, recording]); + + return ( + { + if (!open) { + cleanup(); + setShowDictate(false); + } + }} + > + + + Dictate + Recording audio. Press Enter or click Stop to save. + +
+ +
+ {recording && } + {recording ? 'Recording...' : 'Starting...'} +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index 6e5356f6..80b36dce 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,4 +1,4 @@ -import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react'; +import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; @@ -28,6 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps handleCreateDir, handleCreateDashboardHere, setShowVideoDownload, + setShowDictate, } = fileBrowserManager; return ( @@ -128,6 +129,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Download video + setShowDictate(true)} className="cursor-pointer"> + + Dictate + )} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index f42558e0..1a8b6504 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -43,6 +43,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi const [showVideoDownload, setShowVideoDownload] = useState(false); const [videoUrl, setVideoUrl] = useState(''); const [audioOnly, setAudioOnly] = useState(false); + const [showDictate, setShowDictate] = useState(false); const dragCounter = useRef(0); const { getMatchingTasks } = useTasks(); const searchTimerRef = useRef | null>(null); @@ -688,6 +689,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi showVideoDownload, setShowVideoDownload, videoUrl, setVideoUrl, audioOnly, setAudioOnly, + // Dictate + showDictate, setShowDictate, // Refs fileScrollRef, // Handlers