diff --git a/src/app/api/tts/segments/audio/fallback/route.ts b/src/app/api/tts/segments/audio/fallback/route.ts new file mode 100644 index 0000000..b159305 --- /dev/null +++ b/src/app/api/tts/segments/audio/fallback/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { + resolveCompletedSegmentAudio, + streamAudioBuffer, + ttsSegmentsS3NotConfiguredResponse, +} from '@/lib/server/tts/segments-audio'; +import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + if (!isS3Configured()) return ttsSegmentsS3NotConfiguredResponse(); + + const resolved = await resolveCompletedSegmentAudio(request); + if (resolved instanceof Response) return resolved; + + const audio = await getTtsSegmentAudioObject(resolved.audioKey); + return new NextResponse(streamAudioBuffer(audio), { + headers: { + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + console.error('Error serving TTS segment audio fallback:', error); + return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); + } +} diff --git a/src/app/api/tts/segments/audio/presign/route.ts b/src/app/api/tts/segments/audio/presign/route.ts new file mode 100644 index 0000000..8ea4706 --- /dev/null +++ b/src/app/api/tts/segments/audio/presign/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { + resolveCompletedSegmentAudio, + ttsSegmentsS3NotConfiguredResponse, +} from '@/lib/server/tts/segments-audio'; +import { presignTtsSegmentAudioGet } from '@/lib/server/tts/segments-blobstore'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + if (!isS3Configured()) return ttsSegmentsS3NotConfiguredResponse(); + + const resolved = await resolveCompletedSegmentAudio(request); + if (resolved instanceof Response) return resolved; + + const fallbackUrl = `/api/tts/segments/audio/fallback?documentId=${encodeURIComponent(resolved.documentId)}&segmentId=${encodeURIComponent(resolved.segmentId)}`; + const directUrl = await presignTtsSegmentAudioGet(resolved.audioKey).catch(() => null); + if (!directUrl) { + console.warn('[blob-fallback] presign segment audio unavailable, redirecting to proxy fallback', { + documentId: resolved.documentId, + segmentId: resolved.segmentId, + }); + return NextResponse.redirect(fallbackUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + return NextResponse.redirect(directUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + console.error('Error creating segment audio signature:', error); + return NextResponse.json({ error: 'Failed to prepare segment audio' }, { status: 500 }); + } +} diff --git a/src/app/api/tts/segments/audio/route.ts b/src/app/api/tts/segments/audio/route.ts deleted file mode 100644 index e2e0a93..0000000 --- a/src/app/api/tts/segments/audio/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; -import { isS3Configured } from '@/lib/server/storage/s3'; -import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; - -export const dynamic = 'force-dynamic'; - -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - -function streamBuffer(buffer: Buffer): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(buffer)); - controller.close(); - }, - }); -} - -export async function GET(request: NextRequest) { - try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); - const segmentId = (request.nextUrl.searchParams.get('segmentId') || '').trim().toLowerCase(); - if (!documentId || !segmentId) { - return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); - } - - const scope = await resolveSegmentDocumentScope(request, documentId); - if (scope instanceof Response) return scope; - - const rows = (await db - .select({ - audioKey: ttsSegments.audioKey, - status: ttsSegments.status, - }) - .from(ttsSegments) - .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), - eq(ttsSegments.segmentId, segmentId), - ))) as Array<{ audioKey: string | null; status: string }>; - - const row = rows[0]; - if (!row || !row.audioKey || row.status !== 'completed') { - return NextResponse.json({ error: 'Segment audio not found' }, { status: 404 }); - } - - const audio = await getTtsSegmentAudioObject(row.audioKey); - - return new NextResponse(streamBuffer(audio), { - headers: { - 'Content-Type': 'audio/mpeg', - 'Cache-Control': 'private, max-age=86400', - }, - }); - } catch (error) { - console.error('Error serving TTS segment audio:', error); - return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); - } -} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index a64cd9c..e7cf577 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -108,6 +108,18 @@ function textHmacSecret(): string { || 'openreader-default-tts-segment-secret'; } +function buildSegmentAudioUrls(documentId: string, segmentId: string): { + audioPresignUrl: string; + audioFallbackUrl: string; +} { + const encodedDocumentId = encodeURIComponent(documentId); + const encodedSegmentId = encodeURIComponent(segmentId); + return { + audioPresignUrl: `/api/tts/segments/audio/presign?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + audioFallbackUrl: `/api/tts/segments/audio/fallback?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + }; +} + export async function POST(request: NextRequest) { let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; @@ -230,7 +242,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: existing.segmentIndex, - audioUrl: `/api/tts/segments/audio?documentId=${encodeURIComponent(parsed.documentId)}&segmentId=${encodeURIComponent(segment.segmentId)}`, + ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs: existing.durationMs ?? 0, alignment, locator, @@ -389,7 +401,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - audioUrl: `/api/tts/segments/audio?documentId=${encodeURIComponent(parsed.documentId)}&segmentId=${encodeURIComponent(segment.segmentId)}`, + ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs, alignment, locator: segment.locator, @@ -409,7 +421,8 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - audioUrl: '', + audioPresignUrl: null, + audioFallbackUrl: null, durationMs: 0, alignment: null, locator: segment.locator, @@ -420,8 +433,6 @@ export async function POST(request: NextRequest) { const response = NextResponse.json({ documentId: parsed.documentId, - documentVersion: scope.documentVersion, - settingsHash, segments: manifest, }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts deleted file mode 100644 index ef01812..0000000 --- a/src/app/api/tts/segments/manifest/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { and, asc, eq, gte, lte } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; -import { isS3Configured } from '@/lib/server/storage/s3'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; -import type { TTSSegmentManifestItem } from '@/types/client'; - -export const dynamic = 'force-dynamic'; - -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - -function parseIntOr(value: string | null, fallback: number): number { - const n = Number.parseInt(value || '', 10); - return Number.isFinite(n) ? n : fallback; -} - -export async function GET(request: NextRequest) { - try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); - const settingsHash = (request.nextUrl.searchParams.get('settingsHash') || '').trim(); - if (!documentId || !settingsHash) { - return NextResponse.json({ error: 'Missing documentId or settingsHash' }, { status: 400 }); - } - - const scope = await resolveSegmentDocumentScope(request, documentId); - if (scope instanceof Response) return scope; - - const fromIndex = Math.max(0, parseIntOr(request.nextUrl.searchParams.get('fromIndex'), 0)); - const toIndex = Math.max(fromIndex, parseIntOr(request.nextUrl.searchParams.get('toIndex'), fromIndex + 8)); - - const rows = (await db - .select() - .from(ttsSegments) - .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), - eq(ttsSegments.settingsHash, settingsHash), - gte(ttsSegments.segmentIndex, fromIndex), - lte(ttsSegments.segmentIndex, toIndex), - )) - .orderBy(asc(ttsSegments.segmentIndex))) as Array<{ - segmentId: string; - segmentIndex: number; - locatorJson: string | null; - durationMs: number | null; - alignmentJson: string | null; - status: string; - }>; - - const segments: TTSSegmentManifestItem[] = rows.map((row) => ({ - segmentId: row.segmentId, - segmentIndex: row.segmentIndex, - audioUrl: row.status === 'completed' - ? `/api/tts/segments/audio?documentId=${encodeURIComponent(documentId)}&segmentId=${encodeURIComponent(row.segmentId)}` - : '', - durationMs: row.durationMs ?? 0, - alignment: row.alignmentJson ? JSON.parse(row.alignmentJson) : null, - locator: row.locatorJson ? JSON.parse(row.locatorJson) : null, - status: row.status === 'error' ? 'error' : row.status === 'completed' ? 'completed' : 'pending', - })); - - return NextResponse.json({ - documentId, - documentVersion: scope.documentVersion, - settingsHash, - segments, - }); - } catch (error) { - console.error('Error fetching TTS segment manifest:', error); - return NextResponse.json({ error: 'Failed to fetch TTS segment manifest' }, { status: 500 }); - } -} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e5cb55c..aecb1e7 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -30,7 +30,6 @@ import toast from 'react-hot-toast'; import { useParams, usePathname } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; -import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; @@ -47,7 +46,6 @@ import type { TTSPageTurnEstimate, TTSPlaybackState, TTSSentenceAlignment, - TTSAudioBuffer, } from '@/types/tts'; import type { TTSRequestHeaders, @@ -100,6 +98,12 @@ interface SetTextOptions { nextText?: string; } +type TTSSegmentPlaybackSource = { + presignUrl: string; + fallbackUrl: string; + manifest: TTSSegmentManifestItem; +}; + const CONTINUATION_LOOKAHEAD = 600; const MAX_CONTINUATION_CARRY_CHARS = 220; const MAX_CONTINUATION_CARRY_WORDS = 40; @@ -323,7 +327,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Audio and voice management hooks const audioContext = useAudioContext(); - const audioCache = useAudioCache(AUDIO_CACHE_MAX_ITEMS); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); const { authEnabled, @@ -398,7 +401,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); // Track pending preload requests - const preloadRequests = useRef>>(new Map()); + const preloadRequests = useRef>>(new Map()); // Track active abort controllers for TTS requests const activeAbortControllers = useRef>(new Set()); // Synchronous guard to prevent duplicate playAudio calls from the main playback effect. @@ -1081,17 +1084,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [availableVoices, voice, configVoice, configTTSModel]); - /** - * Generates and plays audio for the current sentence - * - * @param {string} sentence - The sentence to generate audio for - * @returns {Promise} The generated audio buffer - */ - const getAudio = useCallback(async ( + const getSegmentPlaybackSource = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, - ): Promise => { + ): Promise => { const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || @@ -1104,13 +1101,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ); - const cachedAudio = audioCache.get(audioCacheKey); - if (cachedAudio) { - const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); - if (alignmentEnabledForCurrentDoc && cachedManifest?.alignment) { + const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); + if (cachedManifest?.status === 'completed' && cachedManifest.audioPresignUrl && cachedManifest.audioFallbackUrl) { + if (alignmentEnabledForCurrentDoc && cachedManifest.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, cachedManifest.alignment); } - return cachedAudio; + return { + presignUrl: cachedManifest.audioPresignUrl, + fallbackUrl: cachedManifest.audioFallbackUrl, + manifest: cachedManifest, + }; } if (!documentId) { @@ -1169,25 +1169,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); const segment = ensured.segments[0]; - if (!segment || segment.status !== 'completed' || !segment.audioUrl) { + if (!segment || segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) { throw new Error('Failed to prepare segment audio'); } - const response = await fetch(segment.audioUrl, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Segment audio fetch failed: ${response.status}`); - } - const arrayBuffer = await response.arrayBuffer(); - if (arrayBuffer.byteLength === 0) { - throw new Error('Received empty segment audio buffer'); - } - - audioCache.set(audioCacheKey, arrayBuffer); setSegmentManifestCache(audioCacheKey, segment); if (alignmentEnabledForCurrentDoc && segment.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, segment.alignment); } - return arrayBuffer; + return { + presignUrl: segment.audioPresignUrl, + fallbackUrl: segment.audioFallbackUrl, + manifest: segment, + }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return undefined; @@ -1234,7 +1228,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, ttsModel, ttsInstructions, - audioCache, openApiKey, openApiBaseUrl, configTTSProvider, @@ -1260,13 +1253,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * * @param {string} sentence - The sentence to process * @param {boolean} [preload=false] - Whether this is a preload request - * @returns {Promise} The URL of the processed audio + * @returns {Promise} Prepared playback source metadata */ const processSentence = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, - ): Promise => { + ): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); const requestKey = `${sentenceIndex}::${sentence}`; @@ -1288,18 +1281,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Create the audio processing promise const processPromise = (async () => { try { - const audioBuffer = await getAudio(sentence, sentenceIndex, preload); - if (!audioBuffer) { - // If quota or other handled error returns undefined, ensure we don't throw "No audio data" - // Just return empty string to signal graceful failure/skip - return ''; - } - - // Convert to base64 data URI - const bytes = new Uint8Array(audioBuffer); - const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), ''); - const base64String = btoa(binaryString); - return `data:audio/mp3;base64,${base64String}`; + const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload); + return source || null; } catch (error) { setIsProcessing(false); throw error; @@ -1320,7 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } return processPromise; - }, [audioContext, getAudio]); + }, [audioContext, getSegmentPlaybackSource]); /** * Plays the current sentence with Howl @@ -1338,15 +1321,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = 1000; // 1 second - const createHowl = async (retryCount = 0): Promise => { + const createHowl = async ( + retryCount = 0, + useFallbackSource = false, + ): Promise => { let playErrorAttempts = 0; - // Get the processed audio data URI directly from processSentence - const audioDataUri = await processSentence(sentence, sentenceIndex); - if (!audioDataUri) { + const playbackSource = await processSentence(sentence, sentenceIndex); + if (!playbackSource) { // Graceful exit for rate limit / abort / intentionally skipped sentence console.log('Skipping playback for sentence (no audio generated)'); return null; } + const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; // Force unload any previous Howl instance to free up resources if (activeHowl) { @@ -1357,7 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement let howlFinished = false; return new Howl({ - src: [audioDataUri], + src: [audioUrl], format: ['mp3', 'mpeg'], html5: true, preload: true, @@ -1465,6 +1451,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const actualError = error ?? soundId; console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); + // First load failure on presigned URL should fail over to proxy fallback immediately. + if (!useFallbackSource && playbackSource.fallbackUrl && playbackSource.fallbackUrl !== playbackSource.presignUrl) { + try { + this.unload(); + } catch { + // ignore unload errors + } + + try { + const fallbackHowl = await createHowl(retryCount, true); + if (fallbackHowl) { + setActiveHowl(fallbackHowl); + fallbackHowl.play(); + return; + } + } catch (fallbackError) { + console.error('Error switching to fallback segment source:', fallbackError); + } + } + if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount); @@ -1482,7 +1488,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Try to create a new Howl instance try { - const retryHowl = await createHowl(retryCount + 1); + const retryHowl = await createHowl(retryCount + 1, useFallbackSource); if (retryHowl) { setActiveHowl(retryHowl); retryHowl.play(); @@ -1693,7 +1699,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ); - if (audioCache.has(nextKey)) { + if (segmentManifestCacheRef.current.has(nextKey)) { preloadFromOffset(offset + 1); return; } @@ -1742,7 +1748,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } catch (error) { console.error('Error initiating preload:', error); } - }, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]); + }, [isAtLimit, currentIndex, sentences, processSentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]); /** * Main Playback Driver diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index 2395308..ba912cb 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -288,23 +288,3 @@ export const ensureTtsSegments = async ( return await response.json(); }; - -export const fetchTtsSegmentManifest = async (params: { - documentId: string; - settingsHash: string; - fromIndex: number; - toIndex: number; -}): Promise => { - const query = new URLSearchParams({ - documentId: params.documentId, - settingsHash: params.settingsHash, - fromIndex: String(params.fromIndex), - toIndex: String(params.toIndex), - }); - const response = await fetch(`/api/tts/segments/manifest?${query.toString()}`); - if (!response.ok) { - const data = await response.json().catch(() => null) as { error?: string } | null; - throw new Error(data?.error || 'Failed to fetch segment manifest'); - } - return await response.json(); -}; diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts new file mode 100644 index 0000000..e003cef --- /dev/null +++ b/src/lib/server/tts/segments-audio.ts @@ -0,0 +1,61 @@ +import { and, eq } from 'drizzle-orm'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { db } from '@/db'; +import { ttsSegments } from '@/db/schema'; +import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; + +export type ResolvedSegmentAudio = { + documentId: string; + segmentId: string; + audioKey: string; +}; + +export function ttsSegmentsS3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export function streamAudioBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +export async function resolveCompletedSegmentAudio( + request: NextRequest, +): Promise { + const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); + const segmentId = (request.nextUrl.searchParams.get('segmentId') || '').trim().toLowerCase(); + if (!documentId || !segmentId) { + return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); + } + + const scope = await resolveSegmentDocumentScope(request, documentId); + if (scope instanceof Response) return scope; + + const rows = (await db + .select({ + audioKey: ttsSegments.audioKey, + status: ttsSegments.status, + }) + .from(ttsSegments) + .where(and( + eq(ttsSegments.userId, scope.storageUserId), + eq(ttsSegments.documentId, documentId), + eq(ttsSegments.documentVersion, scope.documentVersion), + eq(ttsSegments.segmentId, segmentId), + ))) as Array<{ audioKey: string | null; status: string }>; + + const row = rows[0]; + if (!row || !row.audioKey || row.status !== 'completed') { + return NextResponse.json({ error: 'Segment audio not found' }, { status: 404 }); + } + + return { documentId, segmentId, audioKey: row.audioKey }; +} diff --git a/src/lib/server/tts/segments-blobstore.ts b/src/lib/server/tts/segments-blobstore.ts index 6bb3c8a..305bd57 100644 --- a/src/lib/server/tts/segments-blobstore.ts +++ b/src/lib/server/tts/segments-blobstore.ts @@ -4,7 +4,8 @@ import { ListObjectsV2Command, PutObjectCommand, } from '@aws-sdk/client-s3'; -import { getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3'; function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; @@ -64,6 +65,22 @@ export async function getTtsSegmentAudioObject(key: string): Promise { return bodyToBuffer(res.Body); } +export async function presignTtsSegmentAudioGet( + key: string, + options?: { expiresInSeconds?: number }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + return getSignedUrl( + client, + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + { expiresIn: Math.max(30, Math.min(options?.expiresInSeconds ?? 300, 3600)) }, + ); +} + export async function deleteTtsSegmentPrefix(prefix: string): Promise { const cfg = getS3Config(); const client = getS3ProxyClient(); diff --git a/src/types/client.ts b/src/types/client.ts index a1985fb..778a59e 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -115,7 +115,8 @@ export interface TTSSegmentsEnsureRequest { export interface TTSSegmentManifestItem { segmentId: string; segmentIndex: number; - audioUrl: string; + audioPresignUrl: string | null; + audioFallbackUrl: string | null; durationMs: number; alignment: TTSSentenceAlignment | null; locator: TTSSegmentLocator | null; @@ -124,7 +125,5 @@ export interface TTSSegmentManifestItem { export interface TTSSegmentsEnsureResponse { documentId: string; - documentVersion: number; - settingsHash: string; segments: TTSSegmentManifestItem[]; }