Skip to content

Commit

Permalink
Merge pull request #131 from Cloud-Code-AI/127-text-to-speech-for-docs
Browse files Browse the repository at this point in the history
127 text to speech for docs
  • Loading branch information
shreyashkgupta authored Nov 27, 2024
2 parents b1a9a98 + 0e021be commit ede19f8
Show file tree
Hide file tree
Showing 24 changed files with 2,151 additions and 529 deletions.
47 changes: 26 additions & 21 deletions docs/src/app/[locale]/[type]/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PageNavigation } from '@/components/layout/PageNavigation'
import { MainTitle, SubTitle } from '@/components/blocks/HeadingBlock'
import { SEO } from '@/components/layout/SEO'
import { NotFound } from '@/components/layout/NotFound'
import { TextToSpeech } from '@/components/tts/TextToSpeech'

export const runtime = 'edge'

Expand Down Expand Up @@ -61,27 +62,31 @@ export default function ContentPage({ params }: { params: Promise<{ locale: stri
<Navigation key={type} locale={locale} items={navigationItems} />
<div className="flex-1 flex py-4 w-full">
<PostContainer>
<PageBreadcrumb type={type} slug={slug} locale={locale} />
{process.env.NEXT_PUBLIC_AKIRADOCS_EDIT_MODE === 'true' && (
<Button
onClick={handleEdit}
variant="outline"
size="sm"
className="absolute top-0 right-6 flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<Edit2 className="w-4 h-4" />
Edit
</Button>
)}

<MainTitle>{post.title}</MainTitle>
<SubTitle>{post.description}</SubTitle>
{post.blocks.map((block) => (
block.content !== post.title && (
<BlockRenderer key={block.id} block={block} />
)
))}
<PageNavigation prev={prev} next={next} locale={locale} />
<div className="relative">
<PageBreadcrumb type={type} slug={slug} locale={locale} />
<div className="absolute top-0 right-0 flex gap-2">
{process.env.NEXT_PUBLIC_AKIRADOCS_EDIT_MODE === 'true' && (
<Button
onClick={handleEdit}
variant="outline"
size="sm"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<Edit2 className="w-4 h-4" />
Edit
</Button>
)}
<TextToSpeech blocks={post.blocks} />
</div>
<MainTitle>{post.title}</MainTitle>
<SubTitle>{post.description}</SubTitle>
{post.blocks.map((block) => (
block.content !== post.title && (
<BlockRenderer key={block.id} block={block} />
)
))}
<PageNavigation prev={prev} next={next} locale={locale} />
</div>
</PostContainer>
<TableOfContents publishDate={post.publishDate} modifiedDate={post.modifiedDate} author={post.author} />
</div>
Expand Down
210 changes: 210 additions & 0 deletions docs/src/components/tts/TextToSpeech.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
'use client'

import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Volume2, VolumeX, Settings2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

interface TextToSpeechProps {
blocks: any[];
}

// Define fixed voice options with clearer voices
const VOICE_OPTIONS = [
{ name: 'Samantha', lang: 'en-US' }, // Clear, natural female voice
{ name: 'Daniel', lang: 'en-GB' }, // Clear male voice
{ name: 'Karen', lang: 'en-AU' }, // Clear Australian female voice
{ name: 'Moira', lang: 'en-IE' } // Clear Irish female voice
];

export function TextToSpeech({ blocks }: TextToSpeechProps) {
const [mounted, setMounted] = useState(false);
const [speaking, setSpeaking] = useState(false);
const [selectedVoiceIndex, setSelectedVoiceIndex] = useState(0);

useEffect(() => {
setMounted(true);
}, []);

const getVoice = useCallback(() => {
if (typeof window === 'undefined') return null;
const voices = window.speechSynthesis.getVoices();
const preferredVoice = voices.find(voice =>
voice.name.includes(VOICE_OPTIONS[selectedVoiceIndex].name)
);
return preferredVoice || voices[0];
}, [selectedVoiceIndex]);

const generateSpeechText = (blocks: any[]) => {
return blocks
.filter(block => block.content !== undefined)
.map(block => {
try {
switch (block.type) {
case 'heading':
return `${block.content}. `;

case 'paragraph':
return `${block.content} `;

case 'list':
const listItems = Array.isArray(block.content) ? block.content : block.content.split('\n').filter((item: string) => item.trim());
return `${listItems.join(', ')}`;

case 'checkList':
if (block.metadata?.checkedItems) {
return `Checklist: ${block.metadata.checkedItems.map((item: { text: string; checked: boolean }) =>
`${item.text} (${item.checked ? 'checked' : 'unchecked'})`
).join(', ')}`;
}
return '';

case 'toggleList':
if (block.metadata?.items) {
return `Toggle list: ${block.metadata.items.map((item: { title: string; content: string }) =>
`${item.title}: ${item.content}`
).join('. ')}`;
}
return '';

case 'blockquote':
return `Quote: ${block.content}. `;

case 'code':
const lang = block.metadata?.language || 'code';
const filename = block.metadata?.filename ? ` in file ${block.metadata.filename}` : '';
return `Code block${filename} in ${lang}. `;

case 'table':
try {
const tableData = typeof block.content === 'string'
? JSON.parse(block.content)
: block.content;
return `Table with ${tableData.rows?.length || 0} rows and ${tableData.columns?.length || 0} columns. `;
} catch {
return 'Table content. ';
}

case 'image':
try {
const imageContent = typeof block.content === 'string'
? JSON.parse(block.content)
: block.content;
const caption = imageContent.caption ? `: ${imageContent.caption}` : '';
const alt = imageContent.alt ? ` showing ${imageContent.alt}` : '';
return `Image ${alt} ${caption}. `;
} catch {
return 'Image. ';
}

case 'video':
const videoCaption = block.metadata?.caption ? `: ${block.metadata.caption}` : '';
return `Video for ${videoCaption}. `;

case 'audio':
const audioCaption = block.metadata?.caption ? `: ${block.metadata.caption}` : '';
return `Audio for ${audioCaption}. `;

case 'file':
const fileName = block.metadata?.filename || 'file';
return `Downloadable file called ${fileName}. `;

case 'emoji':
return '';

case 'callout':
const type = block.metadata?.type || 'info';
const title = block.metadata?.title ? `, titled ${block.metadata.title}` : '';
return `${type} callout${title}: ${block.content}. `;

case 'divider':
return '';

default:
return '';
}
} catch (error) {
console.error(`Error processing block of type ${block.type}:`, error);
return '';
}
})
.join(' ');
};

const speak = useCallback(() => {
if (typeof window === 'undefined') return;

window.speechSynthesis.cancel();
const text = generateSpeechText(blocks);
const utterance = new SpeechSynthesisUtterance(text);

const voice = getVoice();
if (voice) utterance.voice = voice;

utterance.rate = 0.9;
utterance.pitch = 1;
utterance.volume = 1;

utterance.onstart = () => setSpeaking(true);
utterance.onend = () => setSpeaking(false);
utterance.onerror = () => setSpeaking(false);

window.speechSynthesis.speak(utterance);
}, [blocks, getVoice]);

const stop = useCallback(() => {
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
setSpeaking(false);
}
}, []);

if (!mounted) return null;

return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings2 className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{VOICE_OPTIONS.map((voice, index) => (
<DropdownMenuItem
key={voice.lang}
onClick={() => setSelectedVoiceIndex(index)}
className={selectedVoiceIndex === index ? "bg-accent" : ""}
>
{voice.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>

<Button
onClick={speaking ? stop : speak}
variant="outline"
size="sm"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
{speaking ? (
<>
<VolumeX className="w-4 h-4" />
Stop Reading
</>
) : (
<>
<Volume2 className="w-4 h-4" />
Read Content
</>
)}
</Button>
</div>
);
}
Loading

0 comments on commit ede19f8

Please sign in to comment.