Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
192 changes: 192 additions & 0 deletions apps/web/src/hooks/use-audio-uppy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 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",
});

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

uppy.upload();
};

const onProgress = (progress: number) => {
setState((prev) => ({ ...prev, progress }));
};

const onComplete = (
result: UploadResult<Record<string, unknown>, Record<string, never>>,
) => {
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) => {
setState((prev) => ({ ...prev, status: "error", error: error.message }));
};

const onError = (error: Error) => {
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