Introduce horoscope generation via OpenAI API, including new API endpoints and database schema. Adjust card components in `home.tsx` to use `aspect-[16/9]` for consistent image sizing, resolving previous height stretching issues. Update dependencies in `package.json`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 413891e8-d784-4bea-b9f5-91a5a68316b4 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ca1aa952-242c-43c1-9e28-47aed39cee1b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/413891e8-d784-4bea-b9f5-91a5a68316b4/nTLKCC5 Replit-Helium-Checkpoint-Created: true
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
/**
|
|
* React hook for handling SSE voice streaming responses.
|
|
* Converts audio blob to base64 and sends as JSON to match server expectations.
|
|
*/
|
|
import { useCallback } from "react";
|
|
import { useAudioPlayback } from "./useAudioPlayback";
|
|
|
|
interface StreamCallbacks {
|
|
onUserTranscript?: (text: string) => void;
|
|
onTranscript?: (text: string, full: string) => void;
|
|
onComplete?: (transcript: string) => void;
|
|
onError?: (error: Error) => void;
|
|
}
|
|
|
|
export function useVoiceStream(callbacks: StreamCallbacks = {}) {
|
|
const playback = useAudioPlayback();
|
|
|
|
const streamVoiceResponse = useCallback(
|
|
async (url: string, audioBlob: Blob) => {
|
|
await playback.init();
|
|
playback.clear();
|
|
|
|
// Convert blob to base64 for JSON body (server expects express.json())
|
|
const base64Audio = await new Promise<string>((resolve) => {
|
|
const fileReader = new FileReader();
|
|
fileReader.onload = () => {
|
|
const result = fileReader.result as string;
|
|
resolve(result.split(",")[1]); // Remove data URL prefix
|
|
};
|
|
fileReader.readAsDataURL(audioBlob);
|
|
});
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ audio: base64Audio }),
|
|
});
|
|
if (!response.ok) throw new Error("Voice request failed");
|
|
|
|
const streamReader = response.body?.getReader();
|
|
if (!streamReader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let fullTranscript = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await streamReader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
|
|
try {
|
|
const event = JSON.parse(line.slice(6));
|
|
|
|
switch (event.type) {
|
|
case "user_transcript":
|
|
callbacks.onUserTranscript?.(event.data);
|
|
break;
|
|
case "transcript":
|
|
fullTranscript += event.data;
|
|
callbacks.onTranscript?.(event.data, fullTranscript);
|
|
break;
|
|
case "audio":
|
|
playback.pushAudio(event.data);
|
|
break;
|
|
case "done":
|
|
playback.signalComplete();
|
|
callbacks.onComplete?.(fullTranscript);
|
|
break;
|
|
case "error":
|
|
throw new Error(event.error);
|
|
}
|
|
} catch (e) {
|
|
if (!(e instanceof SyntaxError)) {
|
|
callbacks.onError?.(e as Error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[playback, callbacks]
|
|
);
|
|
|
|
return { streamVoiceResponse, playbackState: playback.state };
|
|
}
|