├── demo.mp4 ├── next.config.mjs ├── .prettierrc ├── postcss.config.mjs ├── middleware.ts ├── lib ├── utils.ts ├── redis.server.ts └── storage.server.ts ├── components.json ├── .env.example ├── .gitignore ├── .prettierignore ├── tsconfig.json ├── app ├── layout.tsx ├── api │ ├── get │ │ └── route.ts │ ├── history │ │ └── route.ts │ ├── upload │ │ └── route.ts │ ├── schedule │ │ └── route.ts │ └── transcribe │ │ └── route.ts ├── globals.css └── page.tsx ├── components └── ui │ ├── toaster.tsx │ ├── input.tsx │ ├── tooltip.tsx │ ├── button.tsx │ ├── use-toast.ts │ └── toast.tsx ├── package.json ├── tailwind.config.ts └── README.md /demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/transcriber/master/demo.mp4 -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | export default nextConfig 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 180, 4 | "singleQuote": true, 5 | "plugins": ["prettier-plugin-tailwindcss"] 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware } from '@clerk/nextjs/server' 2 | 3 | export default clerkMiddleware() 4 | 5 | export const config = { 6 | matcher: ['/((?!.*\\..*|_next).*)'], 7 | } 8 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /lib/redis.server.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis' 2 | 3 | const redis = new Redis({ 4 | url: process.env.UPSTASH_REDIS_REST_URL, 5 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 6 | }) 7 | 8 | export default redis 9 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FIREWORKS_API_KEY="..." 2 | AWS_KEY_ID="..." 3 | AWS_REGION_NAME="auto" 4 | AWS_S3_BUCKET_NAME="..." 5 | AWS_SECRET_ACCESS_KEY="..." 6 | CLOUDFLARE_R2_ACCOUNT_ID="..." 7 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..." 8 | CLERK_SECRET_KEY="sk_test_..." 9 | QSTASH_TOKEN="..." 10 | QSTASH_CURRENT_SIGNING_KEY="sig_..." 11 | QSTASH_NEXT_SIGNING_KEY="sig_..." 12 | DEPLOYMENT_URL="https://...vercel.app" 13 | UPSTASH_REDIS_REST_URL="https://...upstash.io" 14 | UPSTASH_REDIS_REST_TOKEN="..." 15 | RANDOM_SEPERATOR="4444Kdav" 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | **/*.md* 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from '@/components/ui/toaster' 2 | import { ClerkProvider } from '@clerk/nextjs' 3 | import type { Metadata } from 'next' 4 | import { Inter } from 'next/font/google' 5 | import './globals.css' 6 | 7 | const inter = Inter({ subsets: ['latin'] }) 8 | 9 | export const metadata: Metadata = { 10 | title: 'Create Next App', 11 | description: 'Generated by create next app', 12 | } 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode 18 | }>) { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/api/get/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'nodejs' 2 | 3 | export const dynamic = 'force-dynamic' 4 | 5 | export const fetchCache = 'force-no-store' 6 | 7 | import { getS3Object } from '@/lib/storage.server' 8 | import { auth } from '@clerk/nextjs/server' 9 | import { type NextRequest } from 'next/server' 10 | 11 | export async function GET(request: NextRequest) { 12 | const { userId } = auth() 13 | if (!userId) return new Response(null, { status: 403 }) 14 | const searchParams = request.nextUrl.searchParams 15 | const fileName = searchParams.get('fileName') 16 | if (!fileName) return new Response(null, { status: 400 }) 17 | if (!fileName.startsWith(userId)) return new Response(null, { status: 403 }) 18 | const signedUrl = await getS3Object(fileName) 19 | return new Response(signedUrl) 20 | } 21 | -------------------------------------------------------------------------------- /app/api/history/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'nodejs' 2 | 3 | export const dynamic = 'force-dynamic' 4 | 5 | export const fetchCache = 'force-no-store' 6 | 7 | import redis from '@/lib/redis.server' 8 | import { auth } from '@clerk/nextjs/server' 9 | import { NextResponse, type NextRequest } from 'next/server' 10 | 11 | export async function GET(request: NextRequest) { 12 | const { userId } = auth() 13 | if (!userId) return new Response(null, { status: 403 }) 14 | const count = 10 15 | const audioNames = [] 16 | const searchParams = request.nextUrl.searchParams 17 | const start = parseInt(searchParams.get('start') ?? '0') 18 | const [_, items] = await redis.hscan(userId, start, { count }) 19 | for (let i = 0; i < items.length; i += 2) audioNames.push({ key: items[i], value: items[i + 1] }) 20 | return NextResponse.json(audioNames) 21 | } 22 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast' 4 | import { useToast } from '@/components/ui/use-toast' 5 | 6 | export function Toaster() { 7 | const { toasts } = useToast() 8 | return ( 9 | 10 | {toasts.map(function ({ id, title, description, action, ...props }) { 11 | return ( 12 | 13 |
14 | {title && {title}} 15 | {description && {description}} 16 |
17 | {action} 18 | 19 |
20 | ) 21 | })} 22 | 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'nodejs' 2 | 3 | export const dynamic = 'force-dynamic' 4 | 5 | export const fetchCache = 'force-no-store' 6 | 7 | import { uploadS3Object } from '@/lib/storage.server' 8 | import { auth } from '@clerk/nextjs/server' 9 | import { NextResponse, type NextRequest } from 'next/server' 10 | 11 | export async function GET(request: NextRequest) { 12 | const { userId } = auth() 13 | if (!userId) return new Response(null, { status: 403 }) 14 | const searchParams = request.nextUrl.searchParams 15 | const fileName = searchParams.get('fileName') 16 | const contentType = searchParams.get('contentType') 17 | if (!fileName || !contentType) return new Response(null, { status: 500 }) 18 | const signedObject = await uploadS3Object(`${userId}${process.env.RANDOM_SEPERATOR}${fileName}`, contentType) 19 | return NextResponse.json(signedObject) 20 | } 21 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | import * as React from 'react' 3 | 4 | export interface InputProps extends React.InputHTMLAttributes {} 5 | 6 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | }) 19 | Input.displayName = 'Input' 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /app/api/schedule/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'nodejs' 2 | 3 | export const dynamic = 'force-dynamic' 4 | 5 | export const fetchCache = 'force-no-store' 6 | 7 | import redis from '@/lib/redis.server' 8 | import { Client } from '@upstash/qstash' 9 | 10 | if (!process.env.QSTASH_TOKEN) throw new Error(`QSTASH_TOKEN environment variable is not found.`) 11 | 12 | const client = new Client({ token: process.env.QSTASH_TOKEN }) 13 | 14 | export async function POST(request: Request) { 15 | const { fileName } = await request.json() 16 | await Promise.all([ 17 | client.publishJSON({ 18 | delay: 10, 19 | body: { fileName }, 20 | url: `${process.env.DEPLOYMENT_URL}/api/transcribe`, 21 | }), 22 | redis.hset(fileName.split(process.env.RANDOM_SEPERATOR)[0], { 23 | [fileName.split(process.env.RANDOM_SEPERATOR)[1]]: { 24 | transcribed: false, 25 | }, 26 | }), 27 | ]) 28 | return new Response() 29 | } 30 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | } 34 | 35 | @layer base { 36 | * { 37 | @apply border-border; 38 | } 39 | body { 40 | @apply bg-background text-foreground; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transcriber", 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "fmt": "prettier --write '**/*' --ignore-unknown" 8 | }, 9 | "dependencies": { 10 | "@aws-sdk/client-s3": "^3.627.0", 11 | "@aws-sdk/s3-request-presigner": "^3.627.0", 12 | "@clerk/nextjs": "^5.3.0", 13 | "@radix-ui/react-popover": "^1.1.1", 14 | "@radix-ui/react-slot": "^1.1.0", 15 | "@radix-ui/react-toast": "^1.2.1", 16 | "@radix-ui/react-tooltip": "^1.1.2", 17 | "@upstash/qstash": "^2.6.3", 18 | "@upstash/redis": "^1.34.0", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.1.1", 21 | "form-data": "^4.0.0", 22 | "lucide-react": "^0.427.0", 23 | "next": "14.2.5", 24 | "node-fetch": "^3.3.2", 25 | "react": "^18", 26 | "react-dom": "^18", 27 | "streamifier": "^0.1.1", 28 | "tailwind-merge": "^2.4.0", 29 | "tailwindcss-animate": "^1.0.7", 30 | "uuid": "^10.0.0" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18", 34 | "@types/react-dom": "^18", 35 | "postcss": "^8", 36 | "prettier": "^3.3.3", 37 | "prettier-plugin-tailwindcss": "^0.6.6", 38 | "tailwindcss": "^3.4.1", 39 | "typescript": "^5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 5 | import * as React from 'react' 6 | 7 | const TooltipProvider = TooltipPrimitive.Provider 8 | 9 | const Tooltip = TooltipPrimitive.Root 10 | 11 | const TooltipTrigger = TooltipPrimitive.Trigger 12 | 13 | const TooltipContent = React.forwardRef, React.ComponentPropsWithoutRef>( 14 | ({ className, sideOffset = 4, ...props }, ref) => ( 15 | 24 | ), 25 | ) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } 29 | -------------------------------------------------------------------------------- /lib/storage.server.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, PutObjectCommand, S3Client, S3ClientConfig } from '@aws-sdk/client-s3' 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner' 3 | 4 | const accessKeyId = process.env.AWS_KEY_ID 5 | const region = process.env.AWS_REGION_NAME 6 | const s3BucketName = process.env.AWS_S3_BUCKET_NAME 7 | const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY 8 | const r2AccountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID 9 | 10 | let client: S3Client 11 | 12 | function getS3Client() { 13 | if (!accessKeyId || !secretAccessKey) throw new Error(`Access key and Secret Access Key environment variables are not defined.`) 14 | if (client) return 15 | let s3Config: S3ClientConfig = { 16 | credentials: { 17 | accessKeyId, 18 | secretAccessKey, 19 | }, 20 | } 21 | if (region) s3Config = { ...s3Config, region } 22 | if (r2AccountId) s3Config = { ...s3Config, endpoint: `https://${r2AccountId}.r2.cloudflarestorage.com` } 23 | client = new S3Client(s3Config) 24 | } 25 | 26 | export async function getS3Object(Key: string) { 27 | getS3Client() 28 | const command = new GetObjectCommand({ 29 | Key, 30 | Bucket: s3BucketName, 31 | }) 32 | return await getSignedUrl(client, command, { expiresIn: 3600 }) 33 | } 34 | 35 | export async function uploadS3Object(Key: string, type: string) { 36 | getS3Client() 37 | const command = new PutObjectCommand({ 38 | Key, 39 | ContentType: type, 40 | Bucket: s3BucketName, 41 | }) 42 | const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }) 43 | return [signedUrl, Key] 44 | } 45 | -------------------------------------------------------------------------------- /app/api/transcribe/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'nodejs' 2 | 3 | export const dynamic = 'force-dynamic' 4 | 5 | export const fetchCache = 'force-no-store' 6 | 7 | import redis from '@/lib/redis.server' 8 | import { getS3Object } from '@/lib/storage.server' 9 | import { verifySignatureAppRouter } from '@upstash/qstash/dist/nextjs' 10 | import FormData from 'form-data' 11 | import fetch from 'node-fetch' 12 | 13 | export const POST = verifySignatureAppRouter(handler) 14 | 15 | async function handler(request: Request) { 16 | const { fileName } = await request.json() 17 | const url = await getS3Object(fileName) 18 | const response = await fetch(url) 19 | if (!response.ok) throw new Error(`Failed to fetch audio file: ${response.statusText}.`) 20 | const arrayBuffer = await response.arrayBuffer() 21 | const buffer = Buffer.from(arrayBuffer) 22 | const form = new FormData() 23 | form.append('file', buffer) 24 | form.append('language', 'en') 25 | const options = { 26 | body: form, 27 | method: 'POST', 28 | headers: { 29 | Authorization: `Bearer ${process.env.FIREWORKS_API_KEY}`, 30 | }, 31 | } 32 | const transcribeCall = await fetch('https://api.fireworks.ai/inference/v1/audio/transcriptions', options) 33 | const transcribeResp: any = await transcribeCall.json() 34 | if (transcribeResp?.['text']) { 35 | await redis.hset(fileName.split(process.env.RANDOM_SEPERATOR)[0], { 36 | [fileName.split(process.env.RANDOM_SEPERATOR)[1]]: { 37 | transcribed: true, 38 | transcription: transcribeResp.text, 39 | }, 40 | }) 41 | } 42 | return new Response() 43 | } 44 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | import * as React from 'react' 5 | 6 | const buttonVariants = cva( 7 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 12 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 13 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 14 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 15 | ghost: 'hover:bg-accent hover:text-accent-foreground', 16 | link: 'text-primary underline-offset-4 hover:underline', 17 | }, 18 | size: { 19 | default: 'h-10 px-4 py-2', 20 | sm: 'h-9 rounded-md px-3', 21 | lg: 'h-11 rounded-md px-8', 22 | icon: 'h-10 w-10', 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: 'default', 27 | size: 'default', 28 | }, 29 | }, 30 | ) 31 | 32 | export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { 33 | asChild?: boolean 34 | } 35 | 36 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 37 | const Comp = asChild ? Slot : 'button' 38 | return 39 | }) 40 | Button.displayName = 'Button' 41 | 42 | export { Button, buttonVariants } 43 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config = { 4 | darkMode: ['class'], 5 | content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'], 6 | prefix: '', 7 | theme: { 8 | container: { 9 | center: true, 10 | padding: '2rem', 11 | screens: { 12 | '2xl': '1400px', 13 | }, 14 | }, 15 | extend: { 16 | colors: { 17 | border: 'hsl(var(--border))', 18 | input: 'hsl(var(--input))', 19 | ring: 'hsl(var(--ring))', 20 | background: 'hsl(var(--background))', 21 | foreground: 'hsl(var(--foreground))', 22 | primary: { 23 | DEFAULT: 'hsl(var(--primary))', 24 | foreground: 'hsl(var(--primary-foreground))', 25 | }, 26 | secondary: { 27 | DEFAULT: 'hsl(var(--secondary))', 28 | foreground: 'hsl(var(--secondary-foreground))', 29 | }, 30 | destructive: { 31 | DEFAULT: 'hsl(var(--destructive))', 32 | foreground: 'hsl(var(--destructive-foreground))', 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))', 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))', 41 | }, 42 | popover: { 43 | DEFAULT: 'hsl(var(--popover))', 44 | foreground: 'hsl(var(--popover-foreground))', 45 | }, 46 | card: { 47 | DEFAULT: 'hsl(var(--card))', 48 | foreground: 'hsl(var(--card-foreground))', 49 | }, 50 | }, 51 | borderRadius: { 52 | lg: 'var(--radius)', 53 | md: 'calc(var(--radius) - 2px)', 54 | sm: 'calc(var(--radius) - 4px)', 55 | }, 56 | keyframes: { 57 | 'accordion-down': { 58 | from: { height: '0' }, 59 | to: { height: 'var(--radix-accordion-content-height)' }, 60 | }, 61 | 'accordion-up': { 62 | from: { height: 'var(--radix-accordion-content-height)' }, 63 | to: { height: '0' }, 64 | }, 65 | }, 66 | animation: { 67 | 'accordion-down': 'accordion-down 0.2s ease-out', 68 | 'accordion-up': 'accordion-up 0.2s ease-out', 69 | }, 70 | }, 71 | }, 72 | plugins: [require('tailwindcss-animate')], 73 | } satisfies Config 74 | 75 | export default config 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scheduling Audio Transcriptions with QStash 2 | 3 |

