Skip to content

Commit

Permalink
feat: support app id with namespaces
Browse files Browse the repository at this point in the history
  • Loading branch information
drochetti committed Apr 3, 2024
1 parent 335b817 commit fd12d00
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 13 deletions.
299 changes: 299 additions & 0 deletions apps/demo-nextjs-app-router/app/whisper/realtime/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
'use client';

import * as fal from '@fal-ai/serverless-client';
import { useCallback, useMemo, useRef, useState } from 'react'; // Add useRef here

fal.config({
// credentials: 'FAL_KEY_ID:FAL_KEY_SECRET',
proxyUrl: '/api/fal/proxy',
});

type ErrorProps = {
error: any;
};

function Error(props: ErrorProps) {
if (!props.error) {
return null;
}
return (
<div
className="p-4 mb-4 text-sm text-red-800 rounded bg-red-50 dark:bg-gray-800 dark:text-red-400"
role="alert"
>
<span className="font-medium">Error</span> {props.error.message}
</div>
);
}

type RecorderOptions = {
maxDuration?: number;
onChunk?: (chunk: Blob) => void;
sendInterval?: number; // Add this line
};

function useMediaRecorder({
maxDuration = 20000,
onChunk,
sendInterval = 1000, // Add this line
}: RecorderOptions = {}) {
const [isRecording, setIsRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
null
);
const accumulatedChunks = useRef<BlobPart[]>([]); // Use a ref to accumulate chunks

const sendAccumulatedData = useCallback(() => {
// Convert accumulated chunks to a single blob
const audioBlob = new Blob(accumulatedChunks.current, {
type: 'audio/wav',
});
// Optionally, here you can slice the Blob if you want to send data in smaller pieces
// For example: audioBlob.slice(startByte, endByte)
if (onChunk) {
onChunk(audioBlob);
}
}, [onChunk]);

const record = useCallback((): Promise<File> => {
setIsRecording(true);
accumulatedChunks.current = []; // Reset accumulated chunks
return new Promise<File>(async (resolve, reject) => {

Check failure on line 61 in apps/demo-nextjs-app-router/app/whisper/realtime/page.tsx

View workflow job for this annotation

GitHub Actions / build

Promise executor functions should not be async
// Explicitly type the Promise here
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
const recorder = new MediaRecorder(stream);
setMediaRecorder(recorder);

recorder.addEventListener('dataavailable', (event) => {
accumulatedChunks.current.push(event.data);
});

recorder.addEventListener('stop', () => {
const audioBlob = new Blob(accumulatedChunks.current, {
type: 'audio/wav',
});
const audioFile = new File(
[audioBlob],
`recording_${Date.now()}.wav`,
{ type: 'audio/wav' }
);

sendAccumulatedData(); // Ensure final data is sent
setIsRecording(false);
resolve(audioFile); // Resolve the promise with the audio file
});

recorder.start(1000); // Configure how often you get 'dataavailable' events

// Periodically send accumulated data
const intervalId = setInterval(() => {
sendAccumulatedData();
}, sendInterval);

setTimeout(() => {
clearInterval(intervalId); // Stop the interval when recording stops
recorder.stop();
recorder.stream.getTracks().forEach((track) => track.stop());
}, maxDuration);
} catch (error) {
reject(error);
}
});
}, [maxDuration, sendAccumulatedData, sendInterval]);

// Stop recording logic remains the same
const stopRecording = useCallback(() => {
setIsRecording(false);
mediaRecorder?.stop();
mediaRecorder?.stream.getTracks().forEach((track) => track.stop());
}, [mediaRecorder]);

return { record, stopRecording, isRecording };
}

interface RealTimeOutput {
// Define the structure of your real-time output
// Example:
message: string;
}

