collection list, move, delete

This commit is contained in:
michalcourson
2026-02-20 20:21:08 -05:00
parent d6f4d4166b
commit 60355d176c
18 changed files with 437 additions and 2064 deletions

View File

@ -1,74 +0,0 @@
<!doctype html>
<html>
<head>
<title>Audio Clip Trimmer</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="titlebar"></div>
<div class="app-container">
<div class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="collections-list"></div>
<button id="add-collection-btn" class="add-collection-btn">
+ New Collection
</button>
</div>
<div class="sidebar-section">
<div id="nav-buttons">
<button id="settings-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"
/>
</svg>
</button>
<button id="restart-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="main-content">
<div class="audio-trimmers-section">
<div id="audio-trimmers-list" class="audio-trimmers-list">
<!-- Audio trimmers will be dynamically added here -->
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h2>Settings</h2>
<div class="settings-group">
<label for="recording-length">Recording Length (seconds):</label>
<input type="number" id="recording-length" min="1" max="300" />
</div>
<div class="settings-group">
<label for="osc-port">OSC port:</label>
<input type="number" id="osc-port" min="5000" max="6000" />
</div>
<div class="settings-group">
<label for="output-folder">Output Folder:</label>
<input type="text" id="output-folder" readonly />
<button id="select-output-folder">Browse</button>
</div>
<div class="settings-group">
<label for="input-device">Input Device:</label>
<select id="input-device"></select>
</div>
<button id="save-settings">Save Settings</button>
</div>
</div>
<script src="node_modules/wavesurfer.js/dist/wavesurfer.min.js"></script>
<script src="node_modules/wavesurfer.js/dist/plugin/wavesurfer.regions.js"></script>
<script src="renderer.js"></script>
</body>
</html>

View File

