├── app ├── favicon.ico ├── fonts │ ├── GeistVF.woff │ └── GeistMonoVF.woff ├── api │ └── workflow │ │ ├── story.ts │ │ ├── saveStories.ts │ │ ├── parseStory.ts │ │ ├── route.ts │ │ ├── loadStories.ts │ │ ├── summarizeStory.ts │ │ └── prepareAudio.ts ├── globals.css ├── layout.tsx └── page.tsx ├── next.config.mjs ├── postcss.config.mjs ├── tailwind.config.ts ├── .gitignore ├── README.md ├── tsconfig.json ├── package.json └── temp.json /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/radio-hackernews/master/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/radio-hackernews/master/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upstash/radio-hackernews/master/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/api/workflow/story.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface Story { 4 | id: number; 5 | title: string; 6 | url?: string; 7 | text?: string; 8 | score: number; 9 | by: string; 10 | type: string; 11 | time: number; 12 | readableTime: string; 13 | content?: string; 14 | summary?: string; 15 | voiceId?: string; 16 | summaryAudio?: string; 17 | summaryAudioDuration?: number; 18 | contentAudio?: string; 19 | } 20 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.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*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Radio Hackernews 2 | 3 | This app exemplifies the use of Upstash Workflow in a complex AI pipeline. The app is a Hackernews reader that reads the top stories from Hackernews then summarizes them using OpenAI API and then converts them to audio using the ElevenLabs Text-to-Speech API. The audio is then stored in TigrisData. 4 | 5 | Demo link: https://radio-hackernews-web.vercel.app/ 6 | 7 | Workflow Docs: https://upstash.com/docs/workflow/getstarted 8 | 9 | ![image](https://github.com/user-attachments/assets/cd30ef5f-fed4-428a-bb84-99db7d8ce6ec) 10 | -------------------------------------------------------------------------------- /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 type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | 5 | const geistSans = localFont({ 6 | src: "./fonts/GeistVF.woff", 7 | variable: "--font-geist-sans", 8 | weight: "100 900", 9 | }); 10 | const geistMono = localFont({ 11 | src: "./fonts/GeistMonoVF.woff", 12 | variable: "--font-geist-mono", 13 | weight: "100 900", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "Create Next App", 18 | description: "Generated by create next app", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/api/workflow/saveStories.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis' 2 | import { Story } from "@/app/api/workflow/story"; 3 | 4 | const redis = Redis.fromEnv() 5 | 6 | export async function saveStories(stories: Story[]): Promise { 7 | const currentTime = new Date().getTime(); 8 | const pipeline = redis.pipeline(); 9 | 10 | for (const story of stories) { 11 | pipeline.zadd( 12 | "stories", 13 | { score: currentTime, member: story} 14 | ); 15 | pipeline.sadd("ids", story.id); 16 | } 17 | 18 | await pipeline.exec(); 19 | } 20 | 21 | // Usage example 22 | async function main() { 23 | const stories: Story[] = [ 24 | // ... your story objects here 25 | ]; 26 | 27 | try { 28 | await saveStories(stories); 29 | console.log("Stories saved successfully"); 30 | } catch (error) { 31 | console.error("Failed to save stories:", error); 32 | } 33 | } 34 | 35 | // Uncomment the next line to run the example 36 | // main(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radio-hackernews", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@aws-sdk/client-s3": "^3.658.1", 13 | "@mozilla/readability": "^0.5.0", 14 | "@types/node-fetch": "^2.6.11", 15 | "@upstash/workflow": "^0.2.0", 16 | "@upstash/redis": "^1.34.0", 17 | "axios": "^1.7.7", 18 | "cheerio": "^1.0.0", 19 | "fs": "^0.0.1-security", 20 | "jsdom": "^25.0.1", 21 | "natural": "^8.0.1", 22 | "next": "14.2.35", 23 | "node-fetch": "^2.7.0", 24 | "openai": "^4.62.1", 25 | "react": "^18", 26 | "react-dom": "^18" 27 | }, 28 | "devDependencies": { 29 | "@types/jsdom": "^21.1.7", 30 | "@types/node": "^20", 31 | "@types/react": "^18", 32 | "@types/react-dom": "^18", 33 | "postcss": "^8", 34 | "tailwindcss": "^3.4.1", 35 | "typescript": "^5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/api/workflow/parseStory.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { JSDOM } from 'jsdom'; 3 | import { Readability } from '@mozilla/readability'; 4 | import { Story } from "./story"; 5 | 6 | function formatTime(timestamp: number): string { 7 | const date = new Date(timestamp * 1000); 8 | return date.toLocaleString(); 9 | } 10 | 11 | async function parseContent(url: string): Promise { 12 | const response = await fetch(url); 13 | if (!response.ok) { 14 | throw new Error(`HTTP error! status: ${response.status}`); 15 | } 16 | const html = await response.text(); 17 | 18 | // Create a DOM from the HTML 19 | const dom = new JSDOM(html, { url }); 20 | 21 | // Use Readability to extract the main content 22 | const reader = new Readability(dom.window.document); 23 | const article = reader.parse(); 24 | 25 | if (!article || !article.textContent) { 26 | throw new Error('Failed to parse article content'); 27 | } 28 | 29 | // Clean up the text (remove extra whitespace) 30 | return article.textContent.replace(/\s+/g, ' ').trim(); 31 | } 32 | 33 | export async function parseStory(story: Story): Promise { 34 | if (story.url) { 35 | try { 36 | story.content = await parseContent(story.url); 37 | } catch (error) { 38 | console.error(`Error parsing story from ${story.url}:`, error); 39 | } 40 | } 41 | story.readableTime = formatTime(story.time); 42 | return story; 43 | } 44 | -------------------------------------------------------------------------------- /app/api/workflow/route.ts: -------------------------------------------------------------------------------- 1 | import {serve} from "@upstash/workflow/nextjs" 2 | import { saveStories } from "@/app/api/workflow/saveStories"; 3 | import { loadStories } from "@/app/api/workflow/loadStories"; 4 | import { summarizeStory } from "@/app/api/workflow/summarizeStory"; 5 | import { parseStory } from "@/app/api/workflow/parseStory"; 6 | import {getVoiceFile, upload} from "@/app/api/workflow/prepareAudio"; 7 | 8 | export const { POST } = serve( 9 | async (context) => { 10 | 11 | let stories = await context.run("load-stories", async () => { 12 | return await loadStories(); 13 | }); 14 | 15 | if(stories.length === 0) { 16 | console.log("No stories to process"); 17 | return; 18 | } 19 | 20 | stories = await Promise.all( 21 | stories.map(story => 22 | context.run("parse-story", async () => { 23 | return await parseStory(story); 24 | }) 25 | )); 26 | 27 | 28 | stories = await Promise.all( 29 | stories.map(story => 30 | context.run("summarize-story", async () => { 31 | return await summarizeStory(story); 32 | }) 33 | )); 34 | 35 | 36 | for (const story of stories) { 37 | if (story.summary) { 38 | const {summaryAudioFile, voiceId} = await getVoiceFile(context, story.summary as string); 39 | const { fileUrl, duration } = await context.run("upload-audio", async () => { 40 | return await upload(summaryAudioFile, `${story.id}_${Date.now()}_summary.mp3`); 41 | }); 42 | 43 | story.voiceId = voiceId; 44 | story.summaryAudio = fileUrl; 45 | story.summaryAudioDuration = duration; 46 | } 47 | } 48 | 49 | console.log("stories transcribed and uploaded"); 50 | 51 | await context.run("save-stories", async () => { 52 | await saveStories(stories); 53 | console.log("stories saved"); 54 | return "success" 55 | }); 56 | } 57 | ) -------------------------------------------------------------------------------- /app/api/workflow/loadStories.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import {Story} from "./story"; 3 | import {Redis} from "@upstash/redis"; 4 | 5 | const redis = Redis.fromEnv() 6 | 7 | function formatTime(timestamp: number): string { 8 | const date = new Date(timestamp * 1000); // Convert seconds to milliseconds 9 | return date.toLocaleString(); // This will use the system's locale settings 10 | } 11 | 12 | export async function loadStories(): Promise { 13 | try { 14 | const response = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty'); 15 | if (!response.ok) { 16 | throw new Error(`HTTP error! status: ${response.status}`); 17 | } 18 | let storyIds: number[] = await response.json(); 19 | 20 | 21 | const stories = await Promise.all( 22 | storyIds.slice(0, 30).map(async (id) => { 23 | const storyResponse = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`); 24 | if (!storyResponse.ok) { 25 | throw new Error(`HTTP error! status: ${storyResponse.status}`); 26 | } 27 | const storyData: Story = await storyResponse.json(); 28 | return { 29 | id: storyData.id, 30 | title: storyData.title, 31 | url: storyData.url, 32 | text: storyData.text, 33 | score: storyData.score, 34 | by: storyData.by, 35 | type: storyData.type, 36 | time: storyData.time, 37 | readableTime: formatTime(storyData.time) 38 | }; 39 | }) 40 | ); 41 | // Sort stories by score in descending order 42 | let topStories = stories.sort((a, b) => b.score - a.score); 43 | 44 | // Remove story ids that already exist in redis set 'ids' 45 | const existingIds : number[] = await redis.smembers('ids'); 46 | console.log('Existing ids:', existingIds); 47 | 48 | // Filter out existing ids from top stories 49 | topStories = topStories.filter(story => !existingIds.includes(story.id) && story.url); 50 | 51 | // return top 3 52 | topStories = topStories.slice(0, 3); 53 | 54 | console.log('Top stories:', topStories); 55 | 56 | return topStories; 57 | } catch (error) { 58 | console.error('Error fetching stories:', error); 59 | throw error; 60 | } 61 | } -------------------------------------------------------------------------------- /app/api/workflow/summarizeStory.ts: -------------------------------------------------------------------------------- 1 | import {Story} from "./story"; 2 | import OpenAI from 'openai'; 3 | 4 | // Initialize the OpenAI client 5 | const openai = new OpenAI({ 6 | apiKey: process.env.OPENAI_API_KEY // Make sure to set this environment variable 7 | }); 8 | 9 | async function summarizeContent(content: string): Promise { 10 | try { 11 | /* 12 | const response1 = await openai.chat.completions.create({ 13 | model: "gpt-4o", 14 | messages: [ 15 | { 16 | role: "system", 17 | content: "You are a helpful assistant that checks if content is suitable for listening as an article." 18 | }, 19 | { 20 | role: "user", 21 | content: `The following content is parsed from a URL. Respond "YES" if it appears to be an article or news. Respond "NO" if it seems to be another type of content. :\n\n${content}` 22 | } 23 | ], 24 | max_tokens: 100, 25 | temperature: 0.5 26 | }); 27 | console.log(`Is it article (${url}) :` + response1.choices[0].message.content); 28 | if (response1.choices[0].message.content === "NO") { 29 | return "fail"; 30 | } */ 31 | 32 | const response2 = await openai.chat.completions.create({ 33 | model: "gpt-4o", 34 | messages: [ 35 | {role: "system", content: "You are a helpful assistant that summarizes text."}, 36 | { 37 | role: "user", 38 | content: `The following is a technical article. The audience is software developers and it will be part of a podcast. Introduce the article and what it is about. Then give more details. Talk about the article up to 25 sentences. Use a clean and understandable casual language. Explain the article like you are explaining to a friend. :\n\n${content}` 39 | } 40 | ], 41 | // max_tokens: 100, 42 | max_tokens: 750, 43 | temperature: 0.5 44 | }); 45 | 46 | return response2.choices[0].message.content || "fail"; 47 | } catch (error) { 48 | console.error("Error in OpenAI API call:", error); 49 | throw new Error("Failed to generate summary using OpenAI"); 50 | } 51 | } 52 | 53 | export async function summarizeStory(story: Story): Promise { 54 | if (story.content) { 55 | try { 56 | const summary = await summarizeContent(story.content); 57 | if (summary !== "fail" && story.content.length > 100) { 58 | story.summary = summary; 59 | } 60 | } catch (error) { 61 | console.error(`Error summarizing content from ${story.title}:`, error); 62 | } 63 | } else { 64 | console.error(`No content available ${story.id}`); 65 | } 66 | return story; 67 | } 68 | -------------------------------------------------------------------------------- /temp.json: -------------------------------------------------------------------------------- 1 | {"id":41578483,"title":"Why wordfreq will not be updated","url":"https://github.com/rspeer/wordfreq/blob/master/SUNSET.md","score":1295,"by":"tomthe","type":"story","time":1726659715,"readableTime":"9/18/2024, 4:41:55 AM","content":"Why wordfreq will not be updated The wordfreq data is a snapshot of language that could be found in various online sources up through 2021. There are several reasons why it will not be updated anymore. Generative AI has polluted the data I don't think anyone has reliable information about post-2021 language usage by humans. The open Web (via OSCAR) was one of wordfreq's data sources. Now the Web at large is full of slop generated by large language models, written by no one to communicate nothing. Including this slop in the data skews the word frequencies. Sure, there was spam in the wordfreq data sources, but it was manageable and often identifiable. Large language models generate text that masquerades as real language with intention behind it, even though there is none, and their output crops up everywhere. As one example, Philip Shapira reports that ChatGPT (OpenAI's popular brand of generative language model circa 2024) is obsessed with the word \"delve\" in a way that people never have been, and caused its overall frequency to increase by an order of magnitude. Information that used to be free became expensive wordfreq is not just concerned with formal printed words. It collected more conversational language usage from two sources in particular: Twitter and Reddit. The Twitter data was always built on sand. Even when Twitter allowed free access to a portion of their \"firehose\", the terms of use did not allow me to distribute that data outside of the company where I collected it (Luminoso). wordfreq has the frequencies that were built with that data as input, but the collected data didn't belong to me and I don't have it anymore. Now Twitter is gone anyway, its public APIs have shut down, and the site has been replaced with an oligarch's plaything, a spam-infested right-wing cesspool called X. Even if X made its raw data feed available (which it doesn't), there would be no valuable information to be found there. Reddit also stopped providing public data archives, and now they sell their archives at a price that only OpenAI will pay. And given what's happening to the field, I don't blame them. I don't want to be part of this scene anymore wordfreq used to be at the intersection of my interests. I was doing corpus linguistics in a way that could also benefit natural language processing tools. The field I know as \"natural language processing\" is hard to find these days. It's all being devoured by generative AI. Other techniques still exist but generative AI sucks up all the air in the room and gets all the money. It's rare to see NLP research that doesn't have a dependency on closed data controlled by OpenAI and Google, two companies that I already despise. wordfreq was built by collecting a whole lot of text in a lot of languages. That used to be a pretty reasonable thing to do, and not the kind of thing someone would be likely to object to. Now, the text-slurping tools are mostly used for training generative AI, and people are quite rightly on the defensive. If someone is collecting all the text from your books, articles, Web site, or public posts, it's very likely because they are creating a plagiarism machine that will claim your words as its own. So I don't want to work on anything that could be confused with generative AI, or that could benefit generative AI. OpenAI and Google can collect their own damn data. I hope they have to pay a very high price for it, and I hope they're constantly cursing the mess that they made themselves. — Robyn Speer"} -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 | Next.js logo 15 |
    16 |
  1. 17 | Get started by editing{" "} 18 | 19 | app/page.tsx 20 | 21 | . 22 |
  2. 23 |
  3. Save and see your changes instantly.
  4. 24 |