export default function WhisperDemo() {
const [realTimeOutputs, setRealTimeOutputs] = useState<RealTimeOutput[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [result, setResult] = useState<any>(null); // eslint-disable-line @typescript-eslint/no-explicit-any
const [elapsedTime, setElapsedTime] = useState<number>(0);
const { send } = fal.realtime.connect('20128202/rtw', {
connectionKey: 'realtime-demo',
throttleInterval: 128,
onResult(result) {
console.log('result', result);
handleNewOutput({ message: result.output });
},
});
const accumulatedChunksRef = useRef(new Uint8Array()); // To store accumulated chunks

const { record, stopRecording, isRecording } = useMediaRecorder({
onChunk: async (chunk) => {
const buffer = await chunk.arrayBuffer();
const newChunk = new Uint8Array(buffer);

// Accumulate chunks
const accumulatedChunks = new Uint8Array(
accumulatedChunksRef.current.length + newChunk.length
);
accumulatedChunks.set(accumulatedChunksRef.current, 0);
accumulatedChunks.set(newChunk, accumulatedChunksRef.current.length);
accumulatedChunksRef.current = accumulatedChunks;

console.log('Accumulated chunk', accumulatedChunks);
// Send the accumulated chunks using your `send` method
send({ content: accumulatedChunks });
},
});

const handleNewOutput = (newOutput: RealTimeOutput) => {
// Use `any` if you don't have a specific type
// This could be a transcription chunk, analysis result, etc.
setRealTimeOutputs((prevOutputs) => [...prevOutputs, newOutput]);
};

const reset = () => {
setLoading(false);
setError(null);
setLogs([]);
setElapsedTime(0);
setResult(null);
};

const audioFileLocalUrl = useMemo(() => {
if (!audioFile) {
return null;
}
return URL.createObjectURL(audioFile);
}, [audioFile]);

const transcribeAudio = async (audioFile: File) => {
reset();
setLoading(true);
const start = Date.now();
try {
const result = await fal.subscribe('fal-ai/whisper', {
input: {
file_name: 'recording.wav',
audio_url: audioFile,
},
pollInterval: 1000,
logs: true,
onQueueUpdate(update) {
setElapsedTime(Date.now() - start);
if (
update.status === 'IN_PROGRESS' ||
update.status === 'COMPLETED'
) {
setLogs((update.logs || []).map((log) => log.message));
}
},
});
setResult(result);
} catch (error: any) {
setError(error);
} finally {
setLoading(false);
setElapsedTime(Date.now() - start);
}
};
return (
<div className="min-h-screen dark:bg-gray-900 bg-gray-100">
<main className="container dark:text-gray-50 text-gray-900 flex flex-col items-center justify-center w-full flex-1 py-10 space-y-8">
<h1 className="text-4xl font-bold mb-8">
Hello <code className="text-pink-600">fal</code> and{' '}
<code className="text-indigo-500">whisper</code>
</h1>

<div className="flex flex-row space-x-2">
<button
onClick={async (e) => {
e.preventDefault();
try {
if (isRecording) {
stopRecording();
} else {
const recordedAudioFile = await record();
setAudioFile(recordedAudioFile);
}
} catch (e: any) {
setError(e);
}
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-lg py-3 px-6 mx-auto rounded focus:outline-none focus:shadow-outline disabled:opacity-70"
disabled={loading}
>
{isRecording ? 'Stop Recording' : 'Record'}
</button>
<button
onClick={async (e) => {
e.preventDefault();
if (audioFile) {
try {
await transcribeAudio(audioFile);
} catch (e: any) {
setError(e);
}
}
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-lg py-3 px-6 mx-auto rounded focus:outline-none focus:shadow-outline disabled:opacity-70"
disabled={loading || !audioFile}
>
{loading ? 'Transcribing...' : 'Transcribe'}
</button>
</div>

{audioFileLocalUrl && (
<div className="text-lg w-full my-2">
<audio className="mx-auto" controls src={audioFileLocalUrl} />
</div>
)}

<Error error={error} />

{/* Real-Time Outputs Section */}
<div className="real-time-output-section">
<h2 className="text-2xl font-semibold my-4">Real-Time Outputs</h2>
<div className="output-container bg-gray-50 p-4 rounded-lg shadow">
{realTimeOutputs.map((output, index) => (
<p key={index}>{output.message}</p> // Ensure 'message' is the correct property
))}
</div>
</div>

{/* JSON Result Section */}
<div className="w-full flex flex-col space-y-4">
<div className="space-y-2">
<h3 className="text-xl font-light">JSON Result</h3>
<p className="text-sm text-current/80">
{`Elapsed Time (seconds): ${(elapsedTime / 1000).toFixed(2)}`}
</p>
<pre className="text-sm bg-black/70 text-white/80 font-mono h-96 rounded whitespace-pre overflow-auto w-full">
{result
? JSON.stringify(result, null, 2)
: '// result pending...'}
</pre>
</div>

<div className="space-y-2">
<h3 className="text-xl font-light">Logs</h3>
<pre className="text-sm bg-black/70 text-white/80 font-mono h-60 rounded whitespace-pre overflow-auto w-full">
{logs.filter(Boolean).join('\n')}
</pre>
</div>
</div>
</main>
</div>
);
}
2 changes: 1 addition & 1 deletion libs/client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fal-ai/serverless-client",
"description": "The fal serverless JS/TS client",
"version": "0.9.0",
"version": "0.9.1-alpha.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
6 changes: 3 additions & 3 deletions libs/client/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { getRestApiUrl } from './config';
import { dispatchRequest } from './request';
import { ensureAppIdFormat } from './utils';
import { parseAppId } from './utils';

export const TOKEN_EXPIRATION_SECONDS = 120;

/**
* Get a token to connect to the realtime endpoint.
*/
export async function getTemporaryAuthToken(app: string): Promise<string> {
const [, appAlias] = ensureAppIdFormat(app).split('/');
const appId = parseAppId(app);
const token: string | object = await dispatchRequest<any, string>(

Check warning on line 12 in libs/client/src/auth.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
'POST',
`${getRestApiUrl()}/tokens/`,
{
allowed_apps: [appAlias],
allowed_apps: [appId.alias],
token_expiration: TOKEN_EXPIRATION_SECONDS,
}
);
Expand Down
10 changes: 5 additions & 5 deletions libs/client/src/function.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { dispatchRequest } from './request';
import { storageImpl } from './storage';
import { EnqueueResult, QueueStatus } from './types';
import { ensureAppIdFormat, isUUIDv4, isValidUrl } from './utils';
import { ensureAppIdFormat, isUUIDv4, isValidUrl, parseAppId } from './utils';

/**
* The function input and other configuration when running
Expand Down Expand Up @@ -284,8 +284,8 @@ export const queue: Queue = {
id: string,
{ requestId, logs = false }: QueueStatusOptions
): Promise<QueueStatus> {
const [appOwner, appAlias] = ensureAppIdFormat(id).split('/');
return send(`${appOwner}/${appAlias}`, {
const appId = parseAppId(id);
return send(`${appId.owner}/${appId.alias}`, {
subdomain: 'queue',
method: 'get',
path: `/requests/${requestId}/status`,
Expand All @@ -298,8 +298,8 @@ export const queue: Queue = {
id: string,
{ requestId }: BaseQueueOptions
): Promise<Output> {
const [appOwner, appAlias] = ensureAppIdFormat(id).split('/');
return send(`${appOwner}/${appAlias}`, {
const appId = parseAppId(id);
return send(`${appId.owner}/${appId.alias}`, {
subdomain: 'queue',
method: 'get',
path: `/requests/${requestId}`,
Expand Down
6 changes: 3 additions & 3 deletions libs/client/src/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import uuid from 'uuid-random';
import { TOKEN_EXPIRATION_SECONDS, getTemporaryAuthToken } from './auth';
import { ApiError } from './response';
import { isBrowser } from './runtime';
import { ensureAppIdFormat, isReact, throttle } from './utils';
import { ensureAppIdFormat, isReact, parseAppId, throttle } from './utils';

// Define the context
interface Context {
Expand Down Expand Up @@ -273,9 +273,9 @@ function buildRealtimeUrl(
queryParams.set('max_buffering', maxBuffering.toFixed(0));
}
const appId = ensureAppIdFormat(app);
const [, appAlias] = ensureAppIdFormat(app).split('/');
const { alias } = parseAppId(appId);
const suffix =
LEGACY_APPS.includes(appAlias) || !app.includes('/') ? 'ws' : 'realtime';
LEGACY_APPS.includes(alias) || !app.includes('/') ? 'ws' : 'realtime';
return `wss://fal.run/${appId}/${suffix}?${queryParams.toString()}`;
}

Expand Down
Loading

0 comments on commit fd12d00

Please sign in to comment.