Adding files
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
158
README.md
Normal 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
217
electron/main.cjs
Normal 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
49
electron/main.js
Normal 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
13
electron/preload.cjs
Normal 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
13
index.html
Normal 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
7816
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
419
src/App.tsx
Normal file
419
src/App.tsx
Normal 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
71
src/audioUtils.ts
Normal 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
13
src/index.css
Normal 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
18
src/main.tsx
Normal 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
13
tailwind.config.js
Normal 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
34
vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user