Omarrran commited on
Commit
88b6846
·
0 Parent(s):

TTS Dataset Collector for HF Spaces

Browse files
Files changed (49) hide show
  1. .dockerignore +12 -0
  2. .gitignore +44 -0
  3. Dockerfile +65 -0
  4. README.md +47 -0
  5. docker-compose.yml +13 -0
  6. eslint.config.mjs +18 -0
  7. next.config.ts +18 -0
  8. package-lock.json +0 -0
  9. package.json +37 -0
  10. postcss.config.js +6 -0
  11. public/file.svg +1 -0
  12. public/globe.svg +1 -0
  13. public/next.svg +1 -0
  14. public/vercel.svg +1 -0
  15. public/window.svg +1 -0
  16. src/app/api/bookmarks/route.ts +103 -0
  17. src/app/api/dataset-stats/route.ts +60 -0
  18. src/app/api/export-dataset/route.ts +76 -0
  19. src/app/api/fonts/[filename]/route.ts +48 -0
  20. src/app/api/fonts/route.ts +46 -0
  21. src/app/api/save-recording/route.ts +104 -0
  22. src/app/api/skip-recording/route.ts +66 -0
  23. src/app/api/upload-font/route.ts +45 -0
  24. src/app/favicon.ico +0 -0
  25. src/app/globals.css +85 -0
  26. src/app/layout.tsx +43 -0
  27. src/app/page.module.css +141 -0
  28. src/app/page.tsx +429 -0
  29. src/components/AudioRecorder.tsx +406 -0
  30. src/components/DatasetStats.tsx +121 -0
  31. src/components/FontSelector.tsx +163 -0
  32. src/components/HelpModal.tsx +121 -0
  33. src/components/Providers.tsx +12 -0
  34. src/components/SettingsModal.tsx +124 -0
  35. src/components/TextInput.tsx +111 -0
  36. src/components/ui/badge.tsx +39 -0
  37. src/components/ui/card.tsx +78 -0
  38. src/components/ui/slider.tsx +32 -0
  39. src/components/ui/switch.tsx +33 -0
  40. src/hooks/useKeyboardShortcuts.ts +41 -0
  41. src/hooks/useLocalStorage.ts +31 -0
  42. src/instrumentation.ts +26 -0
  43. src/lib/cleanup.ts +195 -0
  44. src/lib/dataPath.ts +126 -0
  45. src/lib/language.ts +30 -0
  46. src/lib/utils.ts +6 -0
  47. start_app.bat +9 -0
  48. tailwind.config.ts +61 -0
  49. tsconfig.json +34 -0
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ .vs
5
+ dataset/audio
6
+ dataset/transcriptions
7
+ dataset/metadata/dataset_info.json
8
+ *.log
9
+ npm-debug.log*
10
+ yarn-debug.log*
11
+ yarn-error.log*
12
+ .pnpm-debug.log*
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
42
+
43
+ # Dataset folder (user data - created at runtime)
44
+ /dataset/
Dockerfile ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine AS base
2
+
3
+ # Install dependencies only when needed
4
+ FROM base AS deps
5
+ WORKDIR /app
6
+
7
+ # Copy package.json and package-lock.json
8
+ COPY package.json package-lock.json* ./
9
+
10
+ # Install dependencies
11
+ RUN npm ci
12
+
13
+ # Rebuild the source code only when needed
14
+ FROM base AS builder
15
+ WORKDIR /app
16
+ COPY --from=deps /app/node_modules ./node_modules
17
+ COPY . .
18
+
19
+ # Set environment variable for build
20
+ ENV NEXT_TELEMETRY_DISABLED=1
21
+
22
+ # Build the application
23
+ RUN npm run build
24
+
25
+ # Production image, copy all the files and run next
26
+ FROM base AS runner
27
+ WORKDIR /app
28
+
29
+ ENV NODE_ENV=production
30
+ ENV NEXT_TELEMETRY_DISABLED=1
31
+
32
+ # Create a non-root user with UID 1000 (required for HF Spaces)
33
+ RUN addgroup --system --gid 1001 nodejs
34
+ RUN adduser --system --uid 1000 nextjs
35
+
36
+ # Copy public folder
37
+ COPY --from=builder /app/public ./public
38
+
39
+ # Copy built application
40
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
41
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
42
+
43
+ # Create /data directory for HF Spaces persistent storage
44
+ # This directory is mounted at runtime on HF Spaces
45
+ RUN mkdir -p /data && chmod 777 /data
46
+
47
+ # Create local dataset directory as fallback (for non-HF environments)
48
+ RUN mkdir -p /app/dataset && chown nextjs:nodejs /app/dataset
49
+
50
+ # Set default data directory (overridden by HF Spaces when /data is available)
51
+ ENV DATA_DIR=/data
52
+
53
+ USER nextjs
54
+
55
+ # HF Spaces uses port 7860 by default
56
+ EXPOSE 7860
57
+
58
+ ENV PORT=7860
59
+ ENV HOSTNAME="0.0.0.0"
60
+
61
+ # Health check for container monitoring
62
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
63
+ CMD wget --no-verbose --tries=1 --spider http://localhost:7860/ || exit 1
64
+
65
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: TTS Dataset Collector
3
+ emoji: 🎙️
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_file: Dockerfile
8
+ app_port: 7860
9
+ pinned: false
10
+ ---
11
+
12
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
13
+
14
+ ## Getting Started
15
+
16
+ First, run the development server:
17
+
18
+ ```bash
19
+ npm run dev
20
+ # or
21
+ yarn dev
22
+ # or
23
+ pnpm dev
24
+ # or
25
+ bun dev
26
+ ```
27
+
28
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
29
+
30
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
31
+
32
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
33
+
34
+ ## Learn More
35
+
36
+ To learn more about Next.js, take a look at the following resources:
37
+
38
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
39
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
40
+
41
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
42
+
43
+ ## Deploy on Vercel
44
+
45
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
46
+
47
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
docker-compose.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ tts-collector:
5
+ container_name: tts-dataset-collector
6
+ build: .
7
+ ports:
8
+ - "3000:3000"
9
+ volumes:
10
+ - ./dataset:/app/dataset
11
+ restart: unless-stopped
12
+ environment:
13
+ - NODE_ENV=production
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
next.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: 'standalone',
5
+ env: {
6
+ DATA_DIR: process.env.DATA_DIR || '',
7
+ },
8
+ // Ensure server-side code can access environment variables
9
+ serverRuntimeConfig: {
10
+ DATA_DIR: process.env.DATA_DIR || '',
11
+ },
12
+ // Enable instrumentation for cleanup scheduler
13
+ experimental: {
14
+ instrumentationHook: true,
15
+ },
16
+ };
17
+
18
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "polar-interstellar",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@types/archiver": "^7.0.0",
13
+ "archiver": "^7.0.1",
14
+ "class-variance-authority": "^0.7.1",
15
+ "clsx": "^2.1.1",
16
+ "framer-motion": "^12.23.24",
17
+ "franc": "^6.2.0",
18
+ "jszip": "^3.10.1",
19
+ "lucide-react": "^0.555.0",
20
+ "next": "16.0.5",
21
+ "react": "19.2.0",
22
+ "react-dom": "19.2.0",
23
+ "sonner": "^2.0.7",
24
+ "tailwind-merge": "^3.4.0",
25
+ "tailwindcss": "^3.4.18"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20",
29
+ "@types/react": "^19",
30
+ "@types/react-dom": "^19",
31
+ "autoprefixer": "^10.4.22",
32
+ "eslint": "^9",
33
+ "eslint-config-next": "16.0.5",
34
+ "postcss": "^8.5.6",
35
+ "typescript": "^5"
36
+ }
37
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
src/app/api/bookmarks/route.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getMetadataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
5
+
6
+ export async function POST(request: Request) {
7
+ try {
8
+ const body = await request.json();
9
+ const { speaker_id, dataset_name, index } = body;
10
+
11
+ if (!speaker_id || !dataset_name || index === undefined) {
12
+ return NextResponse.json({ error: 'Missing parameters: speaker_id, dataset_name, and index are required' }, { status: 400 });
13
+ }
14
+
15
+ // Validate index is a number
16
+ if (typeof index !== 'number' || index < 0) {
17
+ return NextResponse.json({ error: 'Invalid index: must be a non-negative number' }, { status: 400 });
18
+ }
19
+
20
+ // Sanitize inputs
21
+ const safeSpeakerId = sanitizePath(speaker_id);
22
+ const safeDatasetName = sanitizePath(dataset_name);
23
+
24
+ const metadataDir = getMetadataPath();
25
+ await ensureDir(metadataDir);
26
+ const metadataPath = path.join(metadataDir, 'dataset_info.json');
27
+
28
+ let datasetInfo: Record<string, unknown> = { speakers: {} };
29
+ try {
30
+ const content = await fs.readFile(metadataPath, 'utf-8');
31
+ datasetInfo = JSON.parse(content);
32
+ } catch {
33
+ // File might not exist
34
+ }
35
+
36
+ // Ensure nested structure exists
37
+ const speakers = (datasetInfo.speakers || {}) as Record<string, { datasets?: Record<string, { bookmarks?: number[] }> }>;
38
+ if (!speakers[safeSpeakerId]) {
39
+ speakers[safeSpeakerId] = { datasets: {} };
40
+ }
41
+ if (!speakers[safeSpeakerId].datasets) {
42
+ speakers[safeSpeakerId].datasets = {};
43
+ }
44
+ if (!speakers[safeSpeakerId].datasets![safeDatasetName]) {
45
+ speakers[safeSpeakerId].datasets![safeDatasetName] = { bookmarks: [] };
46
+ }
47
+
48
+ const ds = speakers[safeSpeakerId].datasets![safeDatasetName];
49
+ if (!ds.bookmarks) ds.bookmarks = [];
50
+
51
+ // Toggle bookmark
52
+ if (ds.bookmarks.includes(index)) {
53
+ ds.bookmarks = ds.bookmarks.filter((i: number) => i !== index);
54
+ } else {
55
+ ds.bookmarks.push(index);
56
+ // Sort bookmarks for consistency
57
+ ds.bookmarks.sort((a, b) => a - b);
58
+ }
59
+
60
+ datasetInfo.speakers = speakers;
61
+ await fs.writeFile(metadataPath, JSON.stringify(datasetInfo, null, 2));
62
+
63
+ return NextResponse.json({ success: true, bookmarks: ds.bookmarks });
64
+
65
+ } catch (error) {
66
+ console.error('Error toggling bookmark:', error);
67
+ return NextResponse.json({
68
+ error: 'Internal Server Error',
69
+ details: error instanceof Error ? error.message : 'Unknown error'
70
+ }, { status: 500 });
71
+ }
72
+ }
73
+
74
+ export async function GET(request: Request) {
75
+ try {
76
+ const { searchParams } = new URL(request.url);
77
+ const speaker_id = searchParams.get('speaker_id');
78
+ const dataset_name = searchParams.get('dataset_name');
79
+
80
+ if (!speaker_id || !dataset_name) {
81
+ return NextResponse.json({ bookmarks: [] });
82
+ }
83
+
84
+ // Sanitize inputs
85
+ const safeSpeakerId = sanitizePath(speaker_id);
86
+ const safeDatasetName = sanitizePath(dataset_name);
87
+
88
+ const metadataPath = path.join(getMetadataPath(), 'dataset_info.json');
89
+
90
+ try {
91
+ const content = await fs.readFile(metadataPath, 'utf-8');
92
+ const datasetInfo = JSON.parse(content);
93
+ const speakers = datasetInfo.speakers as Record<string, { datasets?: Record<string, { bookmarks?: number[] }> }>;
94
+ const bookmarks = speakers[safeSpeakerId]?.datasets?.[safeDatasetName]?.bookmarks || [];
95
+ return NextResponse.json({ bookmarks });
96
+ } catch {
97
+ return NextResponse.json({ bookmarks: [] });
98
+ }
99
+ } catch (error) {
100
+ console.error('Error getting bookmarks:', error);
101
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
102
+ }
103
+ }
src/app/api/dataset-stats/route.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getMetadataPath, ensureDir, getDataDir } from '@/lib/dataPath';
5
+ import { getCleanupConfig } from '@/lib/cleanup';
6
+
7
+ export async function GET() {
8
+ try {
9
+ const metadataDir = getMetadataPath();
10
+ await ensureDir(metadataDir);
11
+ const metadataPath = path.join(metadataDir, 'dataset_info.json');
12
+
13
+ let metadata: Record<string, unknown> = { speakers: {}, last_updated: null };
14
+ try {
15
+ const content = await fs.readFile(metadataPath, 'utf-8');
16
+ metadata = JSON.parse(content);
17
+ } catch {
18
+ // File might not exist yet
19
+ }
20
+
21
+ // Flatten statistics
22
+ let recordedSentences = 0;
23
+ const speakers = metadata.speakers as Record<string, { datasets?: Record<string, { recordings?: number }> }>;
24
+
25
+ for (const speakerId in speakers) {
26
+ const speaker = speakers[speakerId];
27
+ if (speaker?.datasets) {
28
+ for (const datasetName in speaker.datasets) {
29
+ const dataset = speaker.datasets[datasetName];
30
+ recordedSentences += dataset?.recordings || 0;
31
+ }
32
+ }
33
+ }
34
+
35
+ // Get cleanup configuration for display
36
+ const cleanupConfig = getCleanupConfig();
37
+
38
+ return NextResponse.json({
39
+ lastUpdated: metadata.last_updated || null,
40
+ total_recordings: Number(metadata.total_recordings) || 0,
41
+ total_duration: Number(metadata.total_duration) || 0,
42
+ recent_recordings: Array.isArray(metadata.recent_recordings) ? metadata.recent_recordings : [],
43
+ speakers: Object.keys(speakers).length,
44
+ recordedSentences,
45
+ // Include storage info for transparency
46
+ storage: {
47
+ dataDirectory: getDataDir(),
48
+ autoCleanupHours: cleanupConfig.maxFileAgeHours,
49
+ isHuggingFaceSpaces: getDataDir() === '/data'
50
+ },
51
+ details: metadata
52
+ });
53
+ } catch (error) {
54
+ console.error('Error getting stats:', error);
55
+ return NextResponse.json({
56
+ error: 'Internal Server Error',
57
+ details: error instanceof Error ? error.message : 'Unknown error'
58
+ }, { status: 500 });
59
+ }
60
+ }
src/app/api/export-dataset/route.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import archiver from 'archiver';
5
+ import { getDataPath, sanitizePath } from '@/lib/dataPath';
6
+
7
+ export async function GET(request: Request) {
8
+ const { searchParams } = new URL(request.url);
9
+ const datasetName = searchParams.get('dataset_name');
10
+
11
+ if (!datasetName) {
12
+ return NextResponse.json({ error: 'Dataset name required' }, { status: 400 });
13
+ }
14
+
15
+ // Sanitize dataset name to prevent path traversal
16
+ const safeDatasetName = sanitizePath(datasetName);
17
+ const datasetDir = getDataPath(safeDatasetName);
18
+
19
+ try {
20
+ await fs.access(datasetDir);
21
+ } catch {
22
+ return NextResponse.json({ error: 'Dataset not found' }, { status: 404 });
23
+ }
24
+
25
+ const archive = archiver('zip', {
26
+ zlib: { level: 9 } // Sets the compression level.
27
+ });
28
+
29
+ const stream = new ReadableStream({
30
+ async start(controller) {
31
+ archive.on('data', (chunk) => {
32
+ controller.enqueue(chunk);
33
+ });
34
+
35
+ archive.on('end', () => {
36
+ controller.close();
37
+ });
38
+
39
+ archive.on('error', (err) => {
40
+ console.error('Archive error:', err);
41
+ controller.error(err);
42
+ });
43
+
44
+ // Generate metadata.csv for LJSpeech compatibility
45
+ try {
46
+ const metadataPath = path.join(datasetDir, 'dataset_info.json');
47
+ const metadataContent = await fs.readFile(metadataPath, 'utf-8');
48
+ const metadata = JSON.parse(metadataContent);
49
+
50
+ if (metadata.recordings && Array.isArray(metadata.recordings)) {
51
+ const csvContent = metadata.recordings.map((r: { filename?: string; text?: string }) =>
52
+ `${r.filename || ''}|${r.text || ''}`
53
+ ).join('\n');
54
+ archive.append(csvContent, { name: 'metadata.csv' });
55
+ }
56
+ } catch (e) {
57
+ console.warn('Could not generate metadata.csv', e);
58
+ }
59
+
60
+ try {
61
+ archive.directory(datasetDir, false);
62
+ archive.finalize();
63
+ } catch (error) {
64
+ console.error('Error finalizing archive:', error);
65
+ controller.error(error);
66
+ }
67
+ }
68
+ });
69
+
70
+ return new NextResponse(stream, {
71
+ headers: {
72
+ 'Content-Type': 'application/zip',
73
+ 'Content-Disposition': `attachment; filename="${safeDatasetName}.zip"`
74
+ }
75
+ });
76
+ }
src/app/api/fonts/[filename]/route.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getFontsPath, sanitizePath } from '@/lib/dataPath';
5
+
6
+ export async function GET(
7
+ request: Request,
8
+ { params }: { params: Promise<{ filename: string }> }
9
+ ) {
10
+ try {
11
+ const { filename } = await params;
12
+
13
+ // Security check: prevent directory traversal
14
+ if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
15
+ return new NextResponse('Invalid filename', { status: 400 });
16
+ }
17
+
18
+ // Validate file extension
19
+ const ext = path.extname(filename).toLowerCase();
20
+ if (ext !== '.ttf' && ext !== '.otf') {
21
+ return new NextResponse('Invalid font file type', { status: 400 });
22
+ }
23
+
24
+ // Sanitize filename (but preserve extension)
25
+ const baseName = sanitizePath(path.basename(filename, ext), 100);
26
+ const safeFilename = `${baseName}${ext}`;
27
+
28
+ const fontsDir = getFontsPath();
29
+ const filePath = path.join(fontsDir, filename); // Use original filename for lookup
30
+
31
+ try {
32
+ const fileBuffer = await fs.readFile(filePath);
33
+ const contentType = ext === '.otf' ? 'font/otf' : 'font/ttf';
34
+
35
+ return new NextResponse(fileBuffer, {
36
+ headers: {
37
+ 'Content-Type': contentType,
38
+ 'Cache-Control': 'public, max-age=31536000, immutable',
39
+ },
40
+ });
41
+ } catch {
42
+ return new NextResponse('Font not found', { status: 404 });
43
+ }
44
+ } catch (error) {
45
+ console.error('Error serving font:', error);
46
+ return new NextResponse('Internal Server Error', { status: 500 });
47
+ }
48
+ }
src/app/api/fonts/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getFontsPath, ensureDir } from '@/lib/dataPath';
5
+
6
+ const DEFAULT_FONTS = [
7
+ { name: "Times New Roman", family: "Times New Roman", css: "font-family: 'Times New Roman', serif;" },
8
+ { name: "Arial", family: "Arial", css: "font-family: Arial, sans-serif;" },
9
+ { name: "Nastaliq", family: "Noto Nastaliq Urdu", css: "font-family: 'Noto Nastaliq Urdu', serif;" },
10
+ { name: "Naskh", family: "Scheherazade New", css: "font-family: 'Scheherazade New', serif;" }
11
+ ];
12
+
13
+ export async function GET() {
14
+ try {
15
+ const fontsDir = getFontsPath();
16
+ await ensureDir(fontsDir);
17
+
18
+ let files: string[] = [];
19
+ try {
20
+ files = await fs.readdir(fontsDir);
21
+ } catch {
22
+ // Directory might not exist yet
23
+ }
24
+
25
+ const customFonts = files
26
+ .filter(file => file.endsWith('.ttf') || file.endsWith('.otf'))
27
+ .map(file => {
28
+ const family = path.basename(file, path.extname(file));
29
+ return {
30
+ name: file,
31
+ family: family,
32
+ css: `font-family: '${family}', serif;`,
33
+ url: `/api/fonts/${encodeURIComponent(file)}`,
34
+ isCustom: true
35
+ };
36
+ });
37
+
38
+ return NextResponse.json({ fonts: [...DEFAULT_FONTS, ...customFonts] });
39
+ } catch (error) {
40
+ console.error('Error listing fonts:', error);
41
+ return NextResponse.json({
42
+ error: 'Internal Server Error',
43
+ details: error instanceof Error ? error.message : 'Unknown error'
44
+ }, { status: 500 });
45
+ }
46
+ }
src/app/api/save-recording/route.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getAudioPath, getTranscriptionsPath, getMetadataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
5
+
6
+ export async function POST(request: Request) {
7
+ try {
8
+ const formData = await request.formData();
9
+ const audioFile = formData.get('audio') as File;
10
+ const metadataStr = formData.get('metadata') as string;
11
+
12
+ if (!audioFile || !metadataStr) {
13
+ return NextResponse.json({ error: 'Missing audio or metadata' }, { status: 400 });
14
+ }
15
+
16
+ let metadata;
17
+ try {
18
+ metadata = JSON.parse(metadataStr);
19
+ } catch {
20
+ return NextResponse.json({ error: 'Invalid metadata JSON' }, { status: 400 });
21
+ }
22
+
23
+ const { speaker_id, dataset_name, text, font_style, timestamp, emotion, rating, duration } = metadata;
24
+
25
+ // Validate required fields
26
+ if (!speaker_id || !dataset_name || !text) {
27
+ return NextResponse.json({ error: 'Missing required fields: speaker_id, dataset_name, or text' }, { status: 400 });
28
+ }
29
+
30
+ // Sanitize inputs to prevent path traversal
31
+ const safeSpeakerId = sanitizePath(speaker_id);
32
+ const safeDataset = sanitizePath(dataset_name);
33
+ const safeText = sanitizePath(text.substring(0, 20));
34
+
35
+ // Get paths using centralized data path utility
36
+ const audioDir = getAudioPath(safeSpeakerId);
37
+ const textDir = getTranscriptionsPath(safeSpeakerId);
38
+
39
+ // Ensure directories exist
40
+ await ensureDir(audioDir);
41
+ await ensureDir(textDir);
42
+
43
+ // Generate filename with timestamp for uniqueness
44
+ const timeStr = new Date().toISOString().replace(/-/g, '').replace(/:/g, '').replace(/\./g, '').substring(0, 14);
45
+ const index = metadata.index !== undefined ? `line${metadata.index + 1}` : `t${timeStr}`;
46
+
47
+ const baseName = `${safeDataset}_${safeSpeakerId}_${index}_${safeText}_${timeStr}`;
48
+ const audioName = `${baseName}.wav`;
49
+ const textName = `${baseName}.txt`;
50
+
51
+ // Save Audio
52
+ const audioBuffer = Buffer.from(await audioFile.arrayBuffer());
53
+ await fs.writeFile(path.join(audioDir, audioName), audioBuffer);
54
+
55
+ // Save Transcription
56
+ const transcriptionContent = `[METADATA]
57
+ Recording_ID: ${audioName}
58
+ Speaker_ID: ${speaker_id}
59
+ Dataset_Name: ${dataset_name}
60
+ Timestamp: ${timestamp || new Date().toISOString()}
61
+ Font_Style: ${font_style || 'default'}
62
+ Emotion: ${emotion || 'neutral'}
63
+ Rating: ${rating || 3}
64
+ Duration: ${duration || 0}
65
+ [TEXT]
66
+ ${text}
67
+ `;
68
+ await fs.writeFile(path.join(textDir, textName), transcriptionContent);
69
+
70
+ // Update Metadata
71
+ const metadataDir = getMetadataPath();
72
+ await ensureDir(metadataDir);
73
+ const metadataPath = path.join(metadataDir, 'dataset_info.json');
74
+
75
+ let datasetInfo: Record<string, unknown> = {
76
+ speakers: {},
77
+ last_updated: null,
78
+ total_duration: 0,
79
+ total_recordings: 0
80
+ };
81
+
82
+ try {
83
+ const content = await fs.readFile(metadataPath, 'utf-8');
84
+ datasetInfo = JSON.parse(content);
85
+ } catch {
86
+ // File doesn't exist yet, use defaults
87
+ }
88
+
89
+ datasetInfo.last_updated = new Date().toISOString();
90
+ datasetInfo.total_recordings = (Number(datasetInfo.total_recordings) || 0) + 1;
91
+ datasetInfo.total_duration = (Number(datasetInfo.total_duration) || 0) + (Number(duration) || 0);
92
+
93
+ await fs.writeFile(metadataPath, JSON.stringify(datasetInfo, null, 2));
94
+
95
+ return NextResponse.json({ success: true, filename: audioName });
96
+
97
+ } catch (error) {
98
+ console.error('Error saving recording:', error);
99
+ return NextResponse.json({
100
+ error: 'Internal Server Error',
101
+ details: error instanceof Error ? error.message : 'Unknown error'
102
+ }, { status: 500 });
103
+ }
104
+ }
src/app/api/skip-recording/route.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getDataPath, ensureDir, sanitizePath } from '@/lib/dataPath';
5
+
6
+ export async function POST(request: Request) {
7
+ try {
8
+ const data = await request.json();
9
+ const { speaker_id, dataset_name, index, text, reason } = data;
10
+
11
+ if (!speaker_id || !dataset_name) {
12
+ return NextResponse.json({ success: false, error: 'Missing required fields: speaker_id and dataset_name' }, { status: 400 });
13
+ }
14
+
15
+ // Sanitize inputs
16
+ const safeSpeakerId = sanitizePath(speaker_id);
17
+ const safeDatasetName = sanitizePath(dataset_name);
18
+
19
+ const datasetDir = getDataPath(safeDatasetName);
20
+ const skippedFile = path.join(datasetDir, 'skipped.json');
21
+
22
+ // Ensure dataset directory exists
23
+ await ensureDir(datasetDir);
24
+
25
+ let skippedData: Array<{
26
+ speaker_id: string;
27
+ index: number;
28
+ text: string;
29
+ reason: string;
30
+ timestamp: string;
31
+ }> = [];
32
+
33
+ try {
34
+ const fileContent = await fs.readFile(skippedFile, 'utf-8');
35
+ skippedData = JSON.parse(fileContent);
36
+
37
+ // Ensure it's an array
38
+ if (!Array.isArray(skippedData)) {
39
+ skippedData = [];
40
+ }
41
+ } catch {
42
+ // File doesn't exist or is empty, start new
43
+ }
44
+
45
+ const newSkip = {
46
+ speaker_id: safeSpeakerId,
47
+ index: typeof index === 'number' ? index : -1,
48
+ text: typeof text === 'string' ? text.substring(0, 500) : '', // Limit text length
49
+ reason: typeof reason === 'string' ? reason : 'Skipped by user',
50
+ timestamp: new Date().toISOString()
51
+ };
52
+
53
+ skippedData.push(newSkip);
54
+
55
+ await fs.writeFile(skippedFile, JSON.stringify(skippedData, null, 2));
56
+
57
+ return NextResponse.json({ success: true });
58
+ } catch (error) {
59
+ console.error('Error skipping recording:', error);
60
+ return NextResponse.json({
61
+ success: false,
62
+ error: 'Internal server error',
63
+ details: error instanceof Error ? error.message : 'Unknown error'
64
+ }, { status: 500 });
65
+ }
66
+ }
src/app/api/upload-font/route.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { getFontsPath, ensureDir, sanitizePath } from '@/lib/dataPath';
5
+
6
+ export async function POST(request: Request) {
7
+ try {
8
+ const formData = await request.formData();
9
+ const fontFile = formData.get('font') as File;
10
+
11
+ if (!fontFile) {
12
+ return NextResponse.json({ error: 'No font file provided' }, { status: 400 });
13
+ }
14
+
15
+ if (!fontFile.name.endsWith('.ttf') && !fontFile.name.endsWith('.otf')) {
16
+ return NextResponse.json({ error: 'Only .ttf and .otf files are supported' }, { status: 400 });
17
+ }
18
+
19
+ // Validate file size (max 10MB)
20
+ const MAX_FONT_SIZE = 10 * 1024 * 1024; // 10MB
21
+ if (fontFile.size > MAX_FONT_SIZE) {
22
+ return NextResponse.json({ error: 'Font file too large (max 10MB)' }, { status: 400 });
23
+ }
24
+
25
+ const fontsDir = getFontsPath();
26
+ await ensureDir(fontsDir);
27
+
28
+ // Sanitize original name and add timestamp for uniqueness
29
+ const timestamp = Date.now();
30
+ const safeName = sanitizePath(fontFile.name.replace(/\.(ttf|otf)$/i, ''), 40);
31
+ const extension = fontFile.name.endsWith('.otf') ? '.otf' : '.ttf';
32
+ const fileName = `font_${timestamp}_${safeName}${extension}`;
33
+
34
+ const buffer = Buffer.from(await fontFile.arrayBuffer());
35
+ await fs.writeFile(path.join(fontsDir, fileName), buffer);
36
+
37
+ return NextResponse.json({ success: true, filename: fileName });
38
+ } catch (error) {
39
+ console.error('Error uploading font:', error);
40
+ return NextResponse.json({
41
+ error: 'Internal Server Error',
42
+ details: error instanceof Error ? error.message : 'Unknown error'
43
+ }, { status: 500 });
44
+ }
45
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222.2 84% 4.9%;
9
+
10
+ --card: 0 0% 100%;
11
+ --card-foreground: 222.2 84% 4.9%;
12
+
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 222.2 84% 4.9%;
15
+
16
+ --primary: 221.2 83.2% 53.3%;
17
+ --primary-foreground: 210 40% 98%;
18
+
19
+ --secondary: 210 40% 96.1%;
20
+ --secondary-foreground: 222.2 47.4% 11.2%;
21
+
22
+ --muted: 210 40% 96.1%;
23
+ --muted-foreground: 215.4 16.3% 46.9%;
24
+
25
+ --accent: 210 40% 96.1%;
26
+ --accent-foreground: 222.2 47.4% 11.2%;
27
+
28
+ --destructive: 0 84.2% 60.2%;
29
+ --destructive-foreground: 210 40% 98%;
30
+
31
+ --border: 214.3 31.8% 91.4%;
32
+ --input: 214.3 31.8% 91.4%;
33
+ --ring: 221.2 83.2% 53.3%;
34
+
35
+ --radius: 0.5rem;
36
+ }
37
+
38
+ .dark {
39
+ --background: 222.2 84% 4.9%;
40
+ --foreground: 210 40% 98%;
41
+
42
+ --card: 222.2 84% 4.9%;
43
+ --card-foreground: 210 40% 98%;
44
+
45
+ --popover: 222.2 84% 4.9%;
46
+ --popover-foreground: 210 40% 98%;
47
+
48
+ --primary: 217.2 91.2% 59.8%;
49
+ --primary-foreground: 222.2 47.4% 11.2%;
50
+
51
+ --secondary: 217.2 32.6% 17.5%;
52
+ --secondary-foreground: 210 40% 98%;
53
+
54
+ --muted: 217.2 32.6% 17.5%;
55
+ --muted-foreground: 215 20.2% 65.1%;
56
+
57
+ --accent: 217.2 32.6% 17.5%;
58
+ --accent-foreground: 210 40% 98%;
59
+
60
+ --destructive: 0 62.8% 30.6%;
61
+ --destructive-foreground: 210 40% 98%;
62
+
63
+ --border: 217.2 32.6% 17.5%;
64
+ --input: 217.2 32.6% 17.5%;
65
+ --ring: 212.7 26.8% 83.9%;
66
+ }
67
+ }
68
+
69
+ @layer base {
70
+ * {
71
+ @apply border-border;
72
+ }
73
+
74
+ body {
75
+ @apply bg-background text-foreground;
76
+ }
77
+ }
78
+
79
+ .glass {
80
+ @apply bg-white/10 backdrop-blur-lg border border-white/20;
81
+ }
82
+
83
+ .glass-card {
84
+ @apply bg-card/50 backdrop-blur-md border border-border/50 shadow-xl;
85
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { DM_Sans, Playfair_Display, JetBrains_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+ import { Providers } from "@/components/Providers";
5
+
6
+ const dmSans = DM_Sans({
7
+ subsets: ["latin"],
8
+ variable: '--font-dm-sans',
9
+ display: 'swap',
10
+ });
11
+
12
+ const playfair = Playfair_Display({
13
+ subsets: ["latin"],
14
+ variable: '--font-playfair',
15
+ display: 'swap',
16
+ });
17
+
18
+ const jetbrains = JetBrains_Mono({
19
+ subsets: ["latin"],
20
+ variable: '--font-jetbrains',
21
+ display: 'swap',
22
+ });
23
+
24
+ export const metadata: Metadata = {
25
+ title: "TTS Dataset Collector - Build Speech Datasets",
26
+ description: "Advanced TTS Dataset Collection Tool",
27
+ };
28
+
29
+ export default function RootLayout({
30
+ children,
31
+ }: Readonly<{
32
+ children: React.ReactNode;
33
+ }>) {
34
+ return (
35
+ <html lang="en" className="dark">
36
+ <body className={`${dmSans.variable} ${playfair.variable} ${jetbrains.variable} font-sans antialiased bg-background text-foreground min-h-screen selection:bg-primary/30 selection:text-primary-foreground`}>
37
+ <Providers>
38
+ {children}
39
+ </Providers>
40
+ </body>
41
+ </html>
42
+ );
43
+ }
src/app/page.module.css ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page {
2
+ --background: #fafafa;
3
+ --foreground: #fff;
4
+
5
+ --text-primary: #000;
6
+ --text-secondary: #666;
7
+
8
+ --button-primary-hover: #383838;
9
+ --button-secondary-hover: #f2f2f2;
10
+ --button-secondary-border: #ebebeb;
11
+
12
+ display: flex;
13
+ min-height: 100vh;
14
+ align-items: center;
15
+ justify-content: center;
16
+ font-family: var(--font-geist-sans);
17
+ background-color: var(--background);
18
+ }
19
+
20
+ .main {
21
+ display: flex;
22
+ min-height: 100vh;
23
+ width: 100%;
24
+ max-width: 800px;
25
+ flex-direction: column;
26
+ align-items: flex-start;
27
+ justify-content: space-between;
28
+ background-color: var(--foreground);
29
+ padding: 120px 60px;
30
+ }
31
+
32
+ .intro {
33
+ display: flex;
34
+ flex-direction: column;
35
+ align-items: flex-start;
36
+ text-align: left;
37
+ gap: 24px;
38
+ }
39
+
40
+ .intro h1 {
41
+ max-width: 320px;
42
+ font-size: 40px;
43
+ font-weight: 600;
44
+ line-height: 48px;
45
+ letter-spacing: -2.4px;
46
+ text-wrap: balance;
47
+ color: var(--text-primary);
48
+ }
49
+
50
+ .intro p {
51
+ max-width: 440px;
52
+ font-size: 18px;
53
+ line-height: 32px;
54
+ text-wrap: balance;
55
+ color: var(--text-secondary);
56
+ }
57
+
58
+ .intro a {
59
+ font-weight: 500;
60
+ color: var(--text-primary);
61
+ }
62
+
63
+ .ctas {
64
+ display: flex;
65
+ flex-direction: row;
66
+ width: 100%;
67
+ max-width: 440px;
68
+ gap: 16px;
69
+ font-size: 14px;
70
+ }
71
+
72
+ .ctas a {
73
+ display: flex;
74
+ justify-content: center;
75
+ align-items: center;
76
+ height: 40px;
77
+ padding: 0 16px;
78
+ border-radius: 128px;
79
+ border: 1px solid transparent;
80
+ transition: 0.2s;
81
+ cursor: pointer;
82
+ width: fit-content;
83
+ font-weight: 500;
84
+ }
85
+
86
+ a.primary {
87
+ background: var(--text-primary);
88
+ color: var(--background);
89
+ gap: 8px;
90
+ }
91
+
92
+ a.secondary {
93
+ border-color: var(--button-secondary-border);
94
+ }
95
+
96
+ /* Enable hover only on non-touch devices */
97
+ @media (hover: hover) and (pointer: fine) {
98
+ a.primary:hover {
99
+ background: var(--button-primary-hover);
100
+ border-color: transparent;
101
+ }
102
+
103
+ a.secondary:hover {
104
+ background: var(--button-secondary-hover);
105
+ border-color: transparent;
106
+ }
107
+ }
108
+
109
+ @media (max-width: 600px) {
110
+ .main {
111
+ padding: 48px 24px;
112
+ }
113
+
114
+ .intro {
115
+ gap: 16px;
116
+ }
117
+
118
+ .intro h1 {
119
+ font-size: 32px;
120
+ line-height: 40px;
121
+ letter-spacing: -1.92px;
122
+ }
123
+ }
124
+
125
+ @media (prefers-color-scheme: dark) {
126
+ .logo {
127
+ filter: invert();
128
+ }
129
+
130
+ .page {
131
+ --background: #000;
132
+ --foreground: #000;
133
+
134
+ --text-primary: #ededed;
135
+ --text-secondary: #999;
136
+
137
+ --button-primary-hover: #ccc;
138
+ --button-secondary-hover: #1a1a1a;
139
+ --button-secondary-border: #1a1a1a;
140
+ }
141
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect } from 'react';
4
+ import HelpModal from '@/components/HelpModal';
5
+
6
+
7
+ import TextInput from '@/components/TextInput';
8
+ import AudioRecorder from '@/components/AudioRecorder';
9
+ import FontSelector from '@/components/FontSelector';
10
+ import DatasetStats from '@/components/DatasetStats';
11
+ import SettingsModal from '@/components/SettingsModal';
12
+ import { Mic2, Moon, Sun, Settings, Search, SkipForward, SkipBack, Bookmark, Hash, HelpCircle } from 'lucide-react';
13
+ import { motion, AnimatePresence } from 'framer-motion';
14
+ import { useLocalStorage } from '@/hooks/useLocalStorage';
15
+ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
16
+ import { toast } from 'sonner';
17
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
18
+ import { Badge } from '@/components/ui/badge';
19
+ import { cn } from '@/lib/utils';
20
+ import { detectLanguage, isRTL } from '@/lib/language';
21
+
22
+ export default function Home() {
23
+ const [sentences, setSentences] = useState<string[]>([]);
24
+ const [currentIndex, setCurrentIndex] = useLocalStorage('currentIndex', 0);
25
+ const [speakerId, setSpeakerId] = useLocalStorage('speakerId', '');
26
+ const [datasetName, setDatasetName] = useLocalStorage('datasetName', 'dataset1');
27
+ const [fontStyle, setFontStyle] = useLocalStorage('fontStyle', 'Times New Roman');
28
+ const [fontFamily, setFontFamily] = useState('Times New Roman'); // Actual CSS font family
29
+ const [darkMode, setDarkMode] = useLocalStorage('darkMode', true);
30
+
31
+ // Settings
32
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
33
+ const [isHelpOpen, setIsHelpOpen] = useState(false);
34
+ const [autoAdvance, setAutoAdvance] = useLocalStorage('autoAdvance', true);
35
+ const [autoSave, setAutoSave] = useLocalStorage('autoSave', false);
36
+ const [silenceThreshold, setSilenceThreshold] = useLocalStorage('silenceThreshold', 5);
37
+
38
+ // Navigation & Search
39
+ const [jumpIndex, setJumpIndex] = useState('');
40
+ const [bookmarks, setBookmarks] = useState<number[]>([]);
41
+ const [detectedLang, setDetectedLang] = useState('eng');
42
+ const [isRTLDir, setIsRTLDir] = useState(false);
43
+
44
+ useEffect(() => {
45
+ if (sentences.length > 0 && sentences[currentIndex]) {
46
+ const lang = detectLanguage(sentences[currentIndex]);
47
+ setDetectedLang(lang);
48
+ setIsRTLDir(isRTL(lang));
49
+ }
50
+ }, [currentIndex, sentences]);
51
+
52
+ const [searchQuery, setSearchQuery] = useState('');
53
+
54
+ useEffect(() => {
55
+ if (darkMode) {
56
+ document.documentElement.classList.add('dark');
57
+ } else {
58
+ document.documentElement.classList.remove('dark');
59
+ }
60
+ }, [darkMode]);
61
+
62
+ useEffect(() => {
63
+ if (speakerId && datasetName) {
64
+ fetch(`/api/bookmarks?speaker_id=${speakerId}&dataset_name=${datasetName}`)
65
+ .then(res => res.json())
66
+ .then(data => setBookmarks(data.bookmarks || []));
67
+ }
68
+ }, [speakerId, datasetName]);
69
+
70
+ // Keyboard Shortcuts
71
+ useKeyboardShortcuts({
72
+ 'arrowright': () => handleNext(),
73
+ 'arrowleft': () => handlePrev(),
74
+ 'ctrl+s': () => document.getElementById('save-btn')?.click(),
75
+ ' ': () => document.getElementById('record-btn')?.click(),
76
+ 'ctrl+f': () => document.getElementById('search-input')?.focus(),
77
+ });
78
+
79
+ const handleSentencesLoaded = (loadedSentences: string[]) => {
80
+ setSentences(loadedSentences);
81
+ setCurrentIndex(0);
82
+ };
83
+
84
+ const handleNext = () => {
85
+ if (currentIndex < sentences.length - 1) {
86
+ setCurrentIndex(prev => prev + 1);
87
+ } else {
88
+ toast.info('Reached end of sentences');
89
+ }
90
+ };
91
+
92
+ const handlePrev = () => {
93
+ if (currentIndex > 0) {
94
+ setCurrentIndex(prev => prev - 1);
95
+ }
96
+ };
97
+
98
+ const handleJump = (e: React.FormEvent) => {
99
+ e.preventDefault();
100
+ const idx = parseInt(jumpIndex) - 1;
101
+ if (idx >= 0 && idx < sentences.length) {
102
+ setCurrentIndex(idx);
103
+ setJumpIndex('');
104
+ } else {
105
+ toast.error('Invalid sentence number');
106
+ }
107
+ };
108
+
109
+ const handleSearch = (e: React.FormEvent) => {
110
+ e.preventDefault();
111
+ if (!searchQuery) return;
112
+
113
+ // Find next occurrence after current index
114
+ let nextIndex = sentences.findIndex((s, i) => i > currentIndex && s.toLowerCase().includes(searchQuery.toLowerCase()));
115
+
116
+ // If not found, wrap around
117
+ if (nextIndex === -1) {
118
+ nextIndex = sentences.findIndex((s) => s.toLowerCase().includes(searchQuery.toLowerCase()));
119
+ }
120
+
121
+ if (nextIndex !== -1) {
122
+ setCurrentIndex(nextIndex);
123
+ toast.success(`Found match at #${nextIndex + 1}`);
124
+ } else {
125
+ toast.error('No matches found');
126
+ }
127
+ };
128
+
129
+ const handleSkip = async () => {
130
+ try {
131
+ const res = await fetch('/api/skip-recording', {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({
135
+ speaker_id: speakerId,
136
+ dataset_name: datasetName,
137
+ index: currentIndex,
138
+ text: sentences[currentIndex],
139
+ reason: 'User skipped'
140
+ })
141
+ });
142
+
143
+ if (res.ok) {
144
+ toast.info('Sentence skipped');
145
+ handleNext();
146
+ } else {
147
+ toast.error('Failed to skip');
148
+ }
149
+ } catch (err) {
150
+ toast.error('Error skipping');
151
+ }
152
+ };
153
+
154
+ const handleFontChange = (font: string) => {
155
+ setFontStyle(font);
156
+ setFontFamily(font);
157
+ };
158
+
159
+ const toggleBookmark = async () => {
160
+ try {
161
+ const res = await fetch('/api/bookmarks', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ speaker_id: speakerId, dataset_name: datasetName, index: currentIndex })
165
+ });
166
+ const data = await res.json();
167
+ if (data.success) {
168
+ setBookmarks(data.bookmarks);
169
+ toast.success(bookmarks.includes(currentIndex) ? 'Bookmark removed' : 'Bookmarked');
170
+ }
171
+ } catch (err) {
172
+ toast.error('Failed to toggle bookmark');
173
+ }
174
+ };
175
+
176
+ return (
177
+ <main className="min-h-screen pb-20 transition-colors duration-300 bg-background text-foreground">
178
+ <header className="sticky top-0 z-20 border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
179
+ <div className="container py-4 flex items-center justify-between">
180
+ <div className="flex items-center gap-3">
181
+ <div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20">
182
+ <Mic2 className="w-6 h-6" />
183
+ </div>
184
+ <h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-400">
185
+ TTS Dataset Collector
186
+ </h1>
187
+ </div>
188
+
189
+ <div className="flex items-center gap-3">
190
+ <div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border/50 text-sm font-medium">
191
+ <span className="opacity-70">Sentence</span>
192
+ <span className="text-primary">{currentIndex + 1}</span>
193
+ <span className="opacity-40">/</span>
194
+ <span className="opacity-70">{sentences.length || 0}</span>
195
+ </div>
196
+
197
+ <button
198
+ onClick={() => setIsHelpOpen(true)}
199
+ className="btn btn-ghost rounded-full w-10 h-10 p-0"
200
+ title="Help"
201
+ >
202
+ <HelpCircle className="w-5 h-5" />
203
+ </button>
204
+
205
+ <button
206
+ onClick={() => setIsSettingsOpen(true)}
207
+ className="btn btn-ghost rounded-full w-10 h-10 p-0"
208
+ title="Settings"
209
+ >
210
+ <Settings className="w-5 h-5" />
211
+ </button>
212
+
213
+ <button
214
+ onClick={() => setDarkMode(!darkMode)}
215
+ className="btn btn-ghost rounded-full w-10 h-10 p-0"
216
+ title="Toggle Dark Mode"
217
+ >
218
+ {darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
219
+ </button>
220
+ </div>
221
+ </div>
222
+ </header>
223
+
224
+ <div className="container grid grid-cols-1 lg:grid-cols-12 gap-8 mt-8">
225
+ {/* Left Sidebar */}
226
+ <div className="lg:col-span-4 space-y-6">
227
+ <motion.div
228
+ initial={{ opacity: 0, x: -20 }}
229
+ animate={{ opacity: 1, x: 0 }}
230
+ transition={{ duration: 0.3 }}
231
+ >
232
+ <Card>
233
+ <CardHeader>
234
+ <CardTitle className="text-lg">Configuration</CardTitle>
235
+ </CardHeader>
236
+ <CardContent className="space-y-4">
237
+ <div>
238
+ <label className="label">Speaker ID</label>
239
+ <input
240
+ type="text"
241
+ className="input"
242
+ placeholder="e.g. spk_001"
243
+ value={speakerId}
244
+ onChange={(e) => setSpeakerId(e.target.value)}
245
+ />
246
+ </div>
247
+ <div>
248
+ <label className="label">Dataset Name</label>
249
+ <input
250
+ type="text"
251
+ className="input"
252
+ placeholder="e.g. common_voice"
253
+ value={datasetName}
254
+ onChange={(e) => setDatasetName(e.target.value)}
255
+ />
256
+ </div>
257
+ </CardContent>
258
+ </Card>
259
+ </motion.div>
260
+
261
+ <FontSelector currentFont={fontStyle} onFontChange={handleFontChange} />
262
+
263
+ <TextInput onSentencesLoaded={handleSentencesLoaded} />
264
+
265
+ <DatasetStats />
266
+ </div>
267
+
268
+ {/* Main Content */}
269
+ <div className="lg:col-span-8 space-y-6">
270
+ <AnimatePresence mode="wait">
271
+ {sentences.length > 0 ? (
272
+ <motion.div
273
+ key="content"
274
+ initial={{ opacity: 0, y: 20 }}
275
+ animate={{ opacity: 1, y: 0 }}
276
+ exit={{ opacity: 0, y: -20 }}
277
+ transition={{ duration: 0.3 }}
278
+ className="space-y-6"
279
+ >
280
+ {/* Navigation Bar */}
281
+ <div className="flex items-center gap-2 overflow-x-auto pb-2">
282
+ <form onSubmit={handleJump} className="flex items-center gap-2">
283
+ <div className="relative">
284
+ <Hash className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
285
+ <input
286
+ type="number"
287
+ className="input w-24 pl-9"
288
+ placeholder="Jump"
289
+ value={jumpIndex}
290
+ onChange={(e) => setJumpIndex(e.target.value)}
291
+ />
292
+ </div>
293
+ </form>
294
+
295
+ <form onSubmit={handleSearch} className="flex items-center gap-2">
296
+ <div className="relative">
297
+ <Search className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
298
+ <input
299
+ id="search-input"
300
+ type="text"
301
+ className="input w-32 md:w-48 pl-9"
302
+ placeholder="Find text..."
303
+ value={searchQuery}
304
+ onChange={(e) => setSearchQuery(e.target.value)}
305
+ />
306
+ </div>
307
+ </form>
308
+
309
+ <div className="flex-1" />
310
+
311
+ <button onClick={() => setCurrentIndex(0)} className="btn btn-secondary text-xs" title="First">
312
+ First
313
+ </button>
314
+ <button onClick={() => setCurrentIndex(sentences.length - 1)} className="btn btn-secondary text-xs" title="Last">
315
+ Last
316
+ </button>
317
+ </div>
318
+
319
+ <Card className="border-primary/20 shadow-lg shadow-primary/5">
320
+ <CardContent className="pt-6">
321
+ <div className="flex justify-between items-center mb-6">
322
+ <div className="flex gap-2">
323
+ <Badge variant="outline" className="opacity-70">
324
+ SENTENCE {currentIndex + 1}
325
+ </Badge>
326
+ <Badge variant="secondary" className="opacity-50 text-xs uppercase">
327
+ {detectedLang}
328
+ </Badge>
329
+ </div>
330
+ <div className="flex gap-2">
331
+ <button
332
+ className={cn(
333
+ "p-2 rounded-full transition-colors",
334
+ bookmarks.includes(currentIndex)
335
+ ? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
336
+ : "hover:bg-secondary opacity-50 hover:opacity-100"
337
+ )}
338
+ onClick={toggleBookmark}
339
+ title="Bookmark"
340
+ >
341
+ <Bookmark className={cn("w-4 h-4", bookmarks.includes(currentIndex) && "fill-current")} />
342
+ </button>
343
+ </div>
344
+ </div>
345
+
346
+ <motion.div
347
+ key={currentIndex}
348
+ initial={{ opacity: 0, scale: 0.98 }}
349
+ animate={{ opacity: 1, scale: 1 }}
350
+ transition={{ duration: 0.2 }}
351
+ className={cn(
352
+ "sentence-display",
353
+ isRTLDir && "text-right"
354
+ )}
355
+ style={{ fontFamily: fontFamily, direction: isRTLDir ? 'rtl' : 'ltr' }}
356
+ >
357
+ {sentences[currentIndex]}
358
+ </motion.div>
359
+ </CardContent>
360
+ </Card>
361
+
362
+ {currentIndex < sentences.length - 1 && (
363
+ <motion.div
364
+ initial={{ opacity: 0 }}
365
+ animate={{ opacity: 0.6 }}
366
+ className="rounded-xl border border-dashed border-border p-4 bg-secondary/10"
367
+ >
368
+ <div className="text-xs font-bold opacity-50 uppercase tracking-wider mb-2">Next Up</div>
369
+ <div
370
+ className="text-lg text-center opacity-70 line-clamp-1"
371
+ style={{ fontFamily: fontFamily }}
372
+ >
373
+ {sentences[currentIndex + 1]}
374
+ </div>
375
+ </motion.div>
376
+ )}
377
+
378
+ <AudioRecorder
379
+ speakerId={speakerId}
380
+ datasetName={datasetName}
381
+ text={sentences[currentIndex]}
382
+ fontStyle={fontStyle}
383
+ index={currentIndex}
384
+ onSaved={() => { }}
385
+ onNext={handleNext}
386
+ onPrev={handlePrev}
387
+ onSkip={handleSkip}
388
+ hasPrev={currentIndex > 0}
389
+ hasNext={currentIndex < sentences.length - 1}
390
+ autoAdvance={autoAdvance}
391
+ autoSave={autoSave}
392
+ silenceThreshold={silenceThreshold}
393
+ />
394
+ </motion.div>
395
+ ) : (
396
+ <motion.div
397
+ key="empty"
398
+ initial={{ opacity: 0, scale: 0.9 }}
399
+ animate={{ opacity: 1, scale: 1 }}
400
+ className="flex flex-col items-center justify-center py-20 text-center opacity-70"
401
+ >
402
+ <div className="w-24 h-24 bg-secondary/50 rounded-full flex items-center justify-center mb-6">
403
+ <Mic2 className="w-10 h-10 text-primary/50" />
404
+ </div>
405
+ <h3 className="text-2xl font-bold mb-3">No Sentences Loaded</h3>
406
+ <p className="text-lg max-w-md mx-auto text-muted-foreground">
407
+ Import a text file or paste content to begin your recording session.
408
+ </p>
409
+ </motion.div>
410
+ )}
411
+ </AnimatePresence>
412
+ </div>
413
+ </div>
414
+
415
+ <SettingsModal
416
+ isOpen={isSettingsOpen}
417
+ onClose={() => setIsSettingsOpen(false)}
418
+ autoAdvance={autoAdvance}
419
+ setAutoAdvance={setAutoAdvance}
420
+ autoSave={autoSave}
421
+ setAutoSave={setAutoSave}
422
+ silenceThreshold={silenceThreshold}
423
+ setSilenceThreshold={setSilenceThreshold}
424
+ datasetName={datasetName}
425
+ />
426
+ <HelpModal isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
427
+ </main>
428
+ );
429
+ }
src/components/AudioRecorder.tsx ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect } from 'react';
4
+ import { Mic, Square, Save, Play, SkipForward, SkipBack, Star, Volume2 } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { toast } from 'sonner';
7
+ import { Card, CardContent } from '@/components/ui/card';
8
+ import { Badge } from '@/components/ui/badge';
9
+ import { cn } from '@/lib/utils';
10
+
11
+ interface AudioRecorderProps {
12
+ speakerId: string;
13
+ datasetName: string;
14
+ text: string;
15
+ fontStyle: string;
16
+ index: number;
17
+ onSaved: () => void;
18
+ onNext: () => void;
19
+ onPrev: () => void;
20
+ onSkip: () => void;
21
+ hasPrev: boolean;
22
+ hasNext: boolean;
23
+ autoAdvance: boolean;
24
+ autoSave: boolean;
25
+ silenceThreshold: number;
26
+ }
27
+
28
+ const EMOTIONS = [
29
+ { id: 'neutral', label: 'Neutral', color: 'bg-gray-500' },
30
+ { id: 'happy', label: 'Happy', color: 'bg-yellow-500' },
31
+ { id: 'sad', label: 'Sad', color: 'bg-blue-500' },
32
+ { id: 'angry', label: 'Angry', color: 'bg-red-500' },
33
+ { id: 'surprised', label: 'Surprised', color: 'bg-purple-500' },
34
+ { id: 'whisper', label: 'Whisper', color: 'bg-indigo-500' },
35
+ { id: 'excited', label: 'Excited', color: 'bg-orange-500' },
36
+ ];
37
+
38
+ export default function AudioRecorder({
39
+ speakerId,
40
+ datasetName,
41
+ text,
42
+ fontStyle,
43
+ index,
44
+ onSaved,
45
+ onNext,
46
+ onPrev,
47
+ onSkip,
48
+ hasPrev,
49
+ hasNext,
50
+ autoAdvance,
51
+ autoSave,
52
+ silenceThreshold
53
+ }: AudioRecorderProps) {
54
+ const [isRecording, setIsRecording] = useState(false);
55
+ const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
56
+ const [audioUrl, setAudioUrl] = useState<string | null>(null);
57
+ const [isSaving, setIsSaving] = useState(false);
58
+ const [isSilent, setIsSilent] = useState(false);
59
+ const [emotion, setEmotion] = useState('neutral');
60
+ const [rating, setRating] = useState(3);
61
+ const [duration, setDuration] = useState(0);
62
+ const [audioLevel, setAudioLevel] = useState(0);
63
+
64
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
65
+ const chunksRef = useRef<Blob[]>([]);
66
+ const canvasRef = useRef<HTMLCanvasElement>(null);
67
+ const audioContextRef = useRef<AudioContext | null>(null);
68
+ const analyserRef = useRef<AnalyserNode | null>(null);
69
+ const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
70
+ const animationFrameRef = useRef<number | null>(null);
71
+ const startTimeRef = useRef<number>(0);
72
+
73
+ useEffect(() => {
74
+ return () => {
75
+ if (audioUrl) URL.revokeObjectURL(audioUrl);
76
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
77
+ if (audioContextRef.current) audioContextRef.current.close();
78
+ };
79
+ }, [audioUrl]);
80
+
81
+ const startRecording = async () => {
82
+ try {
83
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
84
+ mediaRecorderRef.current = new MediaRecorder(stream);
85
+ chunksRef.current = [];
86
+ startTimeRef.current = Date.now();
87
+
88
+ mediaRecorderRef.current.ondataavailable = (e) => {
89
+ if (e.data.size > 0) chunksRef.current.push(e.data);
90
+ };
91
+
92
+ mediaRecorderRef.current.onstop = () => {
93
+ const blob = new Blob(chunksRef.current, { type: 'audio/wav' });
94
+ setAudioBlob(blob);
95
+ setAudioUrl(URL.createObjectURL(blob));
96
+ setDuration((Date.now() - startTimeRef.current) / 1000);
97
+ stopVisualizer();
98
+
99
+ if (autoSave) {
100
+ saveRecording(blob);
101
+ }
102
+ };
103
+
104
+ mediaRecorderRef.current.start();
105
+ setIsRecording(true);
106
+ startVisualizer(stream);
107
+ toast.info('Recording started');
108
+ } catch (err) {
109
+ console.error('Error accessing microphone:', err);
110
+ toast.error('Could not access microphone');
111
+ }
112
+ };
113
+
114
+ const stopRecording = () => {
115
+ if (mediaRecorderRef.current && isRecording) {
116
+ mediaRecorderRef.current.stop();
117
+ setIsRecording(false);
118
+ mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
119
+ toast.info('Recording stopped');
120
+ }
121
+ };
122
+
123
+ const startVisualizer = (stream: MediaStream) => {
124
+ if (!canvasRef.current) return;
125
+
126
+ // @ts-ignore
127
+ audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
128
+ analyserRef.current = audioContextRef.current.createAnalyser();
129
+ sourceRef.current = audioContextRef.current.createMediaStreamSource(stream);
130
+
131
+ sourceRef.current.connect(analyserRef.current);
132
+ analyserRef.current.fftSize = 256;
133
+
134
+ const bufferLength = analyserRef.current.frequencyBinCount;
135
+ const dataArray = new Uint8Array(bufferLength);
136
+ const canvas = canvasRef.current;
137
+ const ctx = canvas.getContext('2d');
138
+ if (!ctx) return;
139
+
140
+ const draw = () => {
141
+ if (!analyserRef.current) return;
142
+
143
+ animationFrameRef.current = requestAnimationFrame(draw);
144
+ analyserRef.current.getByteTimeDomainData(dataArray);
145
+
146
+ // Calculate RMS for level meter
147
+ let sum = 0;
148
+ for (let i = 0; i < bufferLength; i++) {
149
+ const x = (dataArray[i] - 128) / 128.0;
150
+ sum += x * x;
151
+ }
152
+ const rms = Math.sqrt(sum / bufferLength);
153
+ const db = 20 * Math.log10(rms);
154
+ // Normalize db to 0-1 range (approx -60db to 0db)
155
+ const level = Math.max(0, (db + 60) / 60);
156
+ setAudioLevel(level);
157
+
158
+ // Silence detection
159
+ const isCurrentlySilent = level < (silenceThreshold / 100);
160
+ setIsSilent(isCurrentlySilent);
161
+
162
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
163
+
164
+ ctx.lineWidth = 2;
165
+ ctx.strokeStyle = isCurrentlySilent ? 'rgb(239, 68, 68)' : 'rgb(139, 92, 246)';
166
+ ctx.beginPath();
167
+
168
+ const sliceWidth = canvas.width * 1.0 / bufferLength;
169
+ let x = 0;
170
+
171
+ for (let i = 0; i < bufferLength; i++) {
172
+ const v = dataArray[i] / 128.0;
173
+ const y = v * canvas.height / 2;
174
+
175
+ if (i === 0) {
176
+ ctx.moveTo(x, y);
177
+ } else {
178
+ ctx.lineTo(x, y);
179
+ }
180
+
181
+ x += sliceWidth;
182
+ }
183
+
184
+ ctx.lineTo(canvas.width, canvas.height / 2);
185
+ ctx.stroke();
186
+ };
187
+
188
+ draw();
189
+ };
190
+
191
+ const stopVisualizer = () => {
192
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
193
+ setAudioLevel(0);
194
+ };
195
+
196
+ const saveRecording = async (blobToSave: Blob | null = audioBlob) => {
197
+ if (!blobToSave || !speakerId || !datasetName) {
198
+ toast.error('Missing recording or metadata');
199
+ return;
200
+ }
201
+
202
+ setIsSaving(true);
203
+ const formData = new FormData();
204
+ formData.append('audio', blobToSave);
205
+ formData.append('metadata', JSON.stringify({
206
+ speaker_id: speakerId,
207
+ dataset_name: datasetName,
208
+ text: text,
209
+ font_style: fontStyle,
210
+ index: index,
211
+ emotion: emotion,
212
+ rating: rating,
213
+ duration: duration,
214
+ timestamp: new Date().toISOString()
215
+ }));
216
+
217
+ try {
218
+ const res = await fetch('/api/save-recording', {
219
+ method: 'POST',
220
+ body: formData
221
+ });
222
+
223
+ if (res.ok) {
224
+ onSaved();
225
+ setAudioBlob(null);
226
+ setAudioUrl(null);
227
+ toast.success('Recording saved successfully');
228
+ if (autoAdvance) {
229
+ onNext();
230
+ }
231
+ } else {
232
+ toast.error('Failed to save recording');
233
+ }
234
+ } catch (err) {
235
+ console.error('Error saving:', err);
236
+ toast.error('Error saving recording');
237
+ } finally {
238
+ setIsSaving(false);
239
+ }
240
+ };
241
+
242
+ return (
243
+ <Card className="border-primary/10">
244
+ <CardContent className="p-6 space-y-6">
245
+ {/* Meter Bar */}
246
+ <div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
247
+ <div
248
+ className="h-full bg-primary transition-all duration-75 ease-out"
249
+ style={{ width: `${Math.min(100, audioLevel * 100)}%` }}
250
+ />
251
+ </div>
252
+
253
+ <div className="flex justify-center relative">
254
+ <canvas
255
+ ref={canvasRef}
256
+ width={600}
257
+ height={100}
258
+ className="w-full h-24 bg-secondary/30 rounded-xl border border-border"
259
+ />
260
+ {isRecording && isSilent && (
261
+ <Badge variant="destructive" className="absolute top-2 right-2 animate-pulse">
262
+ SILENCE DETECTED
263
+ </Badge>
264
+ )}
265
+ </div>
266
+
267
+ <div className="flex justify-center gap-6 items-center">
268
+ <AnimatePresence mode="wait">
269
+ {!isRecording ? (
270
+ <motion.button
271
+ key="record"
272
+ initial={{ scale: 0 }}
273
+ animate={{ scale: 1 }}
274
+ exit={{ scale: 0 }}
275
+ whileHover={{ scale: 1.1 }}
276
+ whileTap={{ scale: 0.9 }}
277
+ className="w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-pink-600 flex items-center justify-center shadow-lg shadow-red-500/30 hover:shadow-red-500/50 transition-shadow"
278
+ onClick={startRecording}
279
+ id="record-btn"
280
+ >
281
+ <Mic className="w-8 h-8 text-white" />
282
+ </motion.button>
283
+ ) : (
284
+ <motion.button
285
+ key="stop"
286
+ initial={{ scale: 0 }}
287
+ animate={{ scale: 1 }}
288
+ exit={{ scale: 0 }}
289
+ whileHover={{ scale: 1.1 }}
290
+ whileTap={{ scale: 0.9 }}
291
+ className="w-20 h-20 rounded-full bg-secondary flex items-center justify-center shadow-lg hover:bg-secondary/80"
292
+ onClick={stopRecording}
293
+ >
294
+ <Square className="w-8 h-8 fill-current" />
295
+ </motion.button>
296
+ )}
297
+ </AnimatePresence>
298
+ </div>
299
+
300
+ <AnimatePresence>
301
+ {audioUrl && (
302
+ <motion.div
303
+ initial={{ opacity: 0, height: 0 }}
304
+ animate={{ opacity: 1, height: 'auto' }}
305
+ exit={{ opacity: 0, height: 0 }}
306
+ className="space-y-6 overflow-hidden"
307
+ >
308
+ <div className="flex items-center justify-between gap-4 p-4 bg-secondary/20 rounded-xl border border-border/50">
309
+ <audio src={audioUrl} controls className="flex-1 h-8" />
310
+ <div className="flex items-center gap-2 text-sm font-medium">
311
+ <Volume2 className="w-4 h-4 text-muted-foreground" />
312
+ {duration.toFixed(1)}s
313
+ </div>
314
+ </div>
315
+
316
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
317
+ <div className="space-y-2">
318
+ <label className="label">Emotion</label>
319
+ <div className="flex flex-wrap gap-2">
320
+ {EMOTIONS.map((e) => (
321
+ <button
322
+ key={e.id}
323
+ onClick={() => setEmotion(e.id)}
324
+ className={cn(
325
+ "px-3 py-1.5 rounded-full text-xs font-medium transition-all border",
326
+ emotion === e.id
327
+ ? "bg-primary text-primary-foreground border-primary"
328
+ : "bg-secondary/50 text-muted-foreground border-transparent hover:bg-secondary"
329
+ )}
330
+ >
331
+ {e.label}
332
+ </button>
333
+ ))}
334
+ </div>
335
+ </div>
336
+
337
+ <div className="space-y-2">
338
+ <label className="label">Quality Rating</label>
339
+ <div className="flex gap-1">
340
+ {[1, 2, 3, 4, 5].map((star) => (
341
+ <button
342
+ key={star}
343
+ onClick={() => setRating(star)}
344
+ className="p-1 hover:scale-110 transition-transform"
345
+ >
346
+ <Star
347
+ className={cn(
348
+ "w-6 h-6 transition-colors",
349
+ star <= rating ? "fill-yellow-500 text-yellow-500" : "text-muted-foreground"
350
+ )}
351
+ />
352
+ </button>
353
+ ))}
354
+ </div>
355
+ </div>
356
+ </div>
357
+
358
+ <div className="flex gap-4 pt-4 border-t border-border/50">
359
+ <button
360
+ className="btn btn-secondary flex-1"
361
+ onClick={onPrev}
362
+ disabled={!hasPrev}
363
+ >
364
+ <SkipBack className="w-4 h-4 mr-2" />
365
+ Previous
366
+ </button>
367
+
368
+ <button
369
+ className="btn btn-secondary flex-1"
370
+ onClick={onSkip}
371
+ title="Skip this sentence"
372
+ >
373
+ <SkipForward className="w-4 h-4 mr-2" />
374
+ Skip
375
+ </button>
376
+
377
+ <button
378
+ className="btn btn-primary flex-[2]"
379
+ onClick={() => saveRecording()}
380
+ disabled={isSaving}
381
+ id="save-btn"
382
+ >
383
+ {isSaving ? 'Saving...' : (
384
+ <>
385
+ <Save className="w-4 h-4 mr-2" />
386
+ Save & Next
387
+ </>
388
+ )}
389
+ </button>
390
+
391
+ <button
392
+ className="btn btn-secondary flex-1"
393
+ onClick={onNext}
394
+ disabled={!hasNext}
395
+ >
396
+ Next
397
+ <SkipForward className="w-4 h-4 ml-2" />
398
+ </button>
399
+ </div>
400
+ </motion.div>
401
+ )}
402
+ </AnimatePresence>
403
+ </CardContent>
404
+ </Card>
405
+ );
406
+ }
src/components/DatasetStats.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { BarChart3, Clock, Mic, Users, History, Play } from 'lucide-react';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { Badge } from '@/components/ui/badge';
7
+
8
+ interface Recording {
9
+ filename: string;
10
+ text: string;
11
+ duration: number;
12
+ timestamp: string;
13
+ emotion: string;
14
+ rating: number;
15
+ }
16
+
17
+ interface Stats {
18
+ total_recordings: number;
19
+ total_duration: number;
20
+ speakers: number;
21
+ recent_recordings: Recording[];
22
+ }
23
+
24
+ export default function DatasetStats() {
25
+ const [stats, setStats] = useState<Stats>({
26
+ total_recordings: 0,
27
+ total_duration: 0,
28
+ speakers: 0,
29
+ recent_recordings: []
30
+ });
31
+
32
+ const fetchStats = () => {
33
+ fetch('/api/dataset-stats')
34
+ .then(res => res.json())
35
+ .then(data => setStats(data))
36
+ .catch(err => console.error('Error fetching stats:', err));
37
+ };
38
+
39
+ useEffect(() => {
40
+ fetchStats();
41
+ const interval = setInterval(fetchStats, 5000); // Refresh every 5s
42
+ return () => clearInterval(interval);
43
+ }, []);
44
+
45
+ return (
46
+ <div className="space-y-6">
47
+ <Card className="bg-gradient-to-br from-primary/5 to-purple-500/5 border-primary/10">
48
+ <CardHeader>
49
+ <CardTitle className="text-lg flex items-center gap-2">
50
+ <BarChart3 className="w-4 h-4" />
51
+ Session Stats
52
+ </CardTitle>
53
+ </CardHeader>
54
+ <CardContent className="space-y-3">
55
+ <div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
56
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
57
+ <Mic className="w-4 h-4" />
58
+ <span>Recordings</span>
59
+ </div>
60
+ <span className="font-mono font-bold">{stats.total_recordings}</span>
61
+ </div>
62
+
63
+ <div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
64
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
65
+ <Clock className="w-4 h-4" />
66
+ <span>Duration</span>
67
+ </div>
68
+ <span className="font-mono font-bold">{(stats.total_duration / 60).toFixed(1)}m</span>
69
+ </div>
70
+
71
+ <div className="flex items-center justify-between p-3 bg-background/50 rounded-lg border border-border/50">
72
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
73
+ <Users className="w-4 h-4" />
74
+ <span>Speakers</span>
75
+ </div>
76
+ <span className="font-mono font-bold">{stats.speakers}</span>
77
+ </div>
78
+ </CardContent>
79
+ </Card>
80
+
81
+ <Card className="max-h-[400px] flex flex-col">
82
+ <CardHeader>
83
+ <CardTitle className="text-lg flex items-center gap-2">
84
+ <History className="w-4 h-4" />
85
+ Recent History
86
+ </CardTitle>
87
+ </CardHeader>
88
+ <CardContent className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
89
+ {stats.recent_recordings.length === 0 ? (
90
+ <p className="text-sm text-muted-foreground text-center py-4">No recordings yet</p>
91
+ ) : (
92
+ stats.recent_recordings.map((rec, i) => (
93
+ <div key={i} className="p-3 rounded-lg bg-secondary/20 border border-border/50 hover:bg-secondary/40 transition-colors group">
94
+ <div className="flex justify-between items-start mb-1">
95
+ <span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]" title={rec.filename}>
96
+ {rec.filename}
97
+ </span>
98
+ <Badge variant="outline" className="text-[10px] h-5 px-1.5">
99
+ {rec.emotion}
100
+ </Badge>
101
+ </div>
102
+ <p className="text-sm line-clamp-2 mb-2 text-foreground/90">{rec.text}</p>
103
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
104
+ <div className="flex items-center gap-1">
105
+ <Clock className="w-3 h-3" />
106
+ {rec.duration.toFixed(1)}s
107
+ </div>
108
+ <div className="flex gap-0.5">
109
+ {[...Array(rec.rating)].map((_, i) => (
110
+ <span key={i} className="text-yellow-500">★</span>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ </div>
115
+ ))
116
+ )}
117
+ </CardContent>
118
+ </Card>
119
+ </div>
120
+ );
121
+ }
src/components/FontSelector.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+ import { Type, Upload, Check, Star } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Badge } from '@/components/ui/badge';
8
+ import { getSuggestedFont } from '@/lib/language';
9
+
10
+ interface Font {
11
+ name: string;
12
+ family: string;
13
+ css: string;
14
+ url?: string;
15
+ }
16
+
17
+ interface FontSelectorProps {
18
+ currentFont: string;
19
+ onFontChange: (font: string) => void;
20
+ detectedLang?: string;
21
+ }
22
+
23
+ export default function FontSelector({ currentFont, onFontChange, detectedLang }: FontSelectorProps) {
24
+ const [fonts, setFonts] = useState<Font[]>([]);
25
+ const [loading, setLoading] = useState(true);
26
+ const [suggestedFont, setSuggestedFont] = useState<string | null>(null);
27
+ const fileInputRef = useRef<HTMLInputElement>(null);
28
+
29
+ const fetchFonts = async () => {
30
+ try {
31
+ const res = await fetch('/api/fonts');
32
+ const data = await res.json();
33
+ if (data.fonts) {
34
+ setFonts(data.fonts);
35
+
36
+ // Inject custom fonts styles
37
+ data.fonts.forEach((font: Font) => {
38
+ if (font.url) {
39
+ const style = document.createElement('style');
40
+ style.textContent = `
41
+ @font-face {
42
+ font-family: '${font.family}';
43
+ src: url('${font.url}') format('truetype');
44
+ }
45
+ `;
46
+ document.head.appendChild(style);
47
+ }
48
+ });
49
+ }
50
+ } catch (error) {
51
+ console.error('Failed to fetch fonts', error);
52
+ toast.error('Failed to load fonts');
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ };
57
+
58
+ useEffect(() => {
59
+ fetchFonts();
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ if (detectedLang) {
64
+ const suggested = getSuggestedFont(detectedLang);
65
+ setSuggestedFont(suggested);
66
+ }
67
+ }, [detectedLang]);
68
+
69
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
70
+ const file = e.target.files?.[0];
71
+ if (!file) return;
72
+
73
+ const formData = new FormData();
74
+ formData.append('font', file);
75
+
76
+ const promise = fetch('/api/upload-font', {
77
+ method: 'POST',
78
+ body: formData,
79
+ }).then(async (res) => {
80
+ if (!res.ok) throw new Error('Upload failed');
81
+ await fetchFonts();
82
+ return 'Font uploaded successfully';
83
+ });
84
+
85
+ toast.promise(promise, {
86
+ loading: 'Uploading font...',
87
+ success: (data) => data,
88
+ error: 'Failed to upload font',
89
+ });
90
+ };
91
+
92
+ return (
93
+ <Card>
94
+ <CardHeader>
95
+ <CardTitle className="text-lg flex items-center gap-2">
96
+ <Type className="w-4 h-4" />
97
+ Typography
98
+ </CardTitle>
99
+ </CardHeader>
100
+ <CardContent className="space-y-4">
101
+ <div className="space-y-2">
102
+ <label className="label">Display Font</label>
103
+ <div className="space-y-2 max-h-[200px] overflow-y-auto pr-2 custom-scrollbar">
104
+ {fonts.map((font) => (
105
+ <div
106
+ key={font.family}
107
+ className={`
108
+ p-3 rounded-lg border cursor-pointer transition-all flex items-center justify-between group
109
+ ${currentFont === font.family
110
+ ? 'bg-primary/10 border-primary shadow-sm'
111
+ : 'bg-background hover:bg-secondary/50 border-border/50 hover:border-border'
112
+ }
113
+ `}
114
+ onClick={() => onFontChange(font.family)}
115
+ >
116
+ <div className="flex flex-col gap-1">
117
+ <span className="text-sm font-medium" style={{ fontFamily: font.family }}>
118
+ {font.name}
119
+ </span>
120
+ {suggestedFont === font.name && (
121
+ <Badge variant="secondary" className="text-[10px] w-fit px-1.5 h-4 gap-1">
122
+ <Star className="w-2 h-2 fill-current" /> Recommended
123
+ </Badge>
124
+ )}
125
+ </div>
126
+ {currentFont === font.family && (
127
+ <Check className="w-4 h-4 text-primary" />
128
+ )}
129
+ </div>
130
+ ))}
131
+ </div>
132
+ <p className="text-xs text-muted-foreground mt-2">
133
+ Select a font optimized for the target language.
134
+ </p>
135
+ </div>
136
+
137
+ <div className="relative">
138
+ <div className="absolute inset-0 flex items-center">
139
+ <span className="w-full border-t border-border" />
140
+ </div>
141
+ <div className="relative flex justify-center text-xs uppercase">
142
+ <span className="bg-card px-2 text-muted-foreground">Custom Font</span>
143
+ </div>
144
+ </div>
145
+
146
+ <button
147
+ className="btn btn-secondary w-full"
148
+ onClick={() => fileInputRef.current?.click()}
149
+ >
150
+ <Upload className="w-4 h-4 mr-2" />
151
+ Upload .ttf File
152
+ </button>
153
+ <input
154
+ type="file"
155
+ accept=".ttf"
156
+ ref={fileInputRef}
157
+ className="hidden"
158
+ onChange={handleFileUpload}
159
+ />
160
+ </CardContent>
161
+ </Card>
162
+ );
163
+ }
src/components/HelpModal.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { X, Keyboard, Mic, Save, Search, SkipForward, Bookmark, HelpCircle } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7
+
8
+ interface HelpModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ }
12
+
13
+ export default function HelpModal({ isOpen, onClose }: HelpModalProps) {
14
+ return (
15
+ <AnimatePresence>
16
+ {isOpen && (
17
+ <>
18
+ <motion.div
19
+ initial={{ opacity: 0 }}
20
+ animate={{ opacity: 1 }}
21
+ exit={{ opacity: 0 }}
22
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
23
+ onClick={onClose}
24
+ />
25
+ <motion.div
26
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
27
+ animate={{ opacity: 1, scale: 1, y: 0 }}
28
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
29
+ className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl z-50 max-h-[85vh] overflow-y-auto"
30
+ >
31
+ <Card>
32
+ <CardHeader>
33
+ <CardTitle className="flex items-center justify-between">
34
+ <div className="flex items-center gap-2">
35
+ <HelpCircle className="w-5 h-5 text-primary" />
36
+ <span>Help & Documentation</span>
37
+ </div>
38
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground">
39
+ <X className="w-5 h-5" />
40
+ </button>
41
+ </CardTitle>
42
+ </CardHeader>
43
+ <CardContent className="space-y-6">
44
+ {/* Quick Start */}
45
+ <section className="space-y-3">
46
+ <h3 className="text-lg font-semibold flex items-center gap-2">
47
+ 🚀 Quick Start
48
+ </h3>
49
+ <ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground ml-2">
50
+ <li>Enter a <strong>Speaker ID</strong> and <strong>Dataset Name</strong>.</li>
51
+ <li>Paste your text sentences into the text area and click <strong>Load Sentences</strong>.</li>
52
+ <li>Press <strong>Spacebar</strong> or click the microphone to start recording.</li>
53
+ <li>Rate the recording and select an emotion (optional).</li>
54
+ <li>Press <strong>Ctrl + S</strong> or click "Save & Next" to save and move to the next sentence.</li>
55
+ <li>When finished, go to <strong>Settings</strong> to export your dataset.</li>
56
+ </ol>
57
+ </section>
58
+
59
+ <div className="grid md:grid-cols-2 gap-6">
60
+ {/* Features */}
61
+ <section className="space-y-3">
62
+ <h3 className="text-lg font-semibold flex items-center gap-2">
63
+ ✨ Features
64
+ </h3>
65
+ <ul className="space-y-2 text-sm">
66
+ <li className="flex items-start gap-2">
67
+ <Mic className="w-4 h-4 mt-0.5 text-primary" />
68
+ <span><strong>Emotion Tagging:</strong> Label recordings with emotions like Happy, Sad, or Whisper.</span>
69
+ </li>
70
+ <li className="flex items-start gap-2">
71
+ <Bookmark className="w-4 h-4 mt-0.5 text-primary" />
72
+ <span><strong>Bookmarks:</strong> Flag difficult sentences to review later.</span>
73
+ </li>
74
+ <li className="flex items-start gap-2">
75
+ <Search className="w-4 h-4 mt-0.5 text-primary" />
76
+ <span><strong>Search:</strong> Find specific sentences by keyword.</span>
77
+ </li>
78
+ <li className="flex items-start gap-2">
79
+ <SkipForward className="w-4 h-4 mt-0.5 text-primary" />
80
+ <span><strong>Skip:</strong> Skip irrelevant or problematic sentences.</span>
81
+ </li>
82
+ </ul>
83
+ </section>
84
+
85
+ {/* Keyboard Shortcuts */}
86
+ <section className="space-y-3">
87
+ <h3 className="text-lg font-semibold flex items-center gap-2">
88
+ ⌨️ Shortcuts
89
+ </h3>
90
+ <div className="space-y-2 text-sm">
91
+ <div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
92
+ <span>Start/Stop Recording</span>
93
+ <kbd className="bg-background border border-border px-1.5 rounded text-xs">Space</kbd>
94
+ </div>
95
+ <div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
96
+ <span>Save & Next</span>
97
+ <kbd className="bg-background border border-border px-1.5 rounded text-xs">Ctrl + S</kbd>
98
+ </div>
99
+ <div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
100
+ <span>Next Sentence</span>
101
+ <kbd className="bg-background border border-border px-1.5 rounded text-xs">→</kbd>
102
+ </div>
103
+ <div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
104
+ <span>Previous Sentence</span>
105
+ <kbd className="bg-background border border-border px-1.5 rounded text-xs">←</kbd>
106
+ </div>
107
+ <div className="flex justify-between items-center bg-secondary/30 p-2 rounded">
108
+ <span>Focus Search</span>
109
+ <kbd className="bg-background border border-border px-1.5 rounded text-xs">Ctrl + F</kbd>
110
+ </div>
111
+ </div>
112
+ </section>
113
+ </div>
114
+ </CardContent>
115
+ </Card>
116
+ </motion.div>
117
+ </>
118
+ )}
119
+ </AnimatePresence>
120
+ );
121
+ }
src/components/Providers.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Toaster } from 'sonner';
4
+
5
+ export function Providers({ children }: { children: React.ReactNode }) {
6
+ return (
7
+ <>
8
+ {children}
9
+ <Toaster position="top-center" richColors />
10
+ </>
11
+ );
12
+ }
src/components/SettingsModal.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { X, Download, Save, Trash2, Mic, Settings } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { toast } from 'sonner';
7
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8
+ import { Switch } from '@/components/ui/switch';
9
+ import { Slider } from '@/components/ui/slider';
10
+
11
+ interface SettingsModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ autoAdvance: boolean;
15
+ setAutoAdvance: (value: boolean) => void;
16
+ autoSave: boolean;
17
+ setAutoSave: (value: boolean) => void;
18
+ silenceThreshold: number;
19
+ setSilenceThreshold: (value: number) => void;
20
+ datasetName: string;
21
+ }
22
+
23
+ export default function SettingsModal({
24
+ isOpen,
25
+ onClose,
26
+ autoAdvance,
27
+ setAutoAdvance,
28
+ autoSave,
29
+ setAutoSave,
30
+ silenceThreshold,
31
+ setSilenceThreshold,
32
+ datasetName
33
+ }: SettingsModalProps) {
34
+ const handleExport = async () => {
35
+ try {
36
+ // Trigger download by navigating to the API endpoint
37
+ window.location.href = `/api/export-dataset?dataset_name=${datasetName}`;
38
+ toast.success('Export started');
39
+ } catch (error) {
40
+ console.error(error);
41
+ toast.error('Error exporting dataset');
42
+ }
43
+ };
44
+
45
+ return (
46
+ <AnimatePresence>
47
+ {isOpen && (
48
+ <>
49
+ <motion.div
50
+ initial={{ opacity: 0 }}
51
+ animate={{ opacity: 1 }}
52
+ exit={{ opacity: 0 }}
53
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
54
+ onClick={onClose}
55
+ />
56
+ <motion.div
57
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
58
+ animate={{ opacity: 1, scale: 1, y: 0 }}
59
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
60
+ className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md z-50"
61
+ >
62
+ <Card>
63
+ <CardHeader>
64
+ <CardTitle className="flex items-center justify-between">
65
+ <span>Settings</span>
66
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground">
67
+ <X className="w-5 h-5" />
68
+ </button>
69
+ </CardTitle>
70
+ </CardHeader>
71
+ <CardContent className="space-y-6">
72
+ <div className="space-y-4">
73
+ <div className="flex items-center justify-between">
74
+ <div className="space-y-0.5">
75
+ <label className="text-sm font-medium">Auto-advance</label>
76
+ <p className="text-xs text-muted-foreground">
77
+ Go to next sentence after saving
78
+ </p>
79
+ </div>
80
+ <Switch checked={autoAdvance} onCheckedChange={setAutoAdvance} />
81
+ </div>
82
+
83
+ <div className="flex items-center justify-between">
84
+ <div className="space-y-0.5">
85
+ <label className="text-sm font-medium">Auto-save</label>
86
+ <p className="text-xs text-muted-foreground">
87
+ Save automatically when recording stops
88
+ </p>
89
+ </div>
90
+ <Switch checked={autoSave} onCheckedChange={setAutoSave} />
91
+ </div>
92
+
93
+ <div className="space-y-2">
94
+ <div className="flex justify-between">
95
+ <label className="text-sm font-medium">Silence Threshold</label>
96
+ <span className="text-xs text-muted-foreground">{silenceThreshold}%</span>
97
+ </div>
98
+ <Slider
99
+ value={[silenceThreshold]}
100
+ onValueChange={(vals) => setSilenceThreshold(vals[0])}
101
+ max={100}
102
+ step={1}
103
+ />
104
+ </div>
105
+
106
+ <div className="pt-4 border-t border-border">
107
+ <h4 className="text-sm font-medium mb-3">Data Management</h4>
108
+ <button
109
+ onClick={handleExport}
110
+ className="btn btn-secondary w-full flex items-center justify-center gap-2"
111
+ >
112
+ <Download className="w-4 h-4" />
113
+ Export Dataset (ZIP)
114
+ </button>
115
+ </div>
116
+ </div>
117
+ </CardContent>
118
+ </Card>
119
+ </motion.div>
120
+ </>
121
+ )}
122
+ </AnimatePresence>
123
+ );
124
+ }
src/components/TextInput.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { Upload, FileText } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7
+
8
+ interface TextInputProps {
9
+ onSentencesLoaded: (sentences: string[]) => void;
10
+ }
11
+
12
+ export default function TextInput({ onSentencesLoaded }: TextInputProps) {
13
+ const [text, setText] = useState('');
14
+ const fileInputRef = useRef<HTMLInputElement>(null);
15
+
16
+ const processText = (inputText: string) => {
17
+ if (!inputText.trim()) return;
18
+
19
+ // Simple sentence splitting (can be improved or use API)
20
+ // Split by . ! ? followed by space or newline
21
+ const sentences = inputText
22
+ .replace(/([.!?])\s+/g, '$1|')
23
+ .split('|')
24
+ .map(s => s.trim())
25
+ .filter(s => s.length > 0);
26
+
27
+ if (sentences.length > 0) {
28
+ onSentencesLoaded(sentences);
29
+ toast.success(`Loaded ${sentences.length} sentences`);
30
+ setText('');
31
+ } else {
32
+ toast.error('No valid sentences found');
33
+ }
34
+ };
35
+
36
+ const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
37
+ setText(e.target.value);
38
+ };
39
+
40
+ const handlePaste = () => {
41
+ processText(text);
42
+ };
43
+
44
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ const file = e.target.files?.[0];
46
+ if (!file) return;
47
+
48
+ const reader = new FileReader();
49
+ reader.onload = (event) => {
50
+ const content = event.target?.result as string;
51
+ setText(content);
52
+ processText(content);
53
+ toast.success(`File loaded: ${file.name}`);
54
+ };
55
+ reader.onerror = () => toast.error('Failed to read file');
56
+ reader.readAsText(file);
57
+ };
58
+
59
+ return (
60
+ <Card>
61
+ <CardHeader>
62
+ <CardTitle className="text-lg flex items-center gap-2">
63
+ <FileText className="w-4 h-4" />
64
+ Input Data
65
+ </CardTitle>
66
+ </CardHeader>
67
+ <CardContent className="space-y-4">
68
+ <div
69
+ className="border-2 border-dashed border-border rounded-xl p-6 text-center hover:bg-secondary/50 transition-colors cursor-pointer relative group"
70
+ onClick={() => fileInputRef.current?.click()}
71
+ >
72
+ <input
73
+ type="file"
74
+ accept=".txt"
75
+ ref={fileInputRef}
76
+ className="hidden"
77
+ onChange={handleFileUpload}
78
+ />
79
+ <Upload className="w-8 h-8 mx-auto mb-2 text-muted-foreground group-hover:text-primary transition-colors" />
80
+ <p className="text-sm font-medium">Drop text file or click to upload</p>
81
+ <p className="text-xs text-muted-foreground mt-1">.txt files supported</p>
82
+ </div>
83
+
84
+ <div className="relative">
85
+ <div className="absolute inset-0 flex items-center">
86
+ <span className="w-full border-t border-border" />
87
+ </div>
88
+ <div className="relative flex justify-center text-xs uppercase">
89
+ <span className="bg-card px-2 text-muted-foreground">Or paste text</span>
90
+ </div>
91
+ </div>
92
+
93
+ <div className="space-y-2">
94
+ <textarea
95
+ className="input min-h-[100px] resize-none"
96
+ placeholder="Paste your sentences here (one per line)..."
97
+ value={text}
98
+ onChange={handleTextChange}
99
+ />
100
+ <button
101
+ onClick={handlePaste}
102
+ disabled={!text.trim()}
103
+ className="btn btn-secondary w-full"
104
+ >
105
+ Load Sentences
106
+ </button>
107
+ </div>
108
+ </CardContent>
109
+ </Card>
110
+ );
111
+ }
src/components/ui/badge.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default:
11
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
12
+ secondary:
13
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
+ destructive:
15
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
16
+ outline: "text-foreground",
17
+ success:
18
+ "border-transparent bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25",
19
+ warning:
20
+ "border-transparent bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-500/25",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ variant: "default",
25
+ },
26
+ }
27
+ )
28
+
29
+ export interface BadgeProps
30
+ extends React.HTMLAttributes<HTMLDivElement>,
31
+ VariantProps<typeof badgeVariants> { }
32
+
33
+ function Badge({ className, variant, ...props }: BadgeProps) {
34
+ return (
35
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
36
+ )
37
+ }
38
+
39
+ export { Badge, badgeVariants }
src/components/ui/card.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Card = React.forwardRef<
5
+ HTMLDivElement,
6
+ React.HTMLAttributes<HTMLDivElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <div
9
+ ref={ref}
10
+ className={cn(
11
+ "rounded-xl border bg-card text-card-foreground shadow-sm glass-card",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ ))
17
+ Card.displayName = "Card"
18
+
19
+ const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div
24
+ ref={ref}
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ ))
29
+ CardHeader.displayName = "CardHeader"
30
+
31
+ const CardTitle = React.forwardRef<
32
+ HTMLParagraphElement,
33
+ React.HTMLAttributes<HTMLHeadingElement>
34
+ >(({ className, ...props }, ref) => (
35
+ <h3
36
+ ref={ref}
37
+ className={cn(
38
+ "text-2xl font-semibold leading-none tracking-tight",
39
+ className
40
+ )}
41
+ {...props}
42
+ />
43
+ ))
44
+ CardTitle.displayName = "CardTitle"
45
+
46
+ const CardDescription = React.forwardRef<
47
+ HTMLParagraphElement,
48
+ React.HTMLAttributes<HTMLParagraphElement>
49
+ >(({ className, ...props }, ref) => (
50
+ <p
51
+ ref={ref}
52
+ className={cn("text-sm text-muted-foreground", className)}
53
+ {...props}
54
+ />
55
+ ))
56
+ CardDescription.displayName = "CardDescription"
57
+
58
+ const CardContent = React.forwardRef<
59
+ HTMLDivElement,
60
+ React.HTMLAttributes<HTMLDivElement>
61
+ >(({ className, ...props }, ref) => (
62
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
63
+ ))
64
+ CardContent.displayName = "CardContent"
65
+
66
+ const CardFooter = React.forwardRef<
67
+ HTMLDivElement,
68
+ React.HTMLAttributes<HTMLDivElement>
69
+ >(({ className, ...props }, ref) => (
70
+ <div
71
+ ref={ref}
72
+ className={cn("flex items-center p-6 pt-0", className)}
73
+ {...props}
74
+ />
75
+ ))
76
+ CardFooter.displayName = "CardFooter"
77
+
78
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
src/components/ui/slider.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ interface SliderProps {
7
+ value: number[];
8
+ onValueChange: (value: number[]) => void;
9
+ max?: number;
10
+ step?: number;
11
+ className?: string;
12
+ }
13
+
14
+ export function Slider({ value, onValueChange, max = 100, step = 1, className }: SliderProps) {
15
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
16
+ onValueChange([parseFloat(e.target.value)]);
17
+ };
18
+
19
+ return (
20
+ <div className={cn("relative flex w-full touch-none select-none items-center", className)}>
21
+ <input
22
+ type="range"
23
+ min={0}
24
+ max={max}
25
+ step={step}
26
+ value={value[0]}
27
+ onChange={handleChange}
28
+ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary/50 accent-primary"
29
+ />
30
+ </div>
31
+ );
32
+ }
src/components/ui/switch.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ interface SwitchProps {
7
+ checked: boolean;
8
+ onCheckedChange: (checked: boolean) => void;
9
+ className?: string;
10
+ }
11
+
12
+ export function Switch({ checked, onCheckedChange, className }: SwitchProps) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ role="switch"
17
+ aria-checked={checked}
18
+ onClick={() => onCheckedChange(!checked)}
19
+ className={cn(
20
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
21
+ checked ? "bg-primary" : "bg-input/50 bg-gray-200 dark:bg-gray-700",
22
+ className
23
+ )}
24
+ >
25
+ <span
26
+ className={cn(
27
+ "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
28
+ checked ? "translate-x-5 bg-white" : "translate-x-0 bg-white"
29
+ )}
30
+ />
31
+ </button>
32
+ );
33
+ }
src/hooks/useKeyboardShortcuts.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from 'react';
2
+
3
+ interface Shortcuts {
4
+ [key: string]: () => void;
5
+ }
6
+
7
+ export function useKeyboardShortcuts(shortcuts: Shortcuts) {
8
+ useEffect(() => {
9
+ const handleKeyDown = (event: KeyboardEvent) => {
10
+ // Ignore if typing in an input
11
+ if (
12
+ document.activeElement?.tagName === 'INPUT' ||
13
+ document.activeElement?.tagName === 'TEXTAREA' ||
14
+ document.activeElement?.tagName === 'SELECT'
15
+ ) {
16
+ return;
17
+ }
18
+
19
+ const key = event.key.toLowerCase();
20
+ const ctrl = event.ctrlKey || event.metaKey;
21
+ const shift = event.shiftKey;
22
+
23
+ // Construct key string like "ctrl+s" or "shift+arrowright"
24
+ let keyString = key;
25
+ if (shift) keyString = `shift+${keyString}`;
26
+ if (ctrl) keyString = `ctrl+${keyString}`;
27
+
28
+ if (shortcuts[keyString]) {
29
+ event.preventDefault();
30
+ shortcuts[keyString]();
31
+ } else if (shortcuts[key]) {
32
+ // Fallback for single keys
33
+ event.preventDefault();
34
+ shortcuts[key]();
35
+ }
36
+ };
37
+
38
+ window.addEventListener('keydown', handleKeyDown);
39
+ return () => window.removeEventListener('keydown', handleKeyDown);
40
+ }, [shortcuts]);
41
+ }
src/hooks/useLocalStorage.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export function useLocalStorage<T>(key: string, initialValue: T) {
4
+ const [storedValue, setStoredValue] = useState<T>(() => {
5
+ if (typeof window === 'undefined') {
6
+ return initialValue;
7
+ }
8
+ try {
9
+ const item = window.localStorage.getItem(key);
10
+ return item ? JSON.parse(item) : initialValue;
11
+ } catch (error) {
12
+ console.log(error);
13
+ return initialValue;
14
+ }
15
+ });
16
+
17
+ const setValue = (value: T | ((val: T) => T)) => {
18
+ try {
19
+ const valueToStore =
20
+ value instanceof Function ? value(storedValue) : value;
21
+ setStoredValue(valueToStore);
22
+ if (typeof window !== 'undefined') {
23
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
24
+ }
25
+ } catch (error) {
26
+ console.log(error);
27
+ }
28
+ };
29
+
30
+ return [storedValue, setValue] as const;
31
+ }
src/instrumentation.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Next.js Instrumentation
3
+ * This file is used to initialize code that runs when the server starts.
4
+ * Used here to start the cleanup scheduler on HF Spaces.
5
+ */
6
+
7
+ export async function register() {
8
+ // Only run on server-side
9
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
10
+ try {
11
+ // Dynamic import to avoid client-side bundling issues
12
+ const { initializeDataDirs } = await import('./lib/dataPath');
13
+ const { startCleanupScheduler } = await import('./lib/cleanup');
14
+
15
+ // Initialize data directories
16
+ await initializeDataDirs();
17
+
18
+ // Start the cleanup scheduler (only runs on HF Spaces)
19
+ startCleanupScheduler();
20
+
21
+ console.log('[Instrumentation] Server initialization complete');
22
+ } catch (error) {
23
+ console.error('[Instrumentation] Error during initialization:', error);
24
+ }
25
+ }
26
+ }
src/lib/cleanup.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { getDataDir, getAudioPath, getTranscriptionsPath, getMetadataPath, getFontsPath } from './dataPath';
4
+
5
+ // Cleanup interval in milliseconds (6 hours)
6
+ const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
7
+
8
+ // Maximum age for files in milliseconds (24 hours)
9
+ const MAX_FILE_AGE_MS = 24 * 60 * 60 * 1000;
10
+
11
+ // Flag to track if cleanup scheduler is running
12
+ let cleanupSchedulerRunning = false;
13
+
14
+ /**
15
+ * Delete files older than MAX_FILE_AGE_MS from a directory
16
+ * Recursively processes subdirectories
17
+ */
18
+ async function cleanupDirectory(dirPath: string, dryRun: boolean = false): Promise<number> {
19
+ let deletedCount = 0;
20
+ const now = Date.now();
21
+
22
+ try {
23
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
24
+
25
+ for (const entry of entries) {
26
+ const fullPath = path.join(dirPath, entry.name);
27
+
28
+ if (entry.isDirectory()) {
29
+ // Recursively clean subdirectories
30
+ deletedCount += await cleanupDirectory(fullPath, dryRun);
31
+
32
+ // Try to remove empty directories
33
+ try {
34
+ const contents = await fs.readdir(fullPath);
35
+ if (contents.length === 0) {
36
+ if (!dryRun) {
37
+ await fs.rmdir(fullPath);
38
+ }
39
+ console.log(`[Cleanup] Removed empty directory: ${fullPath}`);
40
+ }
41
+ } catch {
42
+ // Directory might not be empty or already removed
43
+ }
44
+ } else if (entry.isFile()) {
45
+ try {
46
+ const stats = await fs.stat(fullPath);
47
+ const fileAge = now - stats.mtimeMs;
48
+
49
+ if (fileAge > MAX_FILE_AGE_MS) {
50
+ if (!dryRun) {
51
+ await fs.unlink(fullPath);
52
+ }
53
+ deletedCount++;
54
+ console.log(`[Cleanup] Deleted old file: ${entry.name} (age: ${Math.round(fileAge / 3600000)}h)`);
55
+ }
56
+ } catch (error) {
57
+ console.error(`[Cleanup] Error processing file ${fullPath}:`, error);
58
+ }
59
+ }
60
+ }
61
+ } catch (error) {
62
+ // Directory might not exist yet
63
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
64
+ console.error(`[Cleanup] Error reading directory ${dirPath}:`, error);
65
+ }
66
+ }
67
+
68
+ return deletedCount;
69
+ }
70
+
71
+ /**
72
+ * Run cleanup on all data directories
73
+ */
74
+ export async function runCleanup(dryRun: boolean = false): Promise<{ totalDeleted: number; timestamp: string }> {
75
+ const startTime = Date.now();
76
+ console.log(`[Cleanup] Starting cleanup at ${new Date().toISOString()}...`);
77
+
78
+ let totalDeleted = 0;
79
+
80
+ // Directories to clean
81
+ const directoriesToClean = [
82
+ getAudioPath(),
83
+ getTranscriptionsPath(),
84
+ ];
85
+
86
+ for (const dir of directoriesToClean) {
87
+ try {
88
+ const deleted = await cleanupDirectory(dir, dryRun);
89
+ totalDeleted += deleted;
90
+ } catch (error) {
91
+ console.error(`[Cleanup] Error cleaning ${dir}:`, error);
92
+ }
93
+ }
94
+
95
+ // Clean up old metadata entries
96
+ try {
97
+ await cleanupMetadata();
98
+ } catch (error) {
99
+ console.error('[Cleanup] Error cleaning metadata:', error);
100
+ }
101
+
102
+ const duration = Date.now() - startTime;
103
+ console.log(`[Cleanup] Completed in ${duration}ms. Deleted ${totalDeleted} files.`);
104
+
105
+ return {
106
+ totalDeleted,
107
+ timestamp: new Date().toISOString()
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Clean up old entries from metadata file
113
+ */
114
+ async function cleanupMetadata(): Promise<void> {
115
+ const metadataPath = path.join(getMetadataPath(), 'dataset_info.json');
116
+
117
+ try {
118
+ const content = await fs.readFile(metadataPath, 'utf-8');
119
+ const metadata = JSON.parse(content);
120
+
121
+ // Update last cleanup timestamp
122
+ metadata.last_cleanup = new Date().toISOString();
123
+
124
+ // Clear old recent_recordings if they exist
125
+ if (metadata.recent_recordings && Array.isArray(metadata.recent_recordings)) {
126
+ const now = Date.now();
127
+ metadata.recent_recordings = metadata.recent_recordings.filter((rec: { timestamp?: string }) => {
128
+ if (!rec.timestamp) return false;
129
+ const recTime = new Date(rec.timestamp).getTime();
130
+ return (now - recTime) < MAX_FILE_AGE_MS;
131
+ });
132
+ }
133
+
134
+ await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
135
+ } catch (error) {
136
+ // Metadata file might not exist
137
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
138
+ throw error;
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Start the cleanup scheduler
145
+ * Runs cleanup on startup and then periodically
146
+ */
147
+ export function startCleanupScheduler(): void {
148
+ // Only run on HF Spaces (when /data exists or SPACE_ID is set)
149
+ const isHFSpaces = !!process.env.SPACE_ID || getDataDir() === '/data';
150
+
151
+ if (!isHFSpaces) {
152
+ console.log('[Cleanup] Not running on HF Spaces, skipping cleanup scheduler');
153
+ return;
154
+ }
155
+
156
+ if (cleanupSchedulerRunning) {
157
+ console.log('[Cleanup] Scheduler already running');
158
+ return;
159
+ }
160
+
161
+ cleanupSchedulerRunning = true;
162
+ console.log('[Cleanup] Starting cleanup scheduler (24h max age, 6h interval)');
163
+
164
+ // Run cleanup on startup (with a small delay to let the app initialize)
165
+ setTimeout(async () => {
166
+ try {
167
+ await runCleanup();
168
+ } catch (error) {
169
+ console.error('[Cleanup] Error during startup cleanup:', error);
170
+ }
171
+ }, 5000);
172
+
173
+ // Schedule periodic cleanup
174
+ setInterval(async () => {
175
+ try {
176
+ await runCleanup();
177
+ } catch (error) {
178
+ console.error('[Cleanup] Error during scheduled cleanup:', error);
179
+ }
180
+ }, CLEANUP_INTERVAL_MS);
181
+ }
182
+
183
+ /**
184
+ * Get cleanup status information
185
+ */
186
+ export function getCleanupConfig() {
187
+ return {
188
+ maxFileAgeMs: MAX_FILE_AGE_MS,
189
+ maxFileAgeHours: MAX_FILE_AGE_MS / 3600000,
190
+ cleanupIntervalMs: CLEANUP_INTERVAL_MS,
191
+ cleanupIntervalHours: CLEANUP_INTERVAL_MS / 3600000,
192
+ isSchedulerRunning: cleanupSchedulerRunning,
193
+ dataDir: getDataDir(),
194
+ };
195
+ }
src/lib/dataPath.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Determines the base data directory path based on environment.
6
+ * On Hugging Face Spaces with persistent storage, uses /data
7
+ * Otherwise, uses the local dataset folder.
8
+ */
9
+ export function getDataDir(): string {
10
+ // Check for explicit environment variable first
11
+ if (process.env.DATA_DIR) {
12
+ return process.env.DATA_DIR;
13
+ }
14
+
15
+ // On HF Spaces with persistent storage, /data is available
16
+ // We check this at runtime since /data only exists at runtime, not build time
17
+ if (process.env.SPACE_ID || isHuggingFaceSpaces()) {
18
+ return '/data';
19
+ }
20
+
21
+ // Default to local dataset directory
22
+ return path.join(process.cwd(), 'dataset');
23
+ }
24
+
25
+ /**
26
+ * Check if running on Hugging Face Spaces
27
+ */
28
+ function isHuggingFaceSpaces(): boolean {
29
+ // HF Spaces sets SPACE_ID environment variable
30
+ return !!process.env.SPACE_ID;
31
+ }
32
+
33
+ /**
34
+ * Get the full path to a subdirectory within the data directory
35
+ */
36
+ export function getDataPath(...subPaths: string[]): string {
37
+ return path.join(getDataDir(), ...subPaths);
38
+ }
39
+
40
+ /**
41
+ * Get audio directory path for a speaker
42
+ */
43
+ export function getAudioPath(speakerId?: string): string {
44
+ if (speakerId) {
45
+ return getDataPath('audio', speakerId);
46
+ }
47
+ return getDataPath('audio');
48
+ }
49
+
50
+ /**
51
+ * Get transcriptions directory path for a speaker
52
+ */
53
+ export function getTranscriptionsPath(speakerId?: string): string {
54
+ if (speakerId) {
55
+ return getDataPath('transcriptions', speakerId);
56
+ }
57
+ return getDataPath('transcriptions');
58
+ }
59
+
60
+ /**
61
+ * Get metadata directory path
62
+ */
63
+ export function getMetadataPath(): string {
64
+ return getDataPath('metadata');
65
+ }
66
+
67
+ /**
68
+ * Get fonts directory path
69
+ */
70
+ export function getFontsPath(): string {
71
+ return getDataPath('fonts');
72
+ }
73
+
74
+ /**
75
+ * Safely create a directory, handling errors gracefully
76
+ */
77
+ export async function ensureDir(dirPath: string): Promise<void> {
78
+ try {
79
+ await fs.mkdir(dirPath, { recursive: true });
80
+ } catch (error: unknown) {
81
+ // Ignore EEXIST errors (directory already exists)
82
+ if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code !== 'EEXIST') {
83
+ console.error(`Failed to create directory ${dirPath}:`, error);
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Sanitize a string for use in file paths
91
+ * Prevents path traversal attacks and invalid characters
92
+ */
93
+ export function sanitizePath(input: string, maxLength: number = 50): string {
94
+ if (!input || typeof input !== 'string') {
95
+ return 'unknown';
96
+ }
97
+
98
+ // Remove any path traversal attempts and invalid characters
99
+ return input
100
+ .replace(/\.\./g, '') // Prevent path traversal
101
+ .replace(/[\/\\:*?"<>|]/g, '_') // Remove invalid path characters
102
+ .replace(/[^a-zA-Z0-9_-]/g, '_') // Keep only safe characters
103
+ .substring(0, maxLength)
104
+ .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
105
+ || 'unknown';
106
+ }
107
+
108
+ /**
109
+ * Initialize the data directory structure
110
+ * Creates all necessary subdirectories
111
+ */
112
+ export async function initializeDataDirs(): Promise<void> {
113
+ const dirs = [
114
+ getDataPath(),
115
+ getAudioPath(),
116
+ getTranscriptionsPath(),
117
+ getMetadataPath(),
118
+ getFontsPath(),
119
+ ];
120
+
121
+ for (const dir of dirs) {
122
+ await ensureDir(dir);
123
+ }
124
+
125
+ console.log(`[DataPath] Initialized data directories at: ${getDataDir()}`);
126
+ }
src/lib/language.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { franc } from 'franc';
2
+
3
+ export const RTL_LANGUAGES = ['arb', 'heb', 'urd', 'per', 'ara', 'fas', 'urd'];
4
+
5
+ export function detectLanguage(text: string): string {
6
+ // franc returns 'und' if undetermined
7
+ // We can set a minimum length threshold to avoid noise
8
+ if (!text || text.length < 5) return 'eng';
9
+ return franc(text);
10
+ }
11
+
12
+ export function isRTL(langCode: string): boolean {
13
+ return RTL_LANGUAGES.includes(langCode);
14
+ }
15
+
16
+ export function getSuggestedFont(langCode: string): string {
17
+ switch (langCode) {
18
+ case 'arb':
19
+ case 'ara':
20
+ return 'Amiri'; // Assuming we have this or similar
21
+ case 'jpn':
22
+ return 'Noto Sans JP';
23
+ case 'kor':
24
+ return 'Noto Sans KR';
25
+ case 'cmn':
26
+ return 'Noto Sans SC';
27
+ default:
28
+ return 'DM Sans';
29
+ }
30
+ }
src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
start_app.bat ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ echo Starting TTS Dataset Collector...
3
+ docker-compose up -d
4
+ echo.
5
+ echo Application started!
6
+ echo Opening browser...
7
+ timeout /t 5
8
+ start http://localhost:3000
9
+ pause
tailwind.config.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ darkMode: ["class"],
5
+ content: [
6
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9
+ ],
10
+ theme: {
11
+ extend: {
12
+ fontFamily: {
13
+ sans: ["var(--font-dm-sans)", "sans-serif"],
14
+ serif: ["var(--font-playfair)", "serif"],
15
+ mono: ["var(--font-jetbrains)", "monospace"],
16
+ },
17
+ colors: {
18
+ border: "hsl(var(--border))",
19
+ input: "hsl(var(--input))",
20
+ ring: "hsl(var(--ring))",
21
+ background: "hsl(var(--background))",
22
+ foreground: "hsl(var(--foreground))",
23
+ primary: {
24
+ DEFAULT: "hsl(var(--primary))",
25
+ foreground: "hsl(var(--primary-foreground))",
26
+ },
27
+ secondary: {
28
+ DEFAULT: "hsl(var(--secondary))",
29
+ foreground: "hsl(var(--secondary-foreground))",
30
+ },
31
+ destructive: {
32
+ DEFAULT: "hsl(var(--destructive))",
33
+ foreground: "hsl(var(--destructive-foreground))",
34
+ },
35
+ muted: {
36
+ DEFAULT: "hsl(var(--muted))",
37
+ foreground: "hsl(var(--muted-foreground))",
38
+ },
39
+ accent: {
40
+ DEFAULT: "hsl(var(--accent))",
41
+ foreground: "hsl(var(--accent-foreground))",
42
+ },
43
+ popover: {
44
+ DEFAULT: "hsl(var(--popover))",
45
+ foreground: "hsl(var(--popover-foreground))",
46
+ },
47
+ card: {
48
+ DEFAULT: "hsl(var(--card))",
49
+ foreground: "hsl(var(--card-foreground))",
50
+ },
51
+ },
52
+ borderRadius: {
53
+ lg: "var(--radius)",
54
+ md: "calc(var(--radius) - 2px)",
55
+ sm: "calc(var(--radius) - 4px)",
56
+ },
57
+ },
58
+ },
59
+ plugins: [],
60
+ };
61
+ export default config;
tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }