Adding files

This commit is contained in:
2025-04-01 15:26:07 +00:00
commit 31b51ea690
15 changed files with 8920 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
dist_electron
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Electron
certificate.pfx

158
README.md Normal file
View File

@ -0,0 +1,158 @@
# Podcast Recorder
A professional desktop application built with Electron and React for recording high-quality podcast audio with automatic backup functionality.
## Features
- 🎙️ High-quality audio recording (48kHz, stereo)
- 💾 Automatic backup system with separate save locations
- 📊 Real-time audio level monitoring
- ⚙️ Configurable settings
- 🔄 Automatic file format conversion (WebM to WAV)
- 🎚️ Professional audio processing:
- Noise suppression
- Echo cancellation
- Automatic gain control
## Technology Stack
- **Frontend**: React + TypeScript
- **Styling**: Tailwind CSS
- **Desktop Framework**: Electron
- **Audio Processing**: Web Audio API
- **File Format Conversion**: audiobuffer-to-wav
- **Settings Management**: electron-store
- **Build Tools**: Vite
## Getting Started
### Prerequisites
- Node.js (v16 or higher)
- npm (v7 or higher)
### Installation
1. Clone the repository:
```bash
git clone [repository-url]
cd podcast-recorder
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run electron:dev
```
### Building for Production
To create a production build:
```bash
npm run electron:build
```
## Project Structure
```
podcast-recorder/
├── electron/
│ ├── main.cjs # Main Electron process
│ └── preload.cjs # Preload script for IPC
├── src/
│ ├── App.tsx # Main React component
│ ├── audioUtils.ts # Audio processing utilities
│ ├── main.tsx # React entry point
│ └── index.css # Global styles
├── public/ # Static assets
└── package.json # Project configuration
```
## How It Works
### Recording Process
1. **Initialization**:
- User enters their email for session identification
- Application creates unique session directories for both final recordings and backups
2. **Audio Capture**:
- Uses the system's default audio input device
- Captures audio at 48kHz with stereo channels
- Applies real-time audio processing:
- Noise suppression
- Echo cancellation
- Automatic gain control
3. **Backup System**:
- Records are saved in 1-second chunks during recording
- Backup chunks are stored in a separate directory
- Provides data safety in case of crashes or power failures
4. **File Management**:
- Original recording is saved in WebM format
- Final output is converted to WAV format
- Files are named using email and timestamp for easy organization
### Directory Structure
- **Save Directory**: Contains completed, processed recordings
- **Backup Directory**: Stores individual audio chunks during recording
## Configuration
The application stores its settings using `electron-store`. Default settings include:
```javascript
{
saveDirectory: "<user-documents>/PodcastRecordings",
backupDirectory: "<user-documents>/PodcastBackups",
audioBitrate: 256000,
autoBackupInterval: 300000, // 5 minutes
fileFormat: "wav",
rodecasterEnabled: true
}
```
## Development
### IPC Communication
The application uses Electron's IPC (Inter-Process Communication) for communication between the main and renderer processes:
- `initRecordingSession`: Creates new recording session directories
- `saveAudioChunk`: Saves individual audio chunks during recording
- `finishRecordingSession`: Cleans up after recording completion
- `selectDirectory`: Handles directory selection dialog
- `getSettings`: Retrieves application settings
- `saveSettings`: Updates application settings
### Audio Processing Pipeline
1. **Capture**: Raw audio from input device
2. **Processing**: Apply noise suppression and echo cancellation
3. **Monitoring**: Real-time audio level visualization
4. **Chunking**: Split into 1-second segments
5. **Backup**: Save chunks to backup directory
6. **Conversion**: Convert final recording to WAV format
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
[Add your license here]
## Support
[Add support information here]

217
electron/main.cjs Normal file
View File

