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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"@tanstack/react-start": "^1.159.5",
"@tanstack/router-plugin": "^1.159.5",
"@unpic/react": "^1.0.2",
"@uppy/core": "^5.2.0",
"@uppy/tus": "^5.1.1",
"chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"drizzle-orm": "^0.44.7",
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/transcription/transcript-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,14 @@ export function FileInfo({
onRemove,
isUploading,
isProcessing,
uploadProgress,
}: {
fileName: string;
fileSize: number;
onRemove: () => void;
isUploading?: boolean;
isProcessing?: boolean;
uploadProgress?: number;
}) {
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
Expand All @@ -122,7 +124,9 @@ export function FileInfo({
{fileName}
</p>
<p className="text-xs text-neutral-500">
{isUploading ? "Uploading..." : formatSize(fileSize)}
{isUploading
? `Uploading... ${uploadProgress != null ? `${Math.round(uploadProgress)}%` : ""}`
: formatSize(fileSize)}
</p>
</div>
</div>
Expand Down
199 changes: 199 additions & 0 deletions apps/web/src/hooks/use-audio-uppy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import Uppy, { type UploadResult } from "@uppy/core";
import Tus from "@uppy/tus";
import { useEffect, useMemo, useRef, useState } from "react";

import {
buildObjectName,
getTusEndpoint,
STORAGE_CONFIG,
} from "@hypr/supabase/storage";

import { env } from "@/env";
import { getSupabaseBrowserClient } from "@/functions/supabase";

async function getAuthHeaders(): Promise<Record<string, string>> {
const supabase = getSupabaseBrowserClient();
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
if (!token) throw new Error("Not authenticated");
return {
authorization: `Bearer ${token}`,
"x-upsert": "true",
};
}

type UploadState = {
status: "idle" | "uploading" | "done" | "error";
progress: number;
fileId: string | null;
error: string | null;
};

export function useAudioUppy() {
const [state, setState] = useState<UploadState>({
status: "idle",
progress: 0,
fileId: null,
error: null,
});

const uppyRef = useRef<Uppy | null>(null);
const generationRef = useRef(0);
const activeGenerationRef = useRef(0);

const uppy = useMemo(() => {
const instance = new Uppy({
restrictions: {
maxNumberOfFiles: 1,
allowedFileTypes: ["audio/*"],
},
autoProceed: false,
});

instance.use(Tus, {
endpoint: getTusEndpoint(env.VITE_SUPABASE_URL!),
chunkSize: STORAGE_CONFIG.chunkSize,
retryDelays: [...STORAGE_CONFIG.retryDelays],
uploadDataDuringCreation: true,
removeFingerprintOnSuccess: true,
allowedMetaFields: [
"bucketName",
"objectName",
"contentType",
"cacheControl",
],
onBeforeRequest: async (req) => {
const headers = await getAuthHeaders();
for (const [key, value] of Object.entries(headers)) {
req.setHeader(key, value);
}
},
onShouldRetry: (err, _retryAttempt, _options, next) => next(err),
});

uppyRef.current = instance;
return instance;
}, []);

useEffect(() => {
const onFileAdded = async (file: {
id: string;
name: string;
type?: string;
}) => {
const generation = generationRef.current;
const supabase = getSupabaseBrowserClient();
const { data } = await supabase.auth.getSession();
if (generation !== generationRef.current) return;

const userId = data?.session?.user?.id;
if (!userId) {
setState((prev) => ({
...prev,
status: "error",
error: "Not authenticated",
}));
return;
}

const objectName = buildObjectName(userId, file.name);
uppy.setFileMeta(file.id, {
bucketName: STORAGE_CONFIG.bucketName,
objectName,
contentType: file.type || "audio/mpeg",
cacheControl: "3600",
});

activeGenerationRef.current = generationRef.current;

setState({
status: "uploading",
progress: 0,
fileId: objectName,
error: null,
});

uppy.upload();
};

const onProgress = (progress: number) => {
if (generationRef.current !== activeGenerationRef.current) return;
setState((prev) => ({ ...prev, progress }));
};

const onComplete = (
result: UploadResult<Record<string, unknown>, Record<string, never>>,
) => {
if (generationRef.current !== activeGenerationRef.current) return;
if (result.failed && result.failed.length > 0) {
setState((prev) => ({
...prev,
status: "error",
error: "Upload failed",
}));
} else {
setState((prev) => ({ ...prev, status: "done", progress: 100 }));
}
};

const onUploadError = (_file: unknown, error: Error) => {
if (generationRef.current !== activeGenerationRef.current) return;
setState((prev) => ({ ...prev, status: "error", error: error.message }));
};

const onError = (error: Error) => {
if (generationRef.current !== activeGenerationRef.current) return;
setState((prev) => ({
...prev,
status: "error",
error: error.message,
}));
};

uppy.on("file-added", onFileAdded);
uppy.on("progress", onProgress);
uppy.on("complete", onComplete);
uppy.on("error", onError);
uppy.on("upload-error", onUploadError);

return () => {
uppy.off("file-added", onFileAdded);
uppy.off("progress", onProgress);
uppy.off("complete", onComplete);
uppy.off("error", onError);
uppy.off("upload-error", onUploadError);
};
}, [uppy]);

useEffect(() => {
return () => {
uppyRef.current?.cancelAll();
};
}, []);

const addFile = (file: File) => {
generationRef.current++;
uppy.cancelAll();
setState({ status: "idle", progress: 0, fileId: null, error: null });
uppy.addFile({
name: file.name,
type: file.type,
data: file,
});
};

const reset = () => {
generationRef.current++;
uppy.cancelAll();
setState({ status: "idle", progress: 0, fileId: null, error: null });
};

return {
addFile,
reset,
status: state.status,
progress: state.progress,
fileId: state.fileId,
error: state.error,
};
}
57 changes: 22 additions & 35 deletions apps/web/src/routes/_view/app/file-transcription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Play } from "lucide-react";
import { useEffect, useMemo, useState } from "react";

import type { SttStatusResponse } from "@hypr/api-client";
import { uploadAudio } from "@hypr/supabase/storage";
import NoteEditor, { type JSONContent } from "@hypr/tiptap/editor";
import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared";
import "@hypr/tiptap/styles.css";
Expand All @@ -17,6 +16,7 @@ import {
import { UploadArea } from "@/components/transcription/upload-area";
import { env } from "@/env";
import { getSupabaseBrowserClient } from "@/functions/supabase";
import { useAudioUppy } from "@/hooks/use-audio-uppy";

const API_URL = env.VITE_API_URL;

Expand Down Expand Up @@ -74,12 +74,20 @@ function Component() {
const { id: searchId } = Route.useSearch();

const [file, setFile] = useState<File | null>(null);
const [fileId, setFileId] = useState<string | null>(null);
const [pipelineId, setPipelineId] = useState<string | null>(searchId ?? null);
const [transcript, setTranscript] = useState<string | null>(null);
const [noteContent, setNoteContent] = useState<JSONContent>(EMPTY_TIPTAP_DOC);
const [isMounted, setIsMounted] = useState(false);

const {
addFile: uppyAddFile,
reset: uppyReset,
status: uppyStatus,
progress: uppyProgress,
fileId: uppyFileId,
error: uppyError,
} = useAudioUppy();

useEffect(() => {
setIsMounted(true);
}, []);
Expand All @@ -94,26 +102,6 @@ function Component() {
return session;
};

const uploadMutation = useMutation({
mutationFn: async (selectedFile: File) => {
const session = await getAccessToken();

const { promise } = uploadAudio({
file: selectedFile,
fileName: selectedFile.name,
contentType: selectedFile.type,
supabaseUrl: env.VITE_SUPABASE_URL!,
accessToken: session.access_token,
userId: session.user.id,
});

return promise;
},
onSuccess: (newFileId) => {
setFileId(newFileId);
},
});

const startPipelineMutation = useMutation({
mutationFn: async (fileIdArg: string) => {
const session = await getAccessToken();
Expand Down Expand Up @@ -171,19 +159,20 @@ function Component() {
if (pipelineStatus === "QUEUED" || pipelineId) {
return "queued" as const;
}
if (uploadMutation.isPending) {
if (uppyStatus === "uploading") {
return "uploading" as const;
}
if (fileId) {
if (uppyStatus === "error") {
return "error" as const;
}
if (uppyStatus === "done" && uppyFileId) {
return "uploaded" as const;
}
return "idle" as const;
})();

const errorMessage =
(uploadMutation.error instanceof Error
? uploadMutation.error.message
: null) ??
uppyError ??
(startPipelineMutation.error instanceof Error
? startPipelineMutation.error.message
: null) ??
Expand All @@ -196,27 +185,24 @@ function Component() {

const handleFileSelect = (selectedFile: File) => {
setFile(selectedFile);
setFileId(null);
setPipelineId(null);
setTranscript(null);
uploadMutation.reset();
startPipelineMutation.reset();
uploadMutation.mutate(selectedFile);
uppyAddFile(selectedFile);
};

const handleStartTranscription = () => {
if (!fileId) return;
startPipelineMutation.mutate(fileId);
if (!uppyFileId) return;
startPipelineMutation.mutate(uppyFileId);
};

const handleRemoveFile = () => {
setFile(null);
setFileId(null);
setPipelineId(null);
setTranscript(null);
setNoteContent(EMPTY_TIPTAP_DOC);
uploadMutation.reset();
startPipelineMutation.reset();
uppyReset();
};

const mentionConfig = useMemo(
Expand Down Expand Up @@ -286,8 +272,9 @@ function Component() {
fileName={file.name}
fileSize={file.size}
onRemove={handleRemoveFile}
isUploading={uploadMutation.isPending}
isUploading={uppyStatus === "uploading"}
isProcessing={isProcessing}
uploadProgress={uppyProgress}
/>
{status === "uploaded" && (
<button
Expand Down
Loading
Loading