25 | 26 | 51 |
52 | 99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /app/api/workflow/prepareAudio.ts: -------------------------------------------------------------------------------- 1 | import {ObjectCannedACL, PutObjectCommand, S3Client} from "@aws-sdk/client-s3"; 2 | import {WorkflowContext} from "@upstash/workflow"; 3 | 4 | 5 | // Array of available voice IDs 6 | // https://elevenlabs.io/docs/voices/default-voices 7 | const ELEVENLABS_VOICE_IDS = [ 8 | 'cgSgspJ2msm6clMCkdW9', 9 | 'FGY2WhTYpPnrIDTdsKH5', 10 | 'TX3LPaxmHKxFdv7VOQHJ', 11 | 'bIHbv24MWmeRgasZH58o', 12 | 'EXAVITQu4vr4xnSDxMaL' 13 | ]; 14 | 15 | // Function to randomly select a voice ID 16 | function getRandomVoiceId() { 17 | const randomIndex = Math.floor(Math.random() * ELEVENLABS_VOICE_IDS.length); 18 | return ELEVENLABS_VOICE_IDS[randomIndex]; 19 | } 20 | 21 | 22 | const s3Region = process.env.AWS_REGION || 'default-region'; // Replace with your default region if needed 23 | const s3AccessKeyId = process.env.AWS_ACCESS_KEY_ID; 24 | const s3SecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; 25 | 26 | if (!s3AccessKeyId || !s3SecretAccessKey) { 27 | throw new Error("AWS credentials are not defined"); 28 | } 29 | 30 | const s3Client = new S3Client({ 31 | region: s3Region, 32 | credentials: { 33 | accessKeyId: s3AccessKeyId, 34 | secretAccessKey: s3SecretAccessKey, 35 | } 36 | }); 37 | 38 | const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME; 39 | const S3_BUCKET_URL = process.env.S3_BUCKET_URL; // e.g., "https://your-bucket-name.s3.amazonaws.com" 40 | 41 | export async function getVoiceFile(context: WorkflowContext, text: string) { 42 | 43 | const selectedVoiceId = await context.run("select voice", async () => { 44 | const voiceId = getRandomVoiceId(); 45 | console.log(`Selected voice ID: ${voiceId}`); 46 | return voiceId; 47 | }); 48 | 49 | const ELEVENLABS_API_KEY = process.env.ELEVENLABS_API_KEY; 50 | 51 | if (!ELEVENLABS_API_KEY) { 52 | throw new Error("ELEVENLABS API Key is not defined"); 53 | } 54 | 55 | const headers = { 56 | 'Accept': 'audio/mpeg', 57 | 'xi-api-key': ELEVENLABS_API_KEY, 58 | 'Content-Type': 'application/json', 59 | }; 60 | 61 | const resp = await context.call( 62 | "transcribe", // Step name 63 | { 64 | url: `https://api.elevenlabs.io/v1/text-to-speech/${selectedVoiceId}`, // Endpoint URL 65 | method: "POST", // HTTP method 66 | body: { // Request body 67 | text: text, 68 | model_id: 'eleven_multilingual_v2', 69 | voice_settings: { 70 | stability: 0.5, 71 | similarity_boost: 0.5 72 | } 73 | }, 74 | headers: headers 75 | } 76 | ); 77 | return { 78 | summaryAudioFile: resp.body, 79 | voiceId: selectedVoiceId 80 | }; 81 | } 82 | 83 | 84 | async function calculateAudioDuration(audioBuffer: Buffer): Promise { 85 | const fileSizeInBytes = audioBuffer.length; 86 | const bitrate = 128 * 1024; // 128 kbps in bits per second 87 | 88 | // Calculate duration: (file size in bits) / (bitrate in bits per second) 89 | const durationInSeconds = (fileSizeInBytes * 8) / bitrate; 90 | 91 | console.log(`Calculated audio duration: ${durationInSeconds.toFixed(2)} seconds`); 92 | return Number(durationInSeconds.toFixed(2)); 93 | } 94 | 95 | export async function upload(audio: any, fileName: string) { 96 | const buffer = Buffer.from(audio, 'binary'); 97 | const s3Key = `audio/${fileName}`; 98 | 99 | try { 100 | const acl: ObjectCannedACL = 'public-read'; 101 | 102 | const uploadParams = { 103 | Bucket: S3_BUCKET_NAME, 104 | Key: s3Key, 105 | Body: buffer, 106 | ContentType: 'audio/mpeg', 107 | ACL: acl, // Make the object publicly readable 108 | }; 109 | 110 | const command = new PutObjectCommand(uploadParams); 111 | await s3Client.send(command); 112 | const fileUrl = `${S3_BUCKET_URL}/${s3Key}` 113 | console.log(`File uploaded successfully to S3: ${fileUrl}`); 114 | 115 | // Calculate duration 116 | const duration = await calculateAudioDuration(buffer); 117 | 118 | // Return both the file URL and duration 119 | return { fileUrl, duration }; 120 | 121 | } catch (error) { 122 | console.error("Error uploading file to S3:", error); 123 | throw error; 124 | } 125 | } --------------------------------------------------------------------------------