@ -0,0 +1,217 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const { join } = require('path');
const { writeFile, mkdir, access } = require('fs').promises;
const { constants } = require('fs');
const Store = require('electron-store');
const store = new Store();
// Initialize default settings if they don't exist
if (!store.get('settings')) {
store.set('settings', {
saveDirectory: join(app.getPath('documents'), 'PodcastRecordings'),
backupDirectory: join(app.getPath('documents'), 'PodcastBackups'),
audioBitrate: 256000,
autoBackupInterval: 300000, // 5 minutes in milliseconds
fileFormat: 'wav',
rodecasterEnabled: true
});
}
let mainWindow;
let currentRecordingDir = null;
let currentBackupDir = null;
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false
}
});
// In development, use Vite's dev server
if (!app.isPackaged) {
try {
await mainWindow.loadURL('http://127.0.0.1:5173');
mainWindow.webContents.openDevTools();
} catch (error) {
console.error('Failed to load dev server:', error);
process.exit(1);
}
} else {
// In production, load the built files
mainWindow.loadFile(join(__dirname, '../dist/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(async () => {
// Start Vite dev server in development
if (!app.isPackaged) {
const { spawn } = require('child_process');
const viteProcess = spawn('npm', ['run', 'dev'], {
shell: true,
stdio: 'inherit'
});
viteProcess.on('error', (err) => {
console.error('Failed to start Vite dev server:', err);
app.quit();
});
// Wait for dev server to be ready
await new Promise((resolve) => {
const checkServer = async () => {
try {
const response = await fetch('http://127.0.0.1:5173');
if (response.status === 200) {
resolve();
} else {
setTimeout(checkServer, 100);
}
} catch {
setTimeout(checkServer, 100);
}
};
checkServer();
});
}
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
async function validateDirectory(directory) {
try {
await access(directory, constants.W_OK);
return true;
} catch {
try {
await mkdir(directory, { recursive: true });
await access(directory, constants.W_OK);
return true;
} catch (error) {
console.error('Error validating directory:', error);
return false;
}
}
}
// Handle directory selection
ipcMain.handle('select-directory', async () => {
if (!mainWindow) {
throw new Error('Main window is not available');
}
try {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
title: 'Select Directory',
buttonLabel: 'Choose Directory'
});
if (result.canceled) {
return null;
}
if (result.filePaths.length > 0) {
const directory = result.filePaths[0];
const isValid = await validateDirectory(directory);
if (!isValid) {
throw new Error('Cannot write to selected directory');
}
return directory;
}
return null;
} catch (error) {
console.error('Error selecting directory:', error);
throw error;
}
});
// Handle recording session initialization
ipcMain.handle('init-recording-session', async (event, { email }) => {
const settings = store.get('settings');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const sessionDir = join(settings.saveDirectory, `${email}_${timestamp}`);
const backupDir = join(settings.backupDirectory, `${email}_${timestamp}_backup`);
try {
await mkdir(sessionDir, { recursive: true });
await mkdir(backupDir, { recursive: true });
currentRecordingDir = sessionDir;
currentBackupDir = backupDir;
return { success: true, directory: sessionDir, backupDirectory: backupDir };
} catch (error) {
console.error('Failed to create recording session directories:', error);
return { success: false, error: error.message };
}
});
// Handle audio chunk saves
ipcMain.handle('save-audio-chunk', async (event, { buffer, chunkIndex }) => {
if (!currentBackupDir) {
throw new Error('No active recording session');
}
try {
const chunkFileName = `chunk_${chunkIndex.toString().padStart(5, '0')}.webm`;
const savePath = join(currentBackupDir, chunkFileName);
await writeFile(savePath, Buffer.from(buffer));
return { success: true, path: savePath };
} catch (error) {
console.error('Failed to save audio chunk:', error);
return { success: false, error: error.message };
}
});
// Handle recording session cleanup
ipcMain.handle('finish-recording-session', async () => {
currentRecordingDir = null;
currentBackupDir = null;
return { success: true };
});
// Handle settings management
ipcMain.handle('get-settings', () => {
return store.get('settings');
});
ipcMain.handle('save-settings', async (event, settings) => {
try {
// Validate both directories before saving settings
const isSaveValid = await validateDirectory(settings.saveDirectory);
const isBackupValid = await validateDirectory(settings.backupDirectory);
if (!isSaveValid || !isBackupValid) {
throw new Error('Cannot write to one or both directories');
}
store.set('settings', settings);
return true;
} catch (error) {
console.error('Error saving settings:', error);
return false;
}
});

49
electron/main.js Normal file
View File

@ -0,0 +1,49 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import { join } from 'path';
import { writeFile } from 'fs/promises';
import Store from 'electron-store';
const store = new Store();
async function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
} else {
mainWindow.loadFile(join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle audio chunk saves
ipcMain.handle('save-audio-chunk', async (event, { buffer, fileName }) => {
try {
const savePath = join(app.getPath('documents'), 'PodcastRecordings', fileName);
await writeFile(savePath, Buffer.from(buffer));
return { success: true, path: savePath };
} catch (error) {
console.error('Failed to save audio chunk:', error);
return { success: false, error: error.message };
}
});

13
electron/preload.cjs Normal file
View File

@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
'electronAPI',
{
saveAudioChunk: (data) => ipcRenderer.invoke('save-audio-chunk', data),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
getSettings: () => ipcRenderer.invoke('get-settings'),
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
initRecordingSession: (data) => ipcRenderer.invoke('init-recording-session', data),
finishRecordingSession: () => ipcRenderer.invoke('finish-recording-session')
}
);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="is">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Podcast Recording Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7816
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "podcast-recorder",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"electron:dev": "vite dev & electron .",
"electron:build": "vite build && electron-builder"
},
"dependencies": {
"@heroicons/react": "^2.1.1",
"@tailwindcss/forms": "^0.5.7",
"audiobuffer-to-wav": "^1.0.0",
"date-fns": "^3.3.1",
"electron-store": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webmidi": "^3.1.8"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"electron": "^28.1.0",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.1"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

419
src/App.tsx Normal file
View File

@ -0,0 +1,419 @@
import React, { useState, useRef, useEffect } from 'react';
import { format } from 'date-fns';
import { Cog6ToothIcon, ArrowRightOnRectangleIcon } from '@heroicons/react/24/outline';
import { blobToAudioBuffer, concatenateAudioBuffers, createWavBlob } from './audioUtils';
interface Settings {
saveDirectory: string;
backupDirectory: string;
audioBitrate: number;
autoBackupInterval: number;
fileFormat: string;
rodecasterEnabled: boolean;
}
declare global {
interface Window {
electronAPI: {
saveAudioChunk: (data: { buffer: ArrayBuffer, chunkIndex: number }) => Promise<{ success: boolean, path: string }>;
initRecordingSession: (data: { email: string }) => Promise<{ success: boolean, directory: string, backupDirectory: string }>;
finishRecordingSession: () => Promise<{ success: boolean }>;
selectDirectory: () => Promise<string | null>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
}
}
}
function App() {
const [email, setEmail] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [hasSubmittedEmail, setHasSubmittedEmail] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioLevel, setAudioLevel] = useState(0);
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const audioContext = useRef<AudioContext | null>(null);
const analyzer = useRef<AnalyserNode | null>(null);
const animationFrame = useRef<number | null>(null);
const timerInterval = useRef<number | null>(null);
const chunks = useRef<Blob[]>([]);
const chunkIndex = useRef<number>(0);
useEffect(() => {
// Load settings when component mounts
const loadSettings = async () => {
try {
const savedSettings = await window.electronAPI.getSettings();
setSettings(savedSettings);
} catch (error) {
console.error('Error loading settings:', error);
}
};
loadSettings();
}, []);
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (email.includes('@')) {
setHasSubmittedEmail(true);
}
};
const handleLogout = () => {
if (isRecording) {
if (window.confirm('You are currently recording. Are you sure you want to stop and log out?')) {
stopRecording();
setHasSubmittedEmail(false);
setEmail('');
}
} else {
setHasSubmittedEmail(false);
setEmail('');
}
};
const handleDirectorySelect = async (type: 'save' | 'backup') => {
try {
const directory = await window.electronAPI.selectDirectory();
if (directory && settings) {
const updatedSettings = { ...settings };
if (type === 'save') {
updatedSettings.saveDirectory = directory;
} else {
updatedSettings.backupDirectory = directory;
}
const saved = await window.electronAPI.saveSettings(updatedSettings);
if (saved) {
setSettings(updatedSettings);
} else {
throw new Error('Failed to save settings');
}
}
} catch (error) {
console.error('Error selecting directory:', error);
alert('Failed to select directory. Please try again.');
}
};
const updateAudioLevel = () => {
if (analyzer.current) {
const dataArray = new Uint8Array(analyzer.current.frequencyBinCount);
analyzer.current.getByteFrequencyData(dataArray);
const average = dataArray.reduce((acc, val) => acc + val, 0) / dataArray.length;
const normalizedLevel = average / 255;
setAudioLevel(normalizedLevel);
animationFrame.current = requestAnimationFrame(updateAudioLevel);
}
};
const startRecording = async () => {
try {
// Initialize recording session
const sessionResult = await window.electronAPI.initRecordingSession({ email });
if (!sessionResult.success) {
throw new Error('Failed to initialize recording session');
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000,
channelCount: 2
}
});
audioContext.current = new AudioContext({ sampleRate: 48000 });
const source = audioContext.current.createMediaStreamSource(stream);
analyzer.current = audioContext.current.createAnalyser();
analyzer.current.fftSize = 256;
source.connect(analyzer.current);
updateAudioLevel();
mediaRecorder.current = new MediaRecorder(stream);
chunks.current = [];
chunkIndex.current = 0;
mediaRecorder.current.ondataavailable = async (e) => {
if (e.data.size > 0) {
chunks.current.push(e.data);
// Save chunk to filesystem
const buffer = await e.data.arrayBuffer();
const saveResult = await window.electronAPI.saveAudioChunk({
buffer,
chunkIndex: chunkIndex.current
});
if (!saveResult.success) {
console.error('Failed to save audio chunk:', saveResult.error);
}
chunkIndex.current++;
}
};
mediaRecorder.current.start(1000); // Save chunks every second
setIsRecording(true);
let seconds = 0;
timerInterval.current = window.setInterval(() => {
seconds++;
setRecordingTime(seconds);
}, 1000);
} catch (err) {
console.error('Error starting recording:', err);
alert('Error starting recording. Please check your audio device.');
}
};
const stopRecording = async () => {
if (!mediaRecorder.current || mediaRecorder.current.state !== 'recording') {
return;
}
try {
mediaRecorder.current.stop();
mediaRecorder.current.stream.getTracks().forEach(track => track.stop());
await new Promise(resolve => setTimeout(resolve, 200));
if (chunks.current.length > 0) {
const blob = new Blob(chunks.current, { type: 'audio/webm' });
const audioBuffer = await blobToAudioBuffer(blob, new AudioContext());
const wavBlob = await createWavBlob(audioBuffer);
const sanitizedEmail = email.replace(/[/\\?%*:|"<>]/g, '-');
const timestamp = format(new Date(), 'yyyy-MM-dd_HH-mm-ss');
const filename = `${sanitizedEmail}_${timestamp}.wav`;
const audioUrl = URL.createObjectURL(wavBlob);
const link = document.createElement('a');
link.href = audioUrl;
link.download = filename;
link.click();
URL.revokeObjectURL(audioUrl);
}
// Cleanup recording session
await window.electronAPI.finishRecordingSession();
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
if (audioContext.current) {
await audioContext.current.close();
}
if (timerInterval.current) {
clearInterval(timerInterval.current);
}
setIsRecording(false);
setAudioLevel(0);
setRecordingTime(0);
chunks.current = [];
chunkIndex.current = 0;
} catch (error) {
console.error('Error stopping recording:', error);
alert('Error saving recording. Please try again.');
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const SettingsDialog = () => (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-lg w-full">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Settings</h2>
<button
onClick={() => setShowSettings(false)}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Recording Save Directory
</label>
<div className="flex items-center space-x-2">
<input
type="text"
value={settings?.saveDirectory || ''}
readOnly
className="flex-1 rounded-md border-gray-300 bg-gray-50"
/>
<button
onClick={() => handleDirectorySelect('save')}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Choose
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Location where completed recordings will be saved
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Backup Directory
</label>
<div className="flex items-center space-x-2">
<input
type="text"
value={settings?.backupDirectory || ''}
readOnly
className="flex-1 rounded-md border-gray-300 bg-gray-50"
/>
<button
onClick={() => handleDirectorySelect('backup')}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Choose
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Location where backup chunks will be saved during recording
</p>
</div>
</div>
<div className="mt-8 flex justify-end">
<button
onClick={() => setShowSettings(false)}
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
);
if (!hasSubmittedEmail) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">
Podcast Recorder
</h1>
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Enter your email to continue
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="user@example.com"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 transition-colors"
>
Continue
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md relative">
<div className="absolute top-4 right-4 flex space-x-2">
<button
onClick={() => !isRecording && setShowSettings(true)}
className={`text-gray-600 ${isRecording ? 'opacity-50 cursor-not-allowed' : 'hover:text-gray-800'}`}
title={isRecording ? 'Cannot change settings while recording' : 'Settings'}
disabled={isRecording}
>
<Cog6ToothIcon className="w-6 h-6" />
</button>
<button
onClick={handleLogout}
className="text-gray-600 hover:text-gray-800"
title="Logout"
>
<ArrowRightOnRectangleIcon className="w-6 h-6" />
</button>
</div>
<h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">
Podcast Recorder
</h1>
<div className="text-center mb-6">
<p className="text-gray-600">User: {email}</p>
</div>
<div className="text-center mb-8">
<div className="text-4xl font-mono mb-4">{formatTime(recordingTime)}</div>
{isRecording && (
<div className="w-full h-8 bg-gray-200 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-green-500 transition-all duration-100"
style={{ width: `${audioLevel * 100}%` }}
/>
</div>
)}
</div>
<div className="flex justify-center space-x-4">
{!isRecording ? (
<button
onClick={startRecording}
className="bg-red-500 text-white py-3 px-6 rounded-full hover:bg-red-600 transition-colors flex items-center space-x-2"
>
<span className="w-4 h-4 bg-white rounded-full inline-block"></span>
<span>Start Recording</span>
</button>
) : (
<button
onClick={stopRecording}
className="bg-gray-800 text-white py-3 px-6 rounded-full hover:bg-gray-900 transition-colors flex items-center space-x-2"
>
<span className="w-4 h-4 bg-white rounded inline-block"></span>
<span>Stop Recording</span>
</button>
)}
</div>
{isRecording && (
<div className="text-center mt-6">
<p className="text-red-500">Recording in progress...</p>
</div>
)}
{showSettings && <SettingsDialog />}
</div>
</div>
);
}
export default App;

71
src/audioUtils.ts Normal file
View File

@ -0,0 +1,71 @@
import audioBufferToWav from 'audiobuffer-to-wav';
export async function blobToAudioBuffer(blob: Blob, audioContext: AudioContext): Promise<AudioBuffer> {
try {
const arrayBuffer = await blob.arrayBuffer();
return new Promise((resolve, reject) => {
audioContext.decodeAudioData(
arrayBuffer,
(buffer) => resolve(buffer),
(error) => reject(new Error('Failed to decode audio data: ' + error))
);
});
} catch (error) {
console.error('Error converting blob to AudioBuffer:', error);
throw new Error('Failed to process audio data');
}
}
export async function concatenateAudioBuffers(audioBuffers: AudioBuffer[], audioContext: AudioContext): Promise<AudioBuffer> {
try {
if (!audioBuffers.length) {
throw new Error('No audio buffers to concatenate');
}
// Calculate the total length of all buffers
const totalLength = audioBuffers.reduce((sum, buffer) => sum + buffer.length, 0);
const numberOfChannels = audioBuffers[0].numberOfChannels;
const sampleRate = audioBuffers[0].sampleRate;
// Create a new buffer with the total length
const concatenatedBuffer = audioContext.createBuffer(
numberOfChannels,
totalLength,
sampleRate
);
// Copy data from each buffer into the concatenated buffer
let offset = 0;
for (const buffer of audioBuffers) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sourceData = buffer.getChannelData(channel);
const targetData = concatenatedBuffer.getChannelData(channel);
targetData.set(sourceData, offset);
}
offset += buffer.length;
}
return concatenatedBuffer;
} catch (error) {
console.error('Error concatenating audio buffers:', error);
throw new Error('Failed to combine audio segments');
}
}
export async function createWavBlob(audioBuffer: AudioBuffer): Promise<Blob> {
try {
if (!audioBuffer || audioBuffer.length === 0) {
throw new Error('Invalid audio buffer');
}
const wavData = audioBufferToWav(audioBuffer);
if (!wavData || wavData.byteLength === 0) {
throw new Error('Failed to create WAV data');
}
return new Blob([wavData], { type: 'audio/wav' });
} catch (error) {
console.error('Error creating WAV blob:', error);
throw new Error('Failed to create audio file');
}
}
// Remove the verifyDirectoryPermissions function as we're using Electron's native file system APIs now

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}

18
src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}

34
vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: process.env.NODE_ENV === 'production' ? './' : '/',
build: {
outDir: 'dist',
assetsDir: '.',
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
},
},
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
watch: {
usePolling: true,
interval: 100,
},
},
clearScreen: false,
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})