4 | Introduction · 5 | One-click Deploy · 6 | Tech Stack + Features · 7 | Author 8 |

9 | 10 | ## Introduction 11 | 12 | In this repository, you will find the code for a scheduled audio transcription system, built using Upstash QStash for task scheduling and Fireworks AI for transcription. You will also learn techniques for secure file uploads to Cloudflare R2, user authentication with Clerk, and data storage with Upstash Redis. 13 | 14 | ## Demo 15 | 16 | https://github.com/user-attachments/assets/9d1484be-2a69-4659-8873-5f8a2f3eb8eb 17 | 18 | ## One-click Deploy 19 | 20 | You can deploy this template to Vercel with the button below: 21 | 22 | [![](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/upstash/transcriber&env=FIREWORKS_API_KEY,AWS_KEY_ID,AWS_REGION_NAME,AWS_S3_BUCKET_NAME,AWS_SECRET_ACCESS_KEY,CLOUDFLARE_R2_ACCOUNT_ID,NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY) 23 | 24 | ## Tech Stack + Features 25 | 26 | ### Frameworks 27 | 28 | - [Next.js](https://nextjs.org/) – React framework for building performant apps with the best developer experience. 29 | - [Clerk](https://clerk.dev/) – Clerk is a complete suite of embeddable UIs, flexible APIs, and admin dashboards to authenticate and manage your users. 30 | 31 | ### Platforms 32 | 33 | - [Vercel](https://vercel.com/) – Easily preview & deploy changes with git. 34 | - [Upstash](https://upstash.com) - Serverless database platform. You are going to use Upstash Vector for storing vector embeddings and metadata, and Upstash Redis for storing per user chat history. 35 | - [Fireworks](https://fireworks.ai/) - A generative AI inference platform to run and customize models with speed and production-readiness. 36 | 37 | ### UI 38 | 39 | - [Tailwind CSS](https://tailwindcss.com/) – Utility-first CSS framework for rapid UI development. 40 | - [Radix](https://www.radix-ui.com/) – Primitives like modal, popover, etc. to build a stellar user experience. 41 | - [Lucide](https://lucide.dev/) – Beautifully simple, pixel-perfect icons. 42 | - [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) – Optimize custom fonts and remove external network requests for improved performance. 43 | 44 | ### Code Quality 45 | 46 | - [TypeScript](https://www.typescriptlang.org/) – Static type checker for end-to-end typesafety 47 | - [Prettier](https://prettier.io/) – Opinionated code formatter for consistent code style 48 | 49 | ## Author 50 | 51 | - Rishi Raj Jain ([@rishi_raj_jain_](https://twitter.com/rishi_raj_jain_)) 52 | -------------------------------------------------------------------------------- /components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | // Inspired by react-hot-toast library 4 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast' 5 | import * as React from 'react' 6 | 7 | const TOAST_LIMIT = 1 8 | const TOAST_REMOVE_DELAY = 1000000 9 | 10 | type ToasterToast = ToastProps & { 11 | id: string 12 | title?: React.ReactNode 13 | description?: React.ReactNode 14 | action?: ToastActionElement 15 | } 16 | 17 | const actionTypes = { 18 | ADD_TOAST: 'ADD_TOAST', 19 | UPDATE_TOAST: 'UPDATE_TOAST', 20 | DISMISS_TOAST: 'DISMISS_TOAST', 21 | REMOVE_TOAST: 'REMOVE_TOAST', 22 | } as const 23 | 24 | let count = 0 25 | 26 | function genId() { 27 | count = (count + 1) % Number.MAX_SAFE_INTEGER 28 | return count.toString() 29 | } 30 | 31 | type ActionType = typeof actionTypes 32 | 33 | type Action = 34 | | { 35 | type: ActionType['ADD_TOAST'] 36 | toast: ToasterToast 37 | } 38 | | { 39 | type: ActionType['UPDATE_TOAST'] 40 | toast: Partial 41 | } 42 | | { 43 | type: ActionType['DISMISS_TOAST'] 44 | toastId?: ToasterToast['id'] 45 | } 46 | | { 47 | type: ActionType['REMOVE_TOAST'] 48 | toastId?: ToasterToast['id'] 49 | } 50 | 51 | interface State { 52 | toasts: ToasterToast[] 53 | } 54 | 55 | const toastTimeouts = new Map>() 56 | 57 | const addToRemoveQueue = (toastId: string) => { 58 | if (toastTimeouts.has(toastId)) { 59 | return 60 | } 61 | 62 | const timeout = setTimeout(() => { 63 | toastTimeouts.delete(toastId) 64 | dispatch({ 65 | type: 'REMOVE_TOAST', 66 | toastId: toastId, 67 | }) 68 | }, TOAST_REMOVE_DELAY) 69 | 70 | toastTimeouts.set(toastId, timeout) 71 | } 72 | 73 | export const reducer = (state: State, action: Action): State => { 74 | switch (action.type) { 75 | case 'ADD_TOAST': 76 | return { 77 | ...state, 78 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 79 | } 80 | 81 | case 'UPDATE_TOAST': 82 | return { 83 | ...state, 84 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), 85 | } 86 | 87 | case 'DISMISS_TOAST': { 88 | const { toastId } = action 89 | 90 | // ! Side effects ! - This could be extracted into a dismissToast() action, 91 | // but I'll keep it here for simplicity 92 | if (toastId) { 93 | addToRemoveQueue(toastId) 94 | } else { 95 | state.toasts.forEach((toast) => { 96 | addToRemoveQueue(toast.id) 97 | }) 98 | } 99 | 100 | return { 101 | ...state, 102 | toasts: state.toasts.map((t) => 103 | t.id === toastId || toastId === undefined 104 | ? { 105 | ...t, 106 | open: false, 107 | } 108 | : t, 109 | ), 110 | } 111 | } 112 | case 'REMOVE_TOAST': 113 | if (action.toastId === undefined) { 114 | return { 115 | ...state, 116 | toasts: [], 117 | } 118 | } 119 | return { 120 | ...state, 121 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 122 | } 123 | } 124 | } 125 | 126 | const listeners: Array<(state: State) => void> = [] 127 | 128 | let memoryState: State = { toasts: [] } 129 | 130 | function dispatch(action: Action) { 131 | memoryState = reducer(memoryState, action) 132 | listeners.forEach((listener) => { 133 | listener(memoryState) 134 | }) 135 | } 136 | 137 | type Toast = Omit 138 | 139 | function toast({ ...props }: Toast) { 140 | const id = genId() 141 | 142 | const update = (props: ToasterToast) => 143 | dispatch({ 144 | type: 'UPDATE_TOAST', 145 | toast: { ...props, id }, 146 | }) 147 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) 148 | 149 | dispatch({ 150 | type: 'ADD_TOAST', 151 | toast: { 152 | ...props, 153 | id, 154 | open: true, 155 | onOpenChange: (open) => { 156 | if (!open) dismiss() 157 | }, 158 | }, 159 | }) 160 | 161 | return { 162 | id: id, 163 | dismiss, 164 | update, 165 | } 166 | } 167 | 168 | function useToast() { 169 | const [state, setState] = React.useState(memoryState) 170 | 171 | React.useEffect(() => { 172 | listeners.push(setState) 173 | return () => { 174 | const index = listeners.indexOf(setState) 175 | if (index > -1) { 176 | listeners.splice(index, 1) 177 | } 178 | } 179 | }, [state]) 180 | 181 | return { 182 | ...state, 183 | toast, 184 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), 185 | } 186 | } 187 | 188 | export { toast, useToast } 189 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | import * as ToastPrimitives from '@radix-ui/react-toast' 5 | import { cva, type VariantProps } from 'class-variance-authority' 6 | import { X } from 'lucide-react' 7 | import * as React from 'react' 8 | 9 | const ToastProvider = ToastPrimitives.Provider 10 | 11 | const ToastViewport = React.forwardRef, React.ComponentPropsWithoutRef>( 12 | ({ className, ...props }, ref) => ( 13 | 18 | ), 19 | ) 20 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 21 | 22 | const toastVariants = cva( 23 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', 24 | { 25 | variants: { 26 | variant: { 27 | default: 'border bg-background text-foreground', 28 | destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground', 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: 'default', 33 | }, 34 | }, 35 | ) 36 | 37 | const Toast = React.forwardRef, React.ComponentPropsWithoutRef & VariantProps>( 38 | ({ className, variant, ...props }, ref) => { 39 | return 40 | }, 41 | ) 42 | Toast.displayName = ToastPrimitives.Root.displayName 43 | 44 | const ToastAction = React.forwardRef, React.ComponentPropsWithoutRef>( 45 | ({ className, ...props }, ref) => ( 46 | 54 | ), 55 | ) 56 | ToastAction.displayName = ToastPrimitives.Action.displayName 57 | 58 | const ToastClose = React.forwardRef, React.ComponentPropsWithoutRef>( 59 | ({ className, ...props }, ref) => ( 60 | 69 | 70 | 71 | ), 72 | ) 73 | ToastClose.displayName = ToastPrimitives.Close.displayName 74 | 75 | const ToastTitle = React.forwardRef, React.ComponentPropsWithoutRef>( 76 | ({ className, ...props }, ref) => , 77 | ) 78 | ToastTitle.displayName = ToastPrimitives.Title.displayName 79 | 80 | const ToastDescription = React.forwardRef, React.ComponentPropsWithoutRef>( 81 | ({ className, ...props }, ref) => , 82 | ) 83 | ToastDescription.displayName = ToastPrimitives.Description.displayName 84 | 85 | type ToastProps = React.ComponentPropsWithoutRef 86 | 87 | type ToastActionElement = React.ReactElement 88 | 89 | export { Toast, ToastAction, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport, type ToastActionElement, type ToastProps } 90 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useToast } from '@/components/ui/use-toast' 4 | import { SignInButton, SignedIn, SignedOut, UserButton, useUser } from '@clerk/nextjs' 5 | import { LoaderCircle, RotateCw, Upload } from 'lucide-react' 6 | import { ChangeEvent, useEffect, useState } from 'react' 7 | 8 | export default function Page() { 9 | const { toast } = useToast() 10 | const { isSignedIn } = useUser() 11 | const [audios, setAudios] = useState([]) 12 | const fetchAudios = (start = 0) => { 13 | if (start === 0) setAudios([]) 14 | fetch('/api/history?start=' + start) 15 | .then((res) => res.json()) 16 | .then((res) => { 17 | setAudios((existingAudios) => [...existingAudios, ...res]) 18 | if (res.length === 10) fetchAudios(start + 10) 19 | }) 20 | } 21 | useEffect(() => { 22 | fetchAudios() 23 | }, []) 24 | return ( 25 |
26 | ) => { 33 | if (!isSignedIn) { 34 | toast({ 35 | duration: 2000, 36 | variant: 'destructive', 37 | description: 'You are not signed in.', 38 | }) 39 | return 40 | } 41 | const file: File | null | undefined = e.target.files?.[0] 42 | if (!file) { 43 | toast({ 44 | duration: 2000, 45 | variant: 'destructive', 46 | description: 'No file attached.', 47 | }) 48 | return 49 | } 50 | const reader = new FileReader() 51 | reader.onload = async (event) => { 52 | const fileData = event.target?.result 53 | if (fileData) { 54 | const presignedURL = new URL('/api/upload', window.location.href) 55 | presignedURL.searchParams.set('fileName', file.name) 56 | presignedURL.searchParams.set('contentType', file.type) 57 | toast({ 58 | duration: 10000, 59 | description: 'Uploading your file to Cloudflare R2...', 60 | }) 61 | fetch(presignedURL.toString()) 62 | .then((res) => res.json()) 63 | .then((res) => { 64 | const body = new File([fileData], file.name, { type: file.type }) 65 | fetch(res[0], { 66 | body, 67 | method: 'PUT', 68 | }) 69 | .then((uploadRes) => { 70 | if (uploadRes.ok) { 71 | toast({ 72 | duration: 2000, 73 | description: 'Upload to Cloudflare R2 succesfully.', 74 | }) 75 | fetch('/api/schedule', { 76 | method: 'POST', 77 | headers: { 78 | 'Content-Type': 'application/json', 79 | }, 80 | body: JSON.stringify({ fileName: res[1] }), 81 | }).then((res) => { 82 | fetchAudios() 83 | if (res.ok) { 84 | toast({ 85 | duration: 2000, 86 | description: 'Scheduled transcription of the audio.', 87 | }) 88 | } 89 | }) 90 | } else { 91 | toast({ 92 | duration: 2000, 93 | variant: 'destructive', 94 | description: 'Failed to upload to Cloudflare R2.', 95 | }) 96 | } 97 | }) 98 | .catch((err) => { 99 | console.log(err) 100 | toast({ 101 | duration: 2000, 102 | variant: 'destructive', 103 | description: 'Failed to upload to Cloudflare R2.', 104 | }) 105 | }) 106 | }) 107 | } 108 | } 109 | reader.readAsArrayBuffer(file) 110 | }} 111 | /> 112 |
113 | Transcriber 114 | 115 |
116 | 117 |
118 |
119 |
120 | {isSignedIn ? ( 121 |
122 | {audios.map((audio, key) => ( 123 |
124 | 125 | {key + 1}. {audio.key} 126 | 127 | {audio.value.transcribed ? ( 128 | {audio.value.transcription} 129 | ) : ( 130 |
131 | 132 | Transcription in progress... 133 |
134 | )} 135 |
136 | ))} 137 |
138 | ) : ( 139 |
140 | 141 |
142 | Sign in to use Transcriber → 143 |
144 |
145 |
146 | )} 147 | {isSignedIn && ( 148 |
149 | 159 | 166 |
167 | )} 168 |
169 | ) 170 | } 171 | --------------------------------------------------------------------------------