@ -1,483 +0,0 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, dialog } = require('electron');
const path = require('path');
const os = require('os');
const spawn = require('child_process').spawn;
require('electron-reload')(__dirname);
const fs = require('fs').promises;
const chokidar = require('chokidar');
const wavefile = require('wavefile');
const MetadataManager = require('./metatadata');
const { webContents } = require('electron');
// import { app, BrowserWindow, ipcMain, Tray, Menu, dialog } from "electron";
// import path from "path";
// import os from "os";
// import spawn from 'child_process';
// import fs from "fs";
// import chokidar from "chokidar";
// import wavefile from "wavefile";
// import MetadataManager from "./metatadata.cjs";
// import { webContents } from "electron";
let mainWindow;
let tray;
let audioServiceProcess;
const metadataPath = path.join(app.getPath('userData'), 'audio_metadata.json');
const metadataManager = new MetadataManager(metadataPath);
async function createPythonService() {
const pythonPath =
process.platform === 'win32'
? path.join(
__dirname,
'..',
'..',
'audio-service',
'venv',
'Scripts',
'python.exe',
)
: path.join(__dirname, '..', 'audio-service', 'venv', 'bin', 'python');
const scriptPath = path.join(
__dirname,
'..',
'..',
'audio-service',
'src',
'main.py',
);
// Load settings to pass as arguments
const settings = await loadSettings(); // Assuming you have a method to load settings
const args = [
scriptPath,
'--recording-length',
settings.recordingLength.toString(),
'--save-path',
path.join(settings.outputFolder, 'original'),
'--osc-port',
settings.oscPort.toString(), // Or make this configurable
];
// Add input device if specified
if (settings.inputDevice) {
const devices = await listAudioDevices();
args.push(
'--input-device',
devices.find((device) => device.id === settings.inputDevice)?.name,
);
}
console.log(args);
audioServiceProcess = spawn(pythonPath, args, {
detached: false,
stdio: 'pipe',
});
audioServiceProcess.stdout.on('data', (data) => {
console.log(`Audio Service: ${data}`);
});
audioServiceProcess.stderr.on('data', (data) => {
console.error(`Audio Service Error: ${data}`);
});
audioServiceProcess.on('close', (code) => {
console.log(`Audio Service process exited with code ${code}`);
audioServiceProcess = null;
});
}
function createTray() {
tray = new Tray(path.join(__dirname, 'assets', 'tray-icon.png')); // You'll need to create this icon
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show',
click: () => {
mainWindow.show();
},
},
{
label: 'Quit',
click: () => {
// Properly terminate the Python service
stopService();
app.quit();
},
},
]);
tray.setToolTip('Audio Trimmer');
tray.setContextMenu(contextMenu);
}
async function checkNewWavFile(filePath) {
// Only process .wav files
if (path.extname(filePath).toLowerCase() === '.wav') {
try {
await metadataManager.addUntrimmedFile(filePath);
// Notify renderer if window is ready
if (mainWindow) {
mainWindow.webContents.send('new-untrimmed-file', filePath);
}
} catch (error) {
console.error('Error adding untrimmed file:', error);
}
}
}
function stopService() {
if (audioServiceProcess) {
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
} else {
audioServiceProcess.kill('SIGTERM');
}
} catch (error) {
console.error('Error killing audio service:', error);
}
}
}
function restartService() {
// Properly terminate the Python service
stopService();
//delay for 2 seconds
setTimeout(createPythonService, 4000);
//createPythonService();
}
async function loadSettings() {
try {
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
const settingsData = await fs.readFile(settingsPath, 'utf8');
return JSON.parse(settingsData);
} catch (error) {
// If no settings file exists, return default settings
return {
recordingLength: 30,
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
inputDevice: null,
};
}
}
async function listAudioDevices() {
try {
// Use a webContents to access navigator.mediaDevices
const contents = webContents.getAllWebContents()[0];
const devices = await contents.executeJavaScript(`
navigator.mediaDevices.enumerateDevices()
.then(devices => devices.filter(device => device.kind === 'audioinput'))
.then(audioDevices => audioDevices.map(device => ({
id: device.deviceId,
name: device.label || 'Unknown Microphone'
})))
`);
return devices;
} catch (error) {
console.error('Error getting input devices:', error);
return [];
}
}
async function createWindow() {
// Initialize metadata
await metadataManager.initialize();
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
autoHideMenuBar: true,
titleBarStyle: 'hidden',
frame: false,
// titleBarOverlay: {
// color: '#1e1e1e',
// symbolColor: '#ffffff',
// height: 30
// },
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
// Add these to help with graphics issues
},
// These additional options can help with graphics rendering
backgroundColor: '#1e1e1e',
...(process.platform !== 'darwin'
? {
titleBarOverlay: {
color: '#262626',
symbolColor: '#ffffff',
height: 30,
},
}
: {}),
});
mainWindow.loadFile('src/index.html');
// Create Python ser
const settings = await loadSettings(); // Assuming you have a method to load settings
const recordingsPath = path.join(settings.outputFolder, 'original');
// Ensure recordings directory exists
try {
await fs.mkdir(recordingsPath, { recursive: true });
} catch (error) {
console.error('Error creating recordings directory:', error);
}
// Watch for new WAV files
const watcher = chokidar.watch(recordingsPath, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
depth: 0,
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
});
fs.readdir(recordingsPath).then((files) => {
files.forEach((file) => {
checkNewWavFile(path.join(recordingsPath, file));
});
});
watcher.on('add', async (filePath) => {
await checkNewWavFile(filePath);
});
ipcMain.handle('get-collections', () => {
return metadataManager.getCollections();
});
ipcMain.handle('get-collection-files', (event, collectionPath) => {
return metadataManager.getFilesInCollection(collectionPath);
});
ipcMain.handle('add-untrimmed-file', (event, filePath) => {
return metadataManager.addUntrimmedFile(filePath);
});
ipcMain.handle(
'save-trimmed-file',
(event, fileName, previousPath, savePath, trimStart, trimEnd, title) => {
return metadataManager.saveTrimmedFile(
fileName,
previousPath,
savePath,
trimStart,
trimEnd,
title,
);
},
);
ipcMain.handle('restart', (event) => {
restartService();
});
ipcMain.handle('delete-old-file', (event, outputFolder, section, title) => {
if (section === 'untrimmed') return;
const collectionPath = path.join(outputFolder, section);
const outputFilePath = path.join(collectionPath, `${title}.wav`);
fs.unlink(outputFilePath);
});
ipcMain.handle(
'save-trimmed-audio',
async (
event,
{
originalFilePath,
outputFolder,
collectionName,
title,
trimStart,
trimEnd,
},
) => {
try {
// Ensure the collection folder exists
const collectionPath = path.join(outputFolder, collectionName);
await fs.mkdir(collectionPath, { recursive: true });
// Generate output file path
const outputFilePath = path.join(collectionPath, `${title}.wav`);
// Read the original WAV file
const originalWaveFile = new wavefile.WaveFile(
await fs.readFile(originalFilePath),
);
// Calculate trim points in samples
const sampleRate = originalWaveFile.fmt.sampleRate;
const startSample = Math.floor(trimStart * sampleRate);
const endSample = Math.floor(trimEnd * sampleRate);
// Extract trimmed audio samples
const originalSamples = originalWaveFile.getSamples(false);
const trimmedSamples = [
originalSamples[0].slice(startSample, endSample),
originalSamples[1].slice(startSample, endSample),
];
// Normalize samples if they are Int16 or Int32
let normalizedSamples;
const bitDepth = originalWaveFile.fmt.bitsPerSample;
// if (bitDepth === 16) {
// // For 16-bit audio, convert to Float32
// normalizedSamples = [new Float32Array(trimmedSamples[0].length),new Float32Array(trimmedSamples[0].length)];
// for (let i = 0; i < trimmedSamples[0].length; i++) {
// normalizedSamples[0][i] = trimmedSamples[0][i] / 32768.0;
// normalizedSamples[1][i] = trimmedSamples[1][i] / 32768.0;
// }
// } else if (bitDepth === 32) {
// // For 32-bit float audio, just convert to Float32
// normalizedSamples = [new Float32Array(trimmedSamples[0]),new Float32Array(trimmedSamples[1])];
// } else {
// throw new Error(`Unsupported bit depth: ${bitDepth}`);
// }
// Create a new WaveFile with normalized samples
const trimmedWaveFile = new wavefile.WaveFile();
trimmedWaveFile.fromScratch(
originalWaveFile.fmt.numChannels,
sampleRate,
bitDepth, // Always use 32-bit float
trimmedSamples,
);
// Write the trimmed WAV file
await fs.writeFile(outputFilePath, trimmedWaveFile.toBuffer());
return {
success: true,
filePath: outputFilePath,
};
} catch (error) {
console.error('Error saving trimmed audio:', error);
return {
success: false,
error: error.message,
};
}
},
);
ipcMain.handle('delete-file', async (event, filePath) => {
try {
const settings = await loadSettings();
return metadataManager.deletefile(filePath, settings.outputFolder);
} catch (error) {
console.error('Error Deleting file:', error);
throw error;
}
});
ipcMain.handle('add-new-collection', (event, collectionName) => {
try {
return metadataManager.addNewCollection(collectionName);
} catch (error) {
console.error('Error adding collection:', error);
throw error;
}
});
ipcMain.handle('get-trim-info', (event, collectionName, filePath) => {
return metadataManager.getTrimInfo(collectionName, filePath);
});
ipcMain.handle(
'set-trim-info',
(event, collectionName, filePath, trim_info) => {
return metadataManager.setTrimInfo(collectionName, filePath, trim_info);
},
);
// Add these IPC handlers
ipcMain.handle('select-output-folder', async (event) => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
});
return result.filePaths[0] || '';
});
ipcMain.handle('get-default-settings', () => {
return {
recordingLength: 30,
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
inputDevice: null,
};
});
ipcMain.handle('save-settings', async (event, settings) => {
try {
// Ensure output folder exists
await fs.mkdir(settings.outputFolder, { recursive: true });
// Save settings to a file
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
restartService();
return true;
} catch (error) {
console.error('Error saving settings:', error);
return false;
}
});
ipcMain.handle('load-settings', async () => {
return loadSettings();
});
ipcMain.handle('get-input-devices', async () => {
return await listAudioDevices();
});
// Minimize to tray instead of closing
mainWindow.on('close', (event) => {
event.preventDefault();
mainWindow.hide();
});
// Create system tray
createTray();
// Launch Python audio service
createPythonService();
}
app.disableHardwareAcceleration();
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
// Do nothing - we handle closing via tray
});
// Ensure Python service is killed when app quits
app.on('before-quit', () => {
if (audioServiceProcess) {
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
} else {
audioServiceProcess.kill('SIGTERM');
}
} catch (error) {
console.error('Error killing audio service:', error);
}
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

View File

@ -1,234 +0,0 @@
const fs = require('fs').promises;
const path = require('path');
// import fs from 'fs';
// import path from 'path';
class MetadataManager {
constructor(metadataPath) {
this.metadataPath = metadataPath;
this.metadata = {};
//this.initialize();
}
async initialize() {
try {
// Create metadata file if it doesn't exist
console.log(this.metadataPath);
await this.ensureMetadataFileExists();
// Load existing metadata
const rawData = await fs.readFile(this.metadataPath, 'utf8');
this.metadata = JSON.parse(rawData);
} catch (error) {
console.error('Error initializing metadata:', error);
this.metadata = {};
}
}
async ensureMetadataFileExists() {
try {
await fs.access(this.metadataPath);
} catch (error) {
// File doesn't exist, create it with an empty object
await fs.writeFile(this.metadataPath, JSON.stringify({
collections: {
untrimmed: {}
}
}, null, 2));
}
}
async addUntrimmedFile(filePath) {
try {
// Read existing metadata
const metadata = this.metadata;
// Check if file is already in untrimmed files
const fileName = path.basename(filePath);
const existingUntrimmedFiles = Object.keys(metadata.collections.untrimmed) || [];
// Check if the file is already in trimmed files across all collections
const collections = Object.keys(metadata.collections || {});
const isAlreadyTrimmed = collections.some(collection => {
return (Object.keys(metadata.collections[collection] || {})).some(name => {
return fileName === name;
});
});
// If already trimmed, don't add to untrimmed files
if (isAlreadyTrimmed) {
return false;
}
// Prevent duplicates
if (!existingUntrimmedFiles.includes(filePath)) {
const d = new Date()
metadata.collections.untrimmed[fileName] = {
originalPath:filePath,
addedAt:d.toISOString()
}
// Write updated metadata
await this.saveMetadata();
return true;
}
return false;
} catch (error) {
console.error('Error adding untrimmed file:', error);
return false;
}
}
async saveTrimmedFile(fileName, previousPath, savePath, trimStart, trimEnd, title) {
console.log(title);
// Ensure collection exists
if (!this.metadata.collections[savePath]) {
this.metadata.collections[savePath] = {};
}
// Find the original untrimmed file
const original = this.metadata.collections[previousPath][fileName];
// Add to specified collection
this.metadata.collections[savePath][fileName] = {
...original,
title,
trimStart,
trimEnd,
};
// Remove from untrimmed if it exists
if(previousPath !== savePath) {
// if(previousPath !== 'untrimmed') {
// const prevmeta = this.metadata.collections[previousPath][fileName];
// let delete_path = path.concat(previousPath, prevmeta.title + ".wav");
// }
delete this.metadata.collections[previousPath][fileName];
}
await this.saveMetadata();
return fileName;
}
async saveMetadata() {
try {
await fs.writeFile(
this.metadataPath,
JSON.stringify(this.metadata, null, 2)
);
} catch (error) {
console.error('Error saving metadata:', error);
}
}
async getUntrimmedFiles() {
try {
// Read the metadata file
const metadata = await this.readMetadataFile();
// Get all collections
const collections = Object.keys(metadata.collections || {});
// Collect all trimmed file names across all collections
const trimmedFiles = new Set();
collections.forEach(collection => {
const collectionTrimmedFiles = metadata.collections[collection]?.trimmedFiles || [];
collectionTrimmedFiles.forEach(trimmedFile => {
trimmedFiles.add(trimmedFile.originalFileName);
});
});
// Filter out untrimmed files that have been trimmed
const untrimmedFiles = (metadata.untrimmedFiles || []).filter(file =>
!trimmedFiles.has(path.basename(file))
);
return untrimmedFiles;
} catch (error) {
console.error('Error getting untrimmed files:', error);
return [];
}
}
async deletefile(filePath, collectionPath) {
try {
const fileName = path.basename(filePath);
for (const collection in this.metadata.collections) {
if (this.metadata.collections[collection][fileName]) {
let delete_path = this.metadata.collections[collection][fileName].originalPath;
fs.unlink(delete_path);
if(collection !== 'untrimmed') {
delete_path = path.join(collectionPath, collection, this.metadata.collections[collection][fileName].title + ".wav");
fs.unlink(delete_path);
}
delete this.metadata.collections[collection][fileName];
this.saveMetadata();
return 0
}
}
}catch (error) {
console.log(error)
}
return 0
}
getCollections() {
return Object.keys(this.metadata.collections);
}
getTrimInfo(collectionName, filePath) {
return this.metadata.collections[collectionName][filePath] || {
trimStart: 0,
trimEnd: 0
};
}
setTrimInfo(collectionName, filePath, trimInfo) {
this.metadata.collections[collectionName][filePath].trimStart = trimInfo.trimStart;
this.metadata.collections[collectionName][filePath].trimEnd = trimInfo.trimEnd;
this.saveMetadata();
}
getFilesInCollection(collectionPath) {
// if(collectionPath === 'untrimmed') {
// return Object.keys(this.metadata.untrimmed).map(fileName => ({
// fileName,
// ...this.metadata.untrimmed[fileName]
// }));
// }
return Object.keys(this.metadata.collections[collectionPath] || {}).map(fileName => {
const fileInfo = this.metadata.collections[collectionPath][fileName];
return {
fileName,
...this.metadata.collections[collectionPath][fileName],
};
});
}
async addNewCollection(collectionName) {
// Ensure collection name is valid
if (!collectionName || collectionName.trim() === '') {
throw new Error('Collection name cannot be empty');
}
// Normalize collection name (remove leading/trailing spaces, convert to lowercase)
const normalizedName = collectionName.trim().toLowerCase();
// Check if collection already exists
if (this.metadata.collections[normalizedName]) {
throw new Error(`Collection '${normalizedName}' already exists`);
}
// Add new collection
this.metadata.collections[normalizedName] = {};
// Save updated metadata
await this.saveMetadata();
return normalizedName;
}
}
module.exports = MetadataManager;

View File

@ -1,818 +0,0 @@
const { ipcRenderer } = require('electron');
// const path = require('path');
const WaveSurfer = require('wavesurfer.js');
const RegionsPlugin = require('wavesurfer.js/dist/plugin/wavesurfer.regions.js');
document.addEventListener('DOMContentLoaded', async () => {
// Settings Modal Logic
const settingsModal = document.getElementById('settings-modal');
const settingsBtn = document.getElementById('settings-btn');
const restartBtn = document.getElementById('restart-btn');
const closeModalBtn = document.querySelector('.close-modal');
const saveSettingsBtn = document.getElementById('save-settings');
const selectOutputFolderBtn = document.getElementById('select-output-folder');
const recordingLengthInput = document.getElementById('recording-length');
const oscPortInput = document.getElementById('osc-port');
const outputFolderInput = document.getElementById('output-folder');
const inputDeviceSelect = document.getElementById('input-device');
// Open settings modal
settingsBtn.addEventListener('click', async () => {
try {
// Request microphone permissions first
await navigator.mediaDevices.getUserMedia({ audio: true });
// Load current settings
const settings = await ipcRenderer.invoke('load-settings');
// Populate input devices
const devices = await ipcRenderer.invoke('get-input-devices');
if (devices.length === 0) {
inputDeviceSelect.innerHTML = '<option>No microphones found</option>';
} else {
inputDeviceSelect.innerHTML = devices
.map(
(device) => `<option value="${device.id}">${device.name}</option>`,
)
.join('');
}
// Set current settings
recordingLengthInput.value = settings.recordingLength;
outputFolderInput.value = settings.outputFolder;
inputDeviceSelect.value = settings.inputDevice;
oscPortInput.value = settings.oscPort;
settingsModal.style.display = 'block';
} catch (error) {
console.error('Error loading settings or devices:', error);
alert('Please grant microphone permissions to list audio devices');
}
});
restartBtn.addEventListener('click', async () => {
try {
await ipcRenderer.invoke('restart');
} catch (error) {
console.error('Error restarting:', error);
alert('Failed to restart Clipper');
}
});
// Close settings modal
closeModalBtn.addEventListener('click', () => {
settingsModal.style.display = 'none';
});
// Select output folder
selectOutputFolderBtn.addEventListener('click', async () => {
const folderPath = await ipcRenderer.invoke('select-output-folder');
if (folderPath) {
outputFolderInput.value = folderPath;
}
});
// Save settings
saveSettingsBtn.addEventListener('click', async () => {
const settings = {
recordingLength: parseInt(recordingLengthInput.value),
oscPort: parseInt(oscPortInput.value),
outputFolder: outputFolderInput.value,
inputDevice: inputDeviceSelect.value,
};
const saved = await ipcRenderer.invoke('save-settings', settings);
if (saved) {
settingsModal.style.display = 'none';
} else {
alert('Failed to save settings');
}
});
// Close modal if clicked outside
window.addEventListener('click', (event) => {
if (event.target === settingsModal) {
settingsModal.style.display = 'none';
}
});
const audioTrimmersList = document.getElementById('audio-trimmers-list');
const collectionsList = document.getElementById('collections-list');
//const currentSectionTitle = document.getElementById("current-section-title");
// Global state to persist wavesurfer instances and trimmer states
const globalState = {
wavesurferInstances: {},
trimmerStates: {},
currentSection: 'untrimmed',
trimmerElements: {},
};
// Utility function to format time
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
// Populate collections list
async function populateCollectionsList() {
const collections = await ipcRenderer.invoke('get-collections');
collectionsList.innerHTML = '';
// Always add Untrimmed section first
const untrimmedItem = document.createElement('div');
untrimmedItem.classList.add('collection-item');
untrimmedItem.textContent = 'Untrimmed';
untrimmedItem.dataset.collection = 'untrimmed';
untrimmedItem.addEventListener('click', () => {
loadCollectionFiles('untrimmed');
});
collectionsList.appendChild(untrimmedItem);
// Add other collections
collections.forEach((collection) => {
if (collection === 'untrimmed') {
return;
}
const collectionItem = document.createElement('div');
collectionItem.classList.add('collection-item');
collectionItem.textContent = collection;
collectionItem.dataset.collection = collection;
collectionItem.addEventListener('click', () => {
loadCollectionFiles(collection);
});
collectionsList.appendChild(collectionItem);
});
}
// Modify loadCollectionFiles function
async function loadCollectionFiles(collection) {
if (collection !== globalState.currentSection) {
//Clear existing trimmers and reset global state
Object.keys(globalState.trimmerElements).forEach((filePath) => {
const trimmerElement = globalState.trimmerElements[filePath];
if (trimmerElement && trimmerElement.parentNode) {
trimmerElement.parentNode.removeChild(trimmerElement);
}
});
// Reset global state
globalState.trimmerElements = {};
globalState.wavesurferInstances = {};
globalState.trimmerStates = {};
}
// Reset active states
document.querySelectorAll('.collection-item').forEach((el) => {
el.classList.remove('active');
});
// Set active state only for existing items
const activeItem = document.querySelector(
`.collection-item[data-collection="${collection}"]`,
);
// Only add active class if the item exists
if (activeItem) {
activeItem.classList.add('active');
}
// Update section title and global state
//currentSectionTitle.textContent = collection;
globalState.currentSection = collection;
// Load files
const files = await ipcRenderer.invoke('get-collection-files', collection);
// Add new trimmers with saved trim information
for (const file of files) {
const filePath = file.originalPath || file.fileName;
// If loading a collection, use saved trim information
//if (collection !== "untrimmed") {
// Store trim information in global state before creating trimmer
// globalState.trimmerStates[filePath] = {
// trimStart: file.trimStart || 0,
// trimEnd: file.trimEnd || 0,
// regionStart: file.trimStart || 0,
// regionEnd: file.trimEnd || 0,
// originalPath: file.originalPath,
// };
//}
createAudioTrimmer(filePath, collection);
}
}
// Create audio trimmer for a single file
async function createAudioTrimmer(filePath, section) {
// Check if trimmer already exists
if (globalState.trimmerElements[filePath]) {
return globalState.trimmerElements[filePath];
}
const savedTrimInfo = await ipcRenderer.invoke(
'get-trim-info',
globalState.currentSection,
path.basename(filePath),
);
// Create trimmer container
const trimmerContainer = document.createElement('div');
trimmerContainer.classList.add('audio-trimmer-item');
trimmerContainer.dataset.filepath = filePath;
// Create header with title and controls
const trimmerHeader = document.createElement('div');
trimmerHeader.classList.add('audio-trimmer-header');
// Title container
const titleContainer = document.createElement('div');
titleContainer.classList.add('audio-trimmer-title-container');
if (savedTrimInfo.title) {
// Title
const title = document.createElement('div');
title.classList.add('audio-trimmer-title');
title.textContent = savedTrimInfo.title;
titleContainer.appendChild(title);
// Filename
const fileName = document.createElement('div');
fileName.classList.add('audio-trimmer-filename');
fileName.textContent = path.basename(filePath);
titleContainer.appendChild(fileName);
} else {
// Title (using filename if no custom title)
const title = document.createElement('div');
title.classList.add('audio-trimmer-title');
title.textContent = path.basename(filePath);
titleContainer.appendChild(title);
// Filename
const fileName = document.createElement('div');
fileName.classList.add('audio-trimmer-filename');
fileName.textContent = 'hidden';
fileName.style.opacity = 0;
titleContainer.appendChild(fileName);
}
// Controls container
const controlsContainer = document.createElement('div');
controlsContainer.classList.add('audio-trimmer-controls');
// Play/Pause and Save buttons
const playPauseBtn = document.createElement('button');
playPauseBtn.classList.add('play-pause-btn');
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
const saveTrimButton = document.createElement('button');
saveTrimButton.classList.add('save-trim');
saveTrimButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>
`;
const deletebutton = document.createElement('button');
deletebutton.classList.add('play-pause-btn');
deletebutton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
`;
controlsContainer.appendChild(playPauseBtn);
controlsContainer.appendChild(saveTrimButton);
controlsContainer.appendChild(deletebutton);
// Assemble header
trimmerHeader.appendChild(titleContainer);
trimmerHeader.appendChild(controlsContainer);
trimmerContainer.appendChild(trimmerHeader);
// Waveform container
const waveformContainer = document.createElement('div');
waveformContainer.classList.add('waveform-container');
const waveformId = `waveform-${path.basename(
filePath,
path.extname(filePath),
)}`;
waveformContainer.innerHTML = `
<div id="${waveformId}" class="waveform"></div>
`;
trimmerContainer.appendChild(waveformContainer);
// Time displays
const timeInfo = document.createElement('div');
timeInfo.classList.add('trim-info');
timeInfo.innerHTML = `
<div class="trim-time">
<span>Start: </span>
<span class="trim-start-time">0:00</span>
</div>
<div class="trim-time">
<span>End: </span>
<span class="trim-end-time">0:00</span>
</div>
`;
// const zoomContainer = document.createElement('div');
// zoomContainer.className = 'zoom-controls';
// zoomContainer.innerHTML = `
// <button class="zoom-in">+</button>
// <button class="zoom-out">-</button>
// <input type="range" min="1" max="200" value="100" class="zoom-slider">
// `;
// timeInfo.appendChild(zoomContainer);
// const zoomInBtn = zoomContainer.querySelector('.zoom-in');
// const zoomOutBtn = zoomContainer.querySelector('.zoom-out');
// const zoomSlider = zoomContainer.querySelector('.zoom-slider');
// // Zoom functionality
// const updateZoom = (zoomLevel) => {
// // Get the current scroll position and width
// const scrollContainer = wavesurfer.container.querySelector('wave');
// const currentScroll = scrollContainer.scrollLeft;
// const containerWidth = scrollContainer.clientWidth;
// // Calculate the center point of the current view
// //const centerTime = wavesurfer.getCurrentTime();
// // Apply zoom
// wavesurfer.zoom(zoomLevel);
// // Recalculate scroll to keep the center point in view
// const newDuration = wavesurfer.getDuration();
// const pixelsPerSecond = wavesurfer.drawer.width / newDuration;
// const centerPixel = centerTime * pixelsPerSecond;
// // Adjust scroll to keep the center point in the same relative position
// const newScrollLeft = centerPixel - (containerWidth / 2);
// scrollContainer.scrollLeft = Math.max(0, newScrollLeft);
// console.log(currentScroll, newScrollLeft);
// };
// zoomInBtn.addEventListener('click', () => {
// const currentZoom = parseInt(zoomSlider.value);
// zoomSlider.value = Math.min(currentZoom + 20, 200);
// updateZoom(zoomSlider.value);
// });
// zoomOutBtn.addEventListener('click', () => {
// const currentZoom = parseInt(zoomSlider.value);
// zoomSlider.value = Math.max(currentZoom - 20, 1);
// updateZoom(zoomSlider.value);
// });
// zoomSlider.addEventListener('input', (e) => {
// updateZoom(e.target.value);
// });
trimmerContainer.appendChild(timeInfo);
// Add to list and global state
audioTrimmersList.appendChild(trimmerContainer);
globalState.trimmerElements[filePath] = trimmerContainer;
// Determine the file to load (original or current)
const fileToLoad =
section === 'untrimmed'
? filePath
: globalState.trimmerStates[filePath]?.originalPath || filePath;
// Setup wavesurfer
const wavesurfer = WaveSurfer.create({
container: `#${waveformId}`,
waveColor: '#ccb1ff',
progressColor: '#6e44ba',
responsive: true,
height: 100,
hideScrollbar: true,
// barWidth: 2,
// barRadius: 3,
cursorWidth: 1,
backend: 'WebAudio',
plugins: [
RegionsPlugin.create({
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
dragSelection: {
slop: 20,
},
}),
// ZoomPlugin.create({
// // the amount of zoom per wheel step, e.g. 0.5 means a 50% magnification per scroll
// scale: 0.5,
// // Optionally, specify the maximum pixels-per-second factor while zooming
// maxZoom: 100,
// }),
],
});
// Store wavesurfer instance in global state
globalState.wavesurferInstances[filePath] = wavesurfer;
// Use existing trim state or create new one
globalState.trimmerStates[filePath] = globalState.trimmerStates[filePath] ||
savedTrimInfo || {
trimStart: 0,
trimEnd: 0,
regionStart: undefined,
regionEnd: undefined,
originalPath: fileToLoad,
};
const startTimeDisplay = timeInfo.querySelector('.trim-start-time');
const endTimeDisplay = timeInfo.querySelector('.trim-end-time');
// Load audio file
wavesurfer.load(`file://${fileToLoad}`);
// Setup play/pause button
playPauseBtn.onclick = () => {
const instanceState = globalState.trimmerStates[filePath];
if (wavesurfer.isPlaying()) {
wavesurfer.pause();
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
} else {
// Always start from the trim start
wavesurfer.play(instanceState.trimStart, instanceState.trimEnd);
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
`;
}
};
// When audio is ready
wavesurfer.on('ready', async () => {
const instanceState = globalState.trimmerStates[filePath];
// Set trim times based on saved state or full duration
if (instanceState.trimStart) {
// Create initial region covering trim or full duration
wavesurfer.clearRegions();
const region = wavesurfer.addRegion({
start: instanceState.trimStart,
end: instanceState.trimEnd,
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
}
instanceState.trimStart = instanceState.trimStart || 0;
instanceState.trimEnd = instanceState.trimEnd || wavesurfer.getDuration();
// Update time displays
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
// Store region details
instanceState.regionStart = instanceState.trimStart;
instanceState.regionEnd = instanceState.trimEnd;
// Listen for region updates
wavesurfer.on('region-update-end', async (updatedRegion) => {
// Ensure the region doesn't exceed audio duration
instanceState.trimStart = Math.max(0, updatedRegion.start);
instanceState.trimEnd = Math.min(
wavesurfer.getDuration(),
updatedRegion.end,
);
// Update time displays
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
// Store updated region details
instanceState.regionStart = instanceState.trimStart;
instanceState.regionEnd = instanceState.trimEnd;
globalState.trimmerStates[filePath] = instanceState;
// Adjust region if it exceeds bounds
updatedRegion.update({
start: instanceState.trimStart,
end: instanceState.trimEnd,
});
});
// Handle region creation
wavesurfer.on('region-created', (newRegion) => {
// Remove all other regions
Object.keys(wavesurfer.regions.list).forEach((id) => {
if (wavesurfer.regions.list[id] !== newRegion) {
wavesurfer.regions.list[id].remove();
}
});
});
// Reset to trim start when audio finishes
wavesurfer.on('finish', () => {
wavesurfer.setCurrentTime(instanceState.trimStart);
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
});
// Save trimmed audio functionality
saveTrimButton.addEventListener('click', async () => {
try {
// Get current collections
const collections = await ipcRenderer.invoke('get-collections');
// Create a dialog to select or create a collection
const dialogHtml = `
<div id="save-collection-dialog"
style="
position: fixed;
top: 50%;
left: 50%;
background-color: #2a2a2a;
transform: translate(-50%, -50%);
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1000;
">
<div style="">
<input type="text" id="new-save-title" placeholder="Title">
</div>
<select id="existing-collections" style="width: 100%; margin-top: 10px;">
${collections
.map((col) =>
col === 'untrimmed'
? ''
: `<option value="${col}" ${
globalState.currentSection === col ? 'selected' : ''
}>${col}</option>`,
)
.join('')}
</select>
<div style="margin-top: 10px; display: flex; justify-content: space-between;">
<button class="play-pause-btn" id="cancel-save-btn" style="width: 48%; ">Cancel</button>
<button class="play-pause-btn" id="save-to-collection-btn" style="width: 48%;">Save</button>
</div>
</div>
`;
// Create dialog overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '999';
overlay.innerHTML = dialogHtml;
document.body.appendChild(overlay);
const existingCollectionsSelect = overlay.querySelector(
'#existing-collections',
);
const newSaveTitleInput = overlay.querySelector('#new-save-title');
const createCollectionBtn = overlay.querySelector(
'#create-collection-btn',
);
const saveToCollectionBtn = overlay.querySelector(
'#save-to-collection-btn',
);
const cancelSaveBtn = overlay.querySelector('#cancel-save-btn');
if (savedTrimInfo.title) {
newSaveTitleInput.value = savedTrimInfo.title;
}
// Save to collection
saveToCollectionBtn.addEventListener('click', async () => {
const newTitle = document
.getElementById('new-save-title')
.value.trim();
const settings = await ipcRenderer.invoke('load-settings');
const selectedCollection = existingCollectionsSelect.value;
if (!selectedCollection) {
alert('Please select or create a collection');
return;
}
try {
await ipcRenderer.invoke(
'delete-old-file',
settings.outputFolder,
globalState.currentSection,
savedTrimInfo.title,
);
await ipcRenderer.invoke(
'save-trimmed-file',
path.basename(filePath),
globalState.currentSection,
selectedCollection,
instanceState.trimStart,
instanceState.trimEnd,
newTitle,
);
const saveResult = await ipcRenderer.invoke(
'save-trimmed-audio',
{
originalFilePath: filePath,
outputFolder: settings.outputFolder,
collectionName: selectedCollection,
title: newTitle,
trimStart: instanceState.trimStart,
trimEnd: instanceState.trimEnd,
},
);
if (saveResult.success) {
// Close save dialog
// Remove dialog
document.body.removeChild(overlay);
trimmerContainer.remove();
await loadCollectionFiles(globalState.currentSection);
await populateCollectionsList();
// Optional: Show success message
//alert(`Trimmed audio saved to ${saveResult.filePath}`);
} else {
alert(`Failed to save trimmed audio: ${saveResult.error}`);
}
// Refresh the view
} catch (error) {
alert('Error saving file: ' + error.message);
}
});
// Cancel button
cancelSaveBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
} catch (error) {
console.error('Error creating save dialog:', error);
}
});
deletebutton.addEventListener('click', async () => {
// Create confirmation dialog
const confirmDelete = confirm(
`Are you sure you want to delete this audio file?\nThis will remove the original file and any trimmed versions.`,
);
if (confirmDelete) {
try {
// Delete original file
await ipcRenderer.invoke('delete-file', filePath);
// Remove from UI
trimmerContainer.remove();
// Optional: Notify user
alert('File deleted successfully');
// Refresh the current section view
await loadCollectionFiles(globalState.currentSection);
await populateCollectionsList();
} catch (error) {
console.error('Error deleting file:', error);
}
}
});
});
return trimmerContainer;
}
// Initial load of untrimmed files and collections
await loadCollectionFiles('untrimmed');
await populateCollectionsList();
// Listen for new untrimmed files
ipcRenderer.on('new-untrimmed-file', async (event, filePath) => {
// Refresh the untrimmed section
await loadCollectionFiles('untrimmed');
await populateCollectionsList();
});
// Periodic refresh
setInterval(async () => {
await populateCollectionsList();
await loadCollectionFiles(globalState.currentSection);
}, 5000);
});
// Add collection button handler
document
.getElementById('add-collection-btn')
.addEventListener('click', async () => {
try {
// Create a dialog to input new collection name
const dialogHtml = `
<div id="new-collection-dialog" style="
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #2a2a2a;
padding: 0px 10px 10px 10px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1000;
">
<h4>Create New Collection</h4>
<input
type="text"
id="new-collection-input"
placeholder="Enter collection name"
style="width: 100%; align-self: center; padding: 10px; margin-bottom: 10px;"
>
<div style="display: flex; justify-content: space-between;">
<button id="create-collection-cancel-btn" class="play-pause-btn" style="width: 48%; ">Cancel</button>
<button id="create-collection-confirm-btn" class="play-pause-btn" style="width: 48%; ">Create</button>
</div>
</div>
`;
// Create dialog overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '999';
overlay.innerHTML = dialogHtml;
document.body.appendChild(overlay);
const newCollectionInput = overlay.querySelector('#new-collection-input');
const createCollectionConfirmBtn = overlay.querySelector(
'#create-collection-confirm-btn',
);
const createCollectionCancelBtn = overlay.querySelector(
'#create-collection-cancel-btn',
);
// Create collection when confirm button is clicked
createCollectionConfirmBtn.addEventListener('click', async () => {
const newCollectionName = newCollectionInput.value.trim();
if (newCollectionName) {
try {
await ipcRenderer.invoke('add-new-collection', newCollectionName);
// Remove dialog
document.body.removeChild(overlay);
// Refresh collections list
await populateCollectionsList();
} catch (error) {
// Show error in the dialog
const errorDiv = document.createElement('div');
errorDiv.textContent = error.message;
errorDiv.style.color = 'red';
errorDiv.style.marginTop = '10px';
overlay.querySelector('div').appendChild(errorDiv);
}
} else {
// Show error if input is empty
const errorDiv = document.createElement('div');
errorDiv.textContent = 'Collection name cannot be empty';
errorDiv.style.color = 'red';
errorDiv.style.marginTop = '10px';
overlay.querySelector('div').appendChild(errorDiv);
}
});
// Cancel button closes the dialog
createCollectionCancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
// Focus the input when dialog opens
newCollectionInput.focus();
} catch (error) {
console.error('Error creating new collection dialog:', error);
}
});

View File

@ -1,355 +0,0 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #1e1e1e;
height: 100vh;
overflow: hidden;
color: #ffffffd3;
}
.titlebar {
height: 30px;
background: #262626;
-webkit-app-region: drag;
color: white;
display: flex;
justify-content: center;
align-items: center;
border: none;
box-shadow: none;
}
.app-container {
display: flex;
height: calc(100vh);
margin-top: 0px;
}
.sidebar {
width: 250px;
background-color: #1e1e1e;
border-right: 1px solid #303030;
padding: 5px 20px 20px 20px;
overflow-y: auto;
}
.sidebar-section {
margin-bottom: 20px;
}
.sidebar-section h3 {
margin-bottom: 10px;
border-bottom: 1px solid #393939;
padding-bottom: 5px;
}
.sidebar-section .add-collection-btn {
width: 100%;
padding: 10px;
margin-top: 10px;
background-color: #6e44ba;
border: none;
border-radius: 5px;
cursor: pointer;
color: #ffffffd3;
}
.sidebar-section .add-collection-btn:hover {
background-color: #4f3186;
}
.section-item, .collection-item {
padding: 10px;
cursor: pointer;
transition: background-color 0.2s;
margin-bottom: 3px;
border-radius: 5px;
}
.section-item:hover, .collection-item:hover {
background-color: #303030;
}
.section-item.active, .collection-item.active {
background-color: rgba(110, 68, 186, 0.3);
color: #ccb1ff;
font-weight: bold;
}
.main-content {
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.audio-trimmers-section {
background-color: #1e1e1e;
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.audio-trimmers-list {
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.audio-trimmers-list::-webkit-scrollbar { /* WebKit */
width: 0;
height: 0;
}
.audio-trimmer-item {
position: relative;
background-color: #1e1e1e;
border-radius: 8px;
padding: 15px;
margin-bottom: 0px;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.audio-trimmer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.audio-trimmer-title-container {
display: flex;
flex-direction: column;
}
.audio-trimmer-title {
font-weight: bold;
font-size: 18px;
margin-bottom: 5px;
}
.audio-trimmer-filename {
color: #888;
font-weight: regular;
font-size: 12px;
}
.audio-trimmer-controls {
display: flex;
gap: 5px;
}
.waveform-container {
width: 100%;
margin-bottom: 20px;
overflow-x: hidden;
}
.waveform {
overflow-x: hidden;
}
.audio-controls {
display: flex;
gap: 5px;
}
.trim-info {
display: flex;
justify-content: space-between;
/* margin-bottom: 20px; */
}
.trim-time {
font-size: 14px;
color: #666;
}
.play-pause-btn, .save-trim {
background-color: #6e44ba;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: #ffffffd3;
}
.play-pause-btn:hover, .save-trim:hover {
background-color: #4f3186;
}
.play-pause-btn svg, .save-trim svg {
width: 20px;
height: 20px;
fill: white;
}
select {
padding: 10px;
border: 1px solid #ccc;
background-color: #383838;
border-radius: 5px;
border-color:#303030;
color: #ffffffd3;
box-sizing: border-box;
}
input{
padding: 10px;
border: 1px solid #ccc;
background-color: #383838;
border-radius: 5px;
border-color:#303030;
color: #ffffffd3;
box-sizing: border-box;
}
input:focus {
border-color: #303030;
outline: none;
}
select:focus {
border-color: #303030;
outline: none;
}
select:active {
border-color: #303030;
outline: none;
}
/* Settings Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
position: relative;
top: 50%;
transform: translateY(-50%);
background-color: #2a2a2a;
margin: auto;
padding: 20px;
border-radius: 10px;
width: 500px;
color: #ffffffd3;
}
.settings-group {
margin-bottom: 20px;
}
.settings-group label {
display: block;
margin-bottom: 10px;
}
.close-modal {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close-modal:hover {
color: white;
}
#save-settings {
width: 100%;
padding: 10px;
background-color: #6e44ba;
color: #ffffffd3;
border: none;
border-radius: 5px;
cursor: pointer;
}
#save-settings:hover {
background-color: #4f3186;
}
#select-output-folder {
width: 15%;
height: 36px;
padding: 10px;
background-color: #6e44ba;
color: #ffffffd3;
border: none;
border-radius: 5px;
cursor: pointer;
display: inline;
}
#select-output-folder:hover {
background-color: #4f3186;
}
#input-device {
width: 100%;
padding: 10px;
}
#output-folder {
width: 84%;
padding: 10px;
}
.nav-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
}
.nav-btn svg {
width: 24px;
height: 24px;
fill: #ffffffd3;
}
/* Zoom controls styling */
.zoom-controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.zoom-controls button {
width: 30px;
height: 30px;
background-color: #f0f0f0;
border: 1px solid #ccc;
cursor: pointer;
}
.zoom-controls .zoom-slider {
flex-grow: 1;
}
#recording-length, #osc-port {
width: 20%;
}

View File

@ -2,7 +2,7 @@ import { createSlice, configureStore } from '@reduxjs/toolkit';
import { ClipMetadata, MetadataState } from './types';
const initialState: MetadataState = {
collections: {},
collections: [],
};
const metadataSlice = createSlice({
name: 'metadata',
@ -13,32 +13,87 @@ const metadataSlice = createSlice({
},
setCollections(state, action) {
const { collection, newMetadata } = action.payload;
state.collections[collection] = newMetadata;
const index = state.collections.findIndex(
(col) => col.name === collection,
);
if (index !== -1) {
state.collections[index] = newMetadata;
}
},
addCollection(state, action) {
const name = action.payload;
if (!state.collections.find((col) => col.name === name)) {
state.collections.push({ name, id: Date.now(), clips: [] });
}
},
editClip(state, action) {
const { collection, clip } = action.payload;
const clips = state.collections[collection];
const collectionState = state.collections.find(
(col) => col.name === collection,
);
// console.log('Editing clip in collection:', collection, clip);
if (clips) {
const index = clips.findIndex((c) => c.filename === clip.filename);
if (collectionState) {
const index = collectionState.clips.findIndex(
(c) => c.filename === clip.filename,
);
if (index !== -1) {
clips[index] = clip;
collectionState.clips[index] = clip;
}
}
},
deleteClip(state, action) {
const { collection, clip } = action.payload;
const collectionState = state.collections.find(
(col) => col.name === collection,
);
if (collectionState) {
collectionState.clips = collectionState.clips.filter(
(c) => c.filename !== clip.filename,
);
}
},
moveClip(state, action) {
const { sourceCollection, targetCollection, clip } = action.payload;
const sourceState = state.collections.find(
(col) => col.name === sourceCollection,
);
const targetState = state.collections.find(
(col) => col.name === targetCollection,
);
if (sourceState && targetState) {
sourceState.clips = sourceState.clips.filter(
(c) => c.filename !== clip.filename,
);
targetState.clips.push(clip);
}
},
addNewClips(state, action) {
const { collections } = action.payload;
Object.keys(collections).forEach((collection) => {
if (!state.collections[collection]) {
state.collections[collection] = [];
const collectionState = state.collections.find(
(col) => col.name === collection,
);
if (!collectionState) {
state.collections.push({
name: collection,
id: Date.now(),
clips: [],
});
}
const existingFilenames = new Set(
state.collections[collection].map((clip) => clip.filename),
state.collections
.find((col) => col.name === collection)
?.clips.map((clip) => clip.filename) || [],
);
const newClips = collections[collection].filter(
(clip: ClipMetadata) => !existingFilenames.has(clip.filename),
);
state.collections[collection].push(...newClips);
// const collectionState = state.collections.find(
// (col) => col.name === collection,
// );
if (collectionState) {
collectionState.clips.push(...newClips);
}
});
},
},
@ -58,5 +113,6 @@ export type RootState = ReturnType<AppStore['getState']>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch'];
export const { setCollections, addNewClips } = metadataSlice.actions;
export const { setCollections, addNewClips, addCollection } =
metadataSlice.actions;
export default metadataSlice.reducer;

View File

@ -12,6 +12,12 @@ export interface ClipMetadata {
playbackType: PlaybackType;
}
export interface MetadataState {
collections: Record<string, ClipMetadata[]>;
export interface CollectionState {
name: string;
id: number;
clips: ClipMetadata[];
}
export interface MetadataState {
collections: CollectionState[];
}

View File

@ -1,42 +1,152 @@
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Provider } from 'react-redux';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
// import 'tailwindcss/tailwind.css';
import icon from '../../assets/icon.svg';
import './App.css';
import ClipList from './components/ClipList';
import { useAppDispatch } from './hooks';
import { useAppDispatch, useAppSelector } from './hooks';
import { store } from '../redux/main';
function MainPage() {
const [collection, setCollection] = useState<string | null>('Uncategorized');
const dispatch = useAppDispatch();
const collections = useAppSelector((state) =>
state.collections.map((col) => col.name),
);
const [selectedCollection, setSelectedCollection] = useState<string>(
collections[0] || 'Uncategorized',
);
const [newCollectionOpen, setNewCollectionOpen] = useState(false);
const [newCollectionName, setNewCollectionName] = useState<string>('');
useEffect(() => {
const fetchMetadata = async () => {
try {
const response = await fetch('http://localhost:5010/meta');
const data = await response.json();
// console.log('Fetched collections:', data.collections);
dispatch({ type: 'metadata/addNewClips', payload: data });
dispatch({ type: 'metadata/setAllData', payload: data });
} catch (error) {
console.error('Error fetching metadata:', error);
}
};
fetchMetadata();
const intervalId = setInterval(fetchMetadata, 5000);
return () => clearInterval(intervalId);
}, [dispatch]);
// 1. Set up the interval
const intervalId = setInterval(async () => {
fetchMetadata();
}, 5000); // 1000 milliseconds delay
useEffect(() => {
// Update selected collection if collections change
if (collections.length > 0 && !collections.includes(selectedCollection)) {
setSelectedCollection(collections[0]);
}
}, [collections, selectedCollection]);
// 2. Return a cleanup function to clear the interval when the component unmounts
return () => {
clearInterval(intervalId);
};
}, [dispatch]); //
return <ClipList collection={collection} />;
const handleNewCollectionSave = () => {
if (
newCollectionName.trim() &&
!collections.includes(newCollectionName.trim())
) {
dispatch({
type: 'metadata/addCollection',
payload: newCollectionName.trim(),
});
setSelectedCollection(newCollectionName.trim());
fetch('http://localhost:5010/meta/collections/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCollectionName.trim() }),
})
.then((res) => res.json())
.catch((err) => console.error('Error creating collection:', err));
}
setNewCollectionOpen(false);
setNewCollectionName('');
};
return (
<div className="min-h-screen min-w-screen bg-midnight text-offwhite relative">
{/* Left Nav Bar - sticky */}
<Dialog
open={newCollectionOpen}
onClose={() => setNewCollectionOpen(false)}
slotProps={{
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
}}
>
<DialogTitle>Edit Clip Name</DialogTitle>
<DialogContent>
<input
autoFocus
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center"
type="text"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleNewCollectionSave();
}}
aria-label="New collection name"
/>
</DialogContent>
<DialogActions>
<button
type="button"
onClick={() => {
setNewCollectionOpen(false);
setNewCollectionName('');
}}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Cancel
</button>
<button
type="button"
onClick={handleNewCollectionSave}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Save
</button>
</DialogActions>
</Dialog>
<nav
className="w-48 h-screen sticky top-0 left-0 border-r border-neutral-700 bg-midnight flex flex-col p-2"
style={{ position: 'absolute', top: 0, left: 0 }}
>
<div className="p-4 font-bold text-lg">Collections</div>
<li>
<button
type="button"
className="w-full rounded text-left px-4 py-2 mb-2 bg-plumDark text-offwhite font-semibold hover:bg-plum"
onClick={() => setNewCollectionOpen(true)}
>
+ Create Collection
</button>
</li>
<ul className="flex-1 overflow-y-auto">
{collections.map((col) => (
<li key={col}>
<button
type="button"
className={`w-full rounded text-left px-4 py-2 mt-2 hover:bg-plumDark ${selectedCollection === col ? 'bg-plum text-offwhite font-semibold' : 'text-offwhite'}`}
onClick={() => setSelectedCollection(col)}
>
{col}
</button>
</li>
))}
</ul>
</nav>
{/* Main Content */}
<div
className="absolute top-0 ml-[12rem] w-[calc(100%-12rem)] h-screen overflow-y-auto p-4"
// style={{ left: '12rem', width: 'calc(100% - 12rem)' }}
>
<ClipList collection={selectedCollection} />
</div>
</div>
);
}
export default function App() {

View File

@ -10,41 +10,42 @@ import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import { useWavesurfer } from '@wavesurfer/react';
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import PauseIcon from '@mui/icons-material/Pause';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import DeleteIcon from '@mui/icons-material/Delete';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
import { ClipMetadata } from '../../redux/types';
import { useAppSelector } from '../hooks';
export interface AudioTrimmerProps {
filename: string;
metadata: ClipMetadata;
onSave?: (metadata: ClipMetadata) => void;
onDelete?: () => void;
onDelete?: (metadata: ClipMetadata) => void;
onMove?: (newCollection: string, metadata: ClipMetadata) => void;
}
export default function AudioTrimmer({
filename,
metadata,
onSave,
onDelete,
onMove,
}: AudioTrimmerProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filename });
useSortable({ id: metadata.filename });
const metadata = useAppSelector((state) => {
const clip = Object.values(state.collections)
.flat()
.find((c) => c.filename === filename);
return clip ?? ({ filename, name: 'Unknown Clip' } as ClipMetadata);
});
// Dialog state for editing name
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [nameInput, setNameInput] = useState<string>(metadata.name);
const collectionNames = useAppSelector((state) =>
state.collections.map((col) => col.name),
);
useEffect(() => {
setNameInput(metadata.name);
@ -68,7 +69,7 @@ export default function AudioTrimmer({
const plugins = useMemo(
() => [
Regions.create(),
RegionsPlugin.create(),
ZoomPlugin.create({
scale: 0.25,
}),
@ -282,7 +283,7 @@ export default function AudioTrimmer({
>
{metadata.name}
</span>
<text className="text-sm text-neutral-500">{fileBaseName}</text>
<span className="text-sm text-neutral-500">{fileBaseName}</span>
</div>
<Dialog
open={editDialogOpen}
@ -294,6 +295,7 @@ export default function AudioTrimmer({
<DialogTitle>Edit Clip Name</DialogTitle>
<DialogContent>
<input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center"
type="text"
@ -302,6 +304,7 @@ export default function AudioTrimmer({
onKeyDown={(e) => {
if (e.key === 'Enter') handleDialogSave();
}}
onFocus={(event) => event.target.select()}
aria-label="Edit clip name"
/>
</DialogContent>
@ -323,6 +326,38 @@ export default function AudioTrimmer({
</DialogActions>
</Dialog>
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
slotProps={{
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
}}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this clip?
</DialogContent>
<DialogActions>
<button
type="button"
onClick={() => setDeleteDialogOpen(false)}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Cancel
</button>
<button
type="button"
onClick={() => {
setDeleteDialogOpen(false);
if (onDelete) onDelete(metadataRef.current);
}}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Delete
</button>
</DialogActions>
</Dialog>
<div className="flex justify-end">
<button
type="button"
@ -331,16 +366,36 @@ export default function AudioTrimmer({
>
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
</button>
<div className="relative inline-block">
<button
type="button"
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
onClick={() => setDropdownOpen((prev) => !prev)}
>
{dropdownOpen ? <ArrowDownwardIcon /> : <ArrowForwardIcon />}
</button>
{dropdownOpen && (
<div className="absolute z-10 mt-2 w-40 bg-midnight rounded-md shadow-lg">
{collectionNames.map((name) => (
<button
key={name}
type="button"
className="block w-full text-left px-4 py-2 text-white hover:bg-plumDark"
onClick={() => {
setDropdownOpen(false);
if (onMove) onMove(name, metadata);
}}
>
{name}
</button>
))}
</div>
)}
</div>
<button
type="button"
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
>
<ArrowForwardIcon />
</button>
<button
type="button"
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
onClick={onDelete}
onClick={() => setDeleteDialogOpen(true)}
>
<DeleteIcon />
</button>

View File

@ -1,3 +1,4 @@
import React from 'react';
import {
DndContext,
closestCenter,
@ -21,7 +22,8 @@ export interface ClipListProps {
export default function ClipList({ collection }: ClipListProps) {
const metadata = useAppSelector(
(state) => state.collections[collection] || [],
(state) =>
state.collections.find((col) => col.name === collection) || { clips: [] },
);
const dispatch = useAppDispatch();
@ -29,14 +31,19 @@ export default function ClipList({ collection }: ClipListProps) {
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
async function handleDragEnd(event) {
async function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = metadata.findIndex(
const oldIndex = metadata.clips.findIndex(
(item) => item.filename === active.id,
);
const newIndex = metadata.findIndex((item) => item.filename === over.id);
const newMetadata = arrayMove(metadata, oldIndex, newIndex);
const newIndex = metadata.clips.findIndex(
(item) => item.filename === over.id,
);
const newMetadata = {
...metadata,
clips: arrayMove(metadata.clips, oldIndex, newIndex),
};
console.log('New order:', newMetadata);
dispatch({
type: 'metadata/setCollections',
@ -52,7 +59,7 @@ export default function ClipList({ collection }: ClipListProps) {
},
body: JSON.stringify({
name: collection,
clips: newMetadata,
clips: newMetadata.clips,
}),
},
);
@ -66,6 +73,47 @@ export default function ClipList({ collection }: ClipListProps) {
}
}
async function handleDelete(meta: ClipMetadata) {
dispatch({
type: 'metadata/deleteClip',
payload: { collection, clip: meta },
});
fetch('http://localhost:5010/meta/collection/clips/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: collection,
clip: meta,
}),
})
.then((res) => res.json())
.catch((err) => console.error('Error deleting clip:', err));
console.log('Deleting clip:', meta);
}
async function handleClipMove(targetCollection: string, meta: ClipMetadata) {
console.log('Moving clip:', meta, 'to collection:', targetCollection);
dispatch({
type: 'metadata/moveClip',
payload: { sourceCollection: collection, targetCollection, clip: meta },
});
fetch('http://localhost:5010/meta/collection/clips/move', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sourceCollection: collection,
targetCollection,
clip: meta,
}),
})
.then((res) => res.json())
.catch((err) => console.error('Error moving clip:', err));
}
async function handleClipSave(meta: ClipMetadata) {
try {
dispatch({
@ -85,7 +133,7 @@ export default function ClipList({ collection }: ClipListProps) {
}),
},
);
const data = await response.json();
await response.json();
// console.log('handle clip save return:', data.collections);
dispatch({
type: 'metadata/editClip',
@ -97,24 +145,42 @@ export default function ClipList({ collection }: ClipListProps) {
}
return (
<div className="min-h-screen flex flex-col justify-center bg-midnight text-offwhite">
<div className="min-h-full flex flex-col justify-start bg-midnight text-offwhite">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
// eslint-disable-next-line react/jsx-no-bind
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext
items={metadata.map((item) => item.filename)}
items={metadata.clips.map((item) => item.filename)}
strategy={verticalListSortingStrategy}
>
{metadata.map((trimmer) => (
{metadata.clips.map((trimmer, idx) => (
<React.Fragment key={trimmer.filename}>
<AudioTrimmer
metadata={trimmer}
onSave={handleClipSave}
onDelete={handleDelete}
onMove={handleClipMove}
/>
{(idx + 1) % 10 === 0 && idx !== metadata.clips.length - 1 && (
<div className="my-4 border-t border-gray-500">
<p className="text-center text-sm text-gray-400">
-- Page {Math.ceil((idx + 1) / 10) + 1} --
</p>
</div>
)}
</React.Fragment>
))}
{/* {metadata.map((trimmer) => (
<AudioTrimmer
key={trimmer.filename}
filename={trimmer.filename}
onSave={handleClipSave}
/>
))}
))} */}
</SortableContext>
</DndContext>
</div>