10 Commits

152 changed files with 32248 additions and 7779 deletions

1
audio-service/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
recordings/

1
audio-service/build.bat Normal file
View File

@ -0,0 +1 @@
python -m venv venv

View File

@ -0,0 +1,34 @@
[
{
"name": "Uncategorized",
"id": 0,
"clips": []
},
{
"name": "Test",
"id": 1,
"clips": []
},
{
"name": "New",
"id": 2,
"clips": [
{
"endTime": 30,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260220_193822.wav",
"name": "Pee pee\npoo poo",
"playbackType": "playStop",
"startTime": 27.756510985786615,
"volume": 1
},
{
"endTime": 28.597210828548004,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260220_200442.wav",
"name": "Clip 20260220_200442",
"playbackType": "playStop",
"startTime": 26.1853978671042,
"volume": 1
}
]
}
]

View File

@ -3,4 +3,5 @@ numpy==1.22.3
python-osc==1.9.3 python-osc==1.9.3
scipy==1.10.1 scipy==1.10.1
comtypes==1.4.8 comtypes==1.4.8
pycaw==20240210 pycaw==20240210
Flask==3.1.2

View File

@ -0,0 +1,10 @@
{
"input_device": {
"default_samplerate": 44100.0,
"index": 1,
"max_input_channels": 8,
"name": "VM Mic mix (VB-Audio Voicemeete"
},
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings",
"recording_length": 30
}

Binary file not shown.

Binary file not shown.

View File

@ -3,9 +3,18 @@ import numpy as np
import os import os
from datetime import datetime from datetime import datetime
import scipy.io.wavfile as wavfile import scipy.io.wavfile as wavfile
from metadata_manager import MetaDataManager
class AudioRecorder: class AudioRecorder:
def __init__(self, duration=30, sample_rate=44100, channels=2, recordings_dir='recordings'): _instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
print("Creating new AudioRecorder instance")
cls._instance = super().__new__(cls)
cls._instance.init()
return cls._instance
def init(self):
""" """
Initialize audio recorder with configurable parameters. Initialize audio recorder with configurable parameters.
@ -13,12 +22,39 @@ class AudioRecorder:
:param sample_rate: Audio sample rate (if None, use default device sample rate) :param sample_rate: Audio sample rate (if None, use default device sample rate)
:param channels: Number of audio channels :param channels: Number of audio channels
""" """
print(f"Initializing AudioRecorder")
self.duration = 30
self.sample_rate = 44100
self.channels = 2
self.buffer = np.zeros((int(self.duration * self.sample_rate), self.channels), dtype=np.float32)
self.recordings_dir = "recordings"
self.stream = sd.InputStream(
samplerate=self.sample_rate,
channels=self.channels,
callback=self.record_callback
)
def refresh_stream(self):
"""
Refresh the audio stream with updated parameters.
"""
was_active = self.stream.active
if was_active:
self.stream.stop()
self.buffer = np.zeros((int(self.duration * self.sample_rate), self.channels), dtype=np.float32)
self.stream = sd.InputStream(
samplerate=self.sample_rate,
channels=self.channels,
callback=self.record_callback
)
if was_active:
self.stream.start()
self.duration = duration
self.sample_rate = sample_rate
self.channels = channels
self.buffer = np.zeros((int(duration * sample_rate), channels), dtype=np.float32)
self.recordings_dir = recordings_dir
def record_callback(self, indata, frames, time, status): def record_callback(self, indata, frames, time, status):
""" """
@ -58,18 +94,63 @@ class AudioRecorder:
# Write buffer to file # Write buffer to file
wavfile.write(filename, int(self.sample_rate), audio_data_int16) wavfile.write(filename, int(self.sample_rate), audio_data_int16)
meta = MetaDataManager()
clip_metadata = {
"filename": filename,
"name": f"Clip {timestamp}",
"playbackType":"playStop",
"volume": 1.0,
}
return filename meta.add_clip_to_collection("Uncategorized", clip_metadata )
return clip_metadata
def set_buffer_duration(self, duration):
"""
Set the duration of the audio buffer.
:param duration: New buffer duration in seconds
"""
self.duration = duration
self.buffer = np.zeros((int(duration * self.sample_rate), self.channels), dtype=np.float32)
def set_recording_directory(self, directory):
"""
Set the directory where recordings will be saved.
:param directory: Path to the recordings directory
"""
self.recordings_dir = directory
def start_recording(self): def start_recording(self):
""" """
Start continuous audio recording with circular buffer. Start continuous audio recording with circular buffer.
""" """
if(self.stream.active):
print("Already recording")
return
print('number of channels', self.channels) print('number of channels', self.channels)
stream = sd.InputStream(
samplerate=self.sample_rate, self.stream.start()
channels=self.channels,
callback=self.record_callback def stop_recording(self):
) """
stream.start() Stop continuous audio recording with circular buffer.
return stream """
if(not self.stream.active):
print("Already stopped")
return
self.stream.stop()
def is_recording(self):
"""
Check if the audio stream is currently active.
:return: True if recording, False otherwise
"""
return self.stream.active

View File

@ -1,12 +0,0 @@
from pythonosc.udp_client import SimpleUDPClient
import sys
ip = "127.0.0.1"
port = 5005
client = SimpleUDPClient(ip, port) # Create client
# client.send_message("/record/start", 0)
#sleep(5)
client.send_message(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else 0)
#sleep(5)
# client.send_message("/record/stop", 0)

View File

@ -1,92 +1,69 @@
import argparse import argparse
import os import os
import sys import sys
from osc_server import OSCRecordingServer
from audio_recorder import AudioRecorder from audio_recorder import AudioRecorder
from windows_audio import WindowsAudioManager from windows_audio import WindowsAudioManager
import sounddevice as sd import sounddevice as sd
from metadata_manager import MetaDataManager
from settings import SettingsManager
from flask import Flask
from flask_cors import CORS
from routes.recording import recording_bp
from routes.device import device_bp
from routes.metadata import metadata_bp
from routes.settings import settings_bp
from flask_socketio import SocketIO
import threading
app = Flask(__name__)
CORS(app)
# socketio = SocketIO(app, cors_allowed_origins="*")
# CORS(socketio)
def main(): def main():
# Create argument parser # Create argument parser
parser = argparse.ArgumentParser(description='Audio Recording Service') parser = argparse.ArgumentParser(description='Audio Recording Service')
# Input device argument
parser.add_argument('--input-device',
type=str,
help='Name or index of the input audio device',
default=None)
# Recording length argument
parser.add_argument('--recording-length',
type=float,
help='Maximum recording length in seconds',
default=30.0)
# Recording save path argument
parser.add_argument('--save-path',
type=str,
help='Directory path to save recordings',
default=os.path.join(os.path.dirname(__file__), 'recordings'))
# OSC port argument # OSC port argument
parser.add_argument('--osc-port', parser.add_argument('--osc-port',
type=int, type=int,
help='OSC server port number', help='OSC server port number',
default=5005) default=5010)
# Parse arguments # Parse arguments
args = parser.parse_args() args = parser.parse_args()
audio_manager = WindowsAudioManager()
settings = SettingsManager()
# Ensure save path exists # Ensure save path exists
os.makedirs(args.save_path, exist_ok=True) os.makedirs(settings.get_settings('save_path'), exist_ok=True)
audio_manager=WindowsAudioManager()
# Handle input device selection
input_device = None # Register blueprints
devices = audio_manager.list_audio_devices('input') app.register_blueprint(recording_bp)
if args.input_device: app.register_blueprint(device_bp)
try: app.register_blueprint(metadata_bp)
# Try to convert to integer first (for device index) app.register_blueprint(settings_bp)
input_device = int(args.input_device) app.run(host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True)
except ValueError: # socketio.run(app, host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True)
# If not an integer, treat as device name
print(devices)
for i, device in enumerate(devices):
if args.input_device.lower() in device['name'].lower():
input_device = device['index']
print(f"Using input device: {device['name']}")
break
# Create AudioRecorder with specified parameters
recorder = AudioRecorder(
duration=args.recording_length,
recordings_dir=args.save_path,
# channels=min(2, devices[input_device]['max_input_channels']),
)
# Create OSC server with specified port
osc_server = OSCRecordingServer(
recorder=recorder,
port=args.osc_port,
audio_manager=audio_manager
)
osc_server.set_audio_device(None, str(input_device))
osc_server.start_recording(None)
# Run the OSC server # Run the OSC server
try: try:
print(f"Starting OSC Recording Server on port {args.osc_port}") print(f"Starting OSC Recording Server on port {args.osc_port}")
print(f"Recording save path: {args.save_path}")
print(f"Max recording length: {args.recording_length} seconds")
osc_server.run_server()
# osc_server.run_server()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nServer stopped by user.") print("\nServer stopped by user.")
except Exception as e: except Exception as e:
print(f"Error starting server: {e}") print(f"Error starting server: {e}")
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,20 @@
{
"Uncategorized": [
{
"endTime": 12.489270386266055,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings\\audio_capture_20260214_133540.wav",
"name": "Clip 20260214_133540",
"playbackType": "playStop",
"startTime": 10.622317596566523,
"volume": 1
},
{
"endTime": 6.824034334763957,
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings\\audio_capture_20260214_133137.wav",
"name": "Clip 20260214_133137",
"playbackType": "playStop",
"startTime": 3.7982832618025753,
"volume": 1
}
]
}

View File

@ -0,0 +1,100 @@
import os
import json
class MetaDataManager:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.init()
return cls._instance
def init(self):
# read metadata file from executing directory
self.metadata_file = os.path.join(os.getcwd(), "metadata.json")
if os.path.exists(self.metadata_file):
with open(self.metadata_file, "r") as f:
self.collections = json.load(f)
else:
self.collections = {}
if(collections := next((c for c in self.collections if c.get("name") == "Uncategorized"), None)) is None:
self.collections.append({"name": "Uncategorized", "id": 0, "clips": []})
self.save_metadata()
def create_collection(self, name):
if any(c.get("name") == name for c in self.collections):
raise ValueError(f"Collection '{name}' already exists.")
new_id = max((c.get("id", 0) for c in self.collections), default=0) + 1
self.collections.append({"name": name, "id": new_id, "clips": []})
self.save_metadata()
def delete_collection(self, name):
collection = next((c for c in self.collections if c.get("name") == name), None)
if collection is None:
raise ValueError(f"Collection '{name}' does not exist.")
self.collections.remove(collection)
self.save_metadata()
def add_clip_to_collection(self, collection_name, clip_metadata):
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
if collection is None:
raise ValueError(f"Collection '{collection_name}' does not exist.")
collection["clips"].append(clip_metadata)
self.save_metadata()
def remove_clip_from_collection(self, collection_name, clip_metadata):
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
if collection is None:
raise ValueError(f"Collection '{collection_name}' does not exist.")
# Remove all clips with the same file name as clip_metadata["file_name"]
in_list = any(clip.get("filename") == clip_metadata.get("filename") for clip in collection["clips"])
if not in_list:
raise ValueError(f"Clip with filename '{clip_metadata.get('filename')}' not found in collection '{collection_name}'.")
collection["clips"] = [
clip for clip in collection["clips"]
if clip.get("filename") != clip_metadata.get("filename")
]
self.save_metadata()
def move_clip_to_collection(self, source_collection, target_collection, clip_metadata):
self.remove_clip_from_collection(source_collection, clip_metadata)
self.add_clip_to_collection(target_collection, clip_metadata)
def edit_clip_in_collection(self, collection_name, new_clip_metadata):
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
if collection is None:
raise ValueError(f"Collection '{collection_name}' does not exist.")
# Find the index of the clip with the same file name as old_clip_metadata["file_name"]
index = next((i for i, clip in enumerate(collection["clips"]) if clip.get("filename") == new_clip_metadata.get("filename")), None)
if index is None:
raise ValueError(f"Clip with filename '{new_clip_metadata.get('filename')}' not found in collection '{collection_name}'.")
collection["clips"][index] = new_clip_metadata
self.save_metadata()
def get_collections(self):
return list(map(lambda c: {"name": c.get("name"), "id": c.get("id")}, self.collections))
def get_clips_in_collection(self, collection_name):
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
if collection is None:
raise ValueError(f"Collection '{collection_name}' does not exist.")
return collection["clips"]
def reorder_clips_in_collection(self, collection_name, new_order):
collection = next((c for c in self.collections if c.get("name") == collection_name), None)
if collection is None:
raise ValueError(f"Collection '{collection_name}' does not exist.")
existing_filenames = {clip.get("filename") for clip in collection["clips"]}
new_filenames = {clip.get("filename") for clip in new_order}
if not new_filenames.issubset(existing_filenames):
raise ValueError("New order contains clips that do not exist in the collection.")
collection["clips"] = new_order
self.save_metadata()
def save_metadata(self):
with open(self.metadata_file, "w") as f:
json.dump(self.collections, f, indent=4)

View File

@ -1,130 +0,0 @@
from pythonosc import dispatcher, osc_server
import threading
import sys
from audio_recorder import AudioRecorder
from windows_audio import WindowsAudioManager
class OSCRecordingServer:
def __init__(self, recorder, audio_manager, ip="127.0.0.1", port=5005):
"""
Initialize OSC server for audio recording triggers.
:param recorder: AudioRecorder instance
:param audio_manager: WindowsAudioManager instance
:param ip: IP address to bind OSC server
:param port: Port number for OSC server
"""
self.recorder = recorder
self.audio_manager = audio_manager
self.ip = ip
self.port = port
self._setup_dispatcher()
self.server_thread = None
def _setup_dispatcher(self):
"""
Set up OSC message dispatchers for different recording commands.
"""
self.osc_dispatcher = dispatcher.Dispatcher()
self.osc_dispatcher.map("/record/start", self.start_recording)
self.osc_dispatcher.map("/record/stop", self.stop_recording)
self.osc_dispatcher.map("/record/save", self.save_recording)
self.osc_dispatcher.map("/exit", self.exit_program)
self.osc_dispatcher.map("/device/set", self.set_audio_device) # New device set handler
self.osc_dispatcher.map("/device/list", self.list_audio_devices) # New device list handler
def start_recording(self, unused_addr, args=None):
"""
Start audio recording via OSC message.
"""
print("OSC: Starting audio recording")
self.recording_stream = self.recorder.start_recording()
def stop_recording(self, unused_addr, args=None):
"""
Stop active audio recording via OSC message.
"""
print("OSC: Stopping audio recording")
if hasattr(self, 'recording_stream'):
self.recording_stream.stop()
def save_recording(self, unused_addr, args=None):
"""
Save the current audio buffer via OSC message.
"""
print("OSC: Saving audio recording")
saved_file = self.recorder.save_last_n_seconds()
print(f"Saved recording to: {saved_file}")
def set_audio_device(self, unused_addr, device_index):
"""
Set the default input audio device via OSC message.
:param device_index: Index of the audio device to set
"""
try:
device_index = int(device_index)
print(f"OSC: Setting audio device to index {device_index}")
# Get the sample rate of the new device
sample_rate = self.audio_manager.set_default_input_device(device_index)
# Reinitialize recorder with new device's sample rate
self.recorder = AudioRecorder(
duration=self.recorder.duration,
sample_rate=sample_rate,
channels=self.recorder.channels,
recordings_dir=self.recorder.recordings_dir
)
print(f"Successfully set audio device to index {device_index} with sample rate {sample_rate}")
except Exception as e:
print(f"OSC: Error setting audio device - {e}")
def list_audio_devices(self, unused_addr, device_type='input'):
"""
List available audio devices via OSC message.
:param device_type: 'input' or 'output'
"""
try:
devices = self.audio_manager.list_audio_devices(device_type)
print(f"Available {device_type} devices:")
for idx, device in enumerate(devices):
print(f"Index {device['index']}: {device['name']}")
except Exception as e:
print(f"OSC: Error listing audio devices - {e}")
def exit_program(self, unused_addr, args=None):
"""
Gracefully exit the program via OSC message.
"""
print("OSC: Received exit command. Shutting down...")
if hasattr(self, 'recording_stream'):
self.recording_stream.stop()
if self.server_thread:
self.server.shutdown()
self.server_thread.join()
sys.exit(0)
def run_server(self):
"""
Start the OSC server in a separate thread.
"""
self.server = osc_server.ThreadingOSCUDPServer(
(self.ip, self.port),
self.osc_dispatcher
)
self.server_thread = threading.Thread(target=self.server.serve_forever)
self.server_thread.start()
return self.server_thread
def stop_server(self):
"""
Stop the OSC server.
"""
if hasattr(self, 'server'):
self.server.shutdown()

View File

@ -0,0 +1,37 @@
from flask import Blueprint, request, jsonify
from windows_audio import WindowsAudioManager
from audio_recorder import AudioRecorder
device_bp = Blueprint('device', __name__)
audio_manager = WindowsAudioManager()
recorder = AudioRecorder()
# @device_bp.route('/device/set', methods=['POST'])
# def set_audio_device():
# device_index = request.json.get('device_index')
# try:
# device_index = int(device_index)
# print(f'HTTP: Setting audio device to index {device_index}')
# sample_rate = audio_manager.set_default_input_device(device_index)
# recorder.sample_rate = sample_rate
# return jsonify({'status': 'device set', 'device_index': device_index, 'sample_rate': sample_rate})
# except Exception as e:
# return jsonify({'status': 'error', 'message': str(e)}), 400
@device_bp.route('/device/get', methods=['GET'])
def get_audio_device():
try:
device_info = audio_manager.get_default_device('input')
return jsonify({'status': 'success', 'device_info': device_info})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@device_bp.route('/device/list', methods=['GET'])
def list_audio_devices():
device_type = request.args.get('device_type', 'input')
try:
devices = audio_manager.list_audio_devices(device_type)
return jsonify({'status': 'success', 'devices': devices})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -0,0 +1,100 @@
from flask import Blueprint, request, jsonify
from metadata_manager import MetaDataManager
metadata_bp = Blueprint('metadata', __name__)
@metadata_bp.route('/meta', methods=['GET'])
def get_allmetadata():
meta_manager = MetaDataManager()
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
@metadata_bp.route('/meta/collections', methods=['GET'])
def get_collections():
meta_manager = MetaDataManager()
collections = meta_manager.get_collections()
return jsonify({'status': 'success', 'collections': collections})
@metadata_bp.route('/meta/collections/add', methods=['POST'])
def add_collection():
meta_manager = MetaDataManager()
collection_name = request.json.get('name')
try:
meta_manager.create_collection(collection_name)
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/<name>', methods=['GET'])
def get_clips_in_collection(name):
meta_manager = MetaDataManager()
collection_name = name
try:
clips = meta_manager.get_clips_in_collection(collection_name)
return jsonify({'status': 'success', 'clips': clips})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/reorder', methods=['POST'])
def reorder_clips_in_collection():
meta_manager = MetaDataManager()
collection_name = request.json.get('name')
new_order = request.json.get('clips')
try:
meta_manager.reorder_clips_in_collection(collection_name, new_order)
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/add', methods=['POST'])
def add_clip_to_collection():
meta_manager = MetaDataManager()
collection_name = request.json.get('name')
clip_metadata = request.json.get('clip')
try:
meta_manager.add_clip_to_collection(collection_name, clip_metadata)
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/remove', methods=['POST'])
def remove_clip_from_collection():
meta_manager = MetaDataManager()
collection_name = request.json.get('name')
clip_metadata = request.json.get('clip')
try:
meta_manager.remove_clip_from_collection(collection_name, clip_metadata)
clips = meta_manager.get_clips_in_collection(collection_name)
return jsonify({'status': 'success', 'clips': clips})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/move', methods=['POST'])
def move_clip_to_collection():
meta_manager = MetaDataManager()
sourceCollection = request.json.get('sourceCollection')
targetCollection = request.json.get('targetCollection')
clip_metadata = request.json.get('clip')
try:
meta_manager.move_clip_to_collection(sourceCollection, targetCollection, clip_metadata)
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@metadata_bp.route('/meta/collection/clips/edit', methods=['POST'])
def edit_clip_in_collection():
meta_manager = MetaDataManager()
collection_name = request.json.get('name')
clip_metadata = request.json.get('clip')
print(f"Received request to edit clip in collection '{collection_name}': {clip_metadata}")
try:
meta_manager.edit_clip_in_collection(collection_name, clip_metadata)
collections = meta_manager.collections
return jsonify({'status': 'success', 'collections': collections})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -0,0 +1,53 @@
from flask import Blueprint, request, jsonify
from audio_recorder import AudioRecorder
import os
recording_bp = Blueprint('recording', __name__)
@recording_bp.route('/record/start', methods=['POST'])
def start_recording():
recorder = AudioRecorder()
print('HTTP: Starting audio recording')
recorder.start_recording()
return jsonify({'status': 'recording started'})
@recording_bp.route('/record/stop', methods=['POST'])
def stop_recording():
recorder = AudioRecorder()
print('HTTP: Stopping audio recording')
recorder.stop_recording()
return jsonify({'status': 'recording stopped'})
@recording_bp.route('/record/save', methods=['POST'])
def save_recording():
recorder = AudioRecorder()
print('HTTP: Saving audio recording')
saved_file = recorder.save_last_n_seconds()
return jsonify({'status': 'recording saved', 'file': saved_file})
@recording_bp.route('/record/status', methods=['GET'])
def recording_status():
recorder = AudioRecorder()
print('HTTP: Checking recording status')
status = 'recording' if recorder.is_recording() else 'stopped'
return jsonify({'status': status})
@recording_bp.route('/record/delete', methods=['POST'])
def recording_delete():
filename = request.json.get('filename')
try:
os.remove(filename)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@recording_bp.route('/playback/start', methods=['POST'])
def playback_start():
print('HTTP: Starting audio playback')
try:
# os.remove(filename)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -0,0 +1,25 @@
from flask import Blueprint, request, jsonify
from settings import SettingsManager
settings_bp = Blueprint('settings', __name__)
@settings_bp.route('/settings', methods=['GET'])
def get_all_settings():
return jsonify({'status': 'success', 'settings': SettingsManager().get_all_settings()})
@settings_bp.route('/settings/<name>', methods=['GET'])
def get_setting(name):
value = SettingsManager().get_settings(name)
if value is not None:
return jsonify({'status': 'success', 'name': name, 'value': value})
else:
return jsonify({'status': 'error', 'message': f'Setting "{name}" not found'}), 404
@settings_bp.route('/settings/<name>', methods=['POST'])
def set_setting(name):
value = request.json.get('value')
if value is None:
return jsonify({'status': 'error', 'message': 'Value is required'}), 400
SettingsManager().set_settings(name, value)
return jsonify({'status': 'success', 'name': name, 'value': value})

View File

@ -0,0 +1,10 @@
{
"input_device": {
"index": 0,
"name": "Microsoft Sound Mapper - Input",
"max_input_channels": 2,
"default_samplerate": 44100.0
},
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\src\\recordings",
"recording_length": 15
}

View File

@ -0,0 +1,69 @@
import os
import json
from audio_recorder import AudioRecorder
from windows_audio import WindowsAudioManager
class SettingsManager:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.init()
return cls._instance
def init(self):
# read settings file from executing directory
self.settings_file = os.path.join(os.getcwd(), "settings.json")
if os.path.exists(self.settings_file):
with open(self.settings_file, "r") as f:
self.settings = json.load(f)
else:
self.settings = {
"input_device": None,
"save_path": os.path.join(os.getcwd(), "recordings"),
"recording_length": 15
}
audio_manager = WindowsAudioManager()
devices = audio_manager.list_audio_devices('input')
print(f"Available input devices: {self.settings}")
input = self.settings["input_device"]
#see if input device is in "devices", if not set to the first index
if input is not None and any(d['name'] == input["name"] for d in devices):
print(f"Using saved input device index: {input}")
else:
input = devices[0] if devices else None
self.settings["input_device"] = input
self.save_settings()
def get_settings(self, name):
# print(f"Getting setting '{name}': {self.settings}")
return self.settings.get(name, None)
def get_all_settings(self):
return self.settings
def set_settings(self, name, value):
self.settings[name] = value
self.save_settings()
def save_settings(self):
self.refresh_settings()
with open(self.settings_file, "w") as f:
json.dump(self.settings, f, indent=4)
def refresh_settings(self):
recorder = AudioRecorder()
# Update recorder parameters based on new setting
recorder.set_buffer_duration(self.get_settings('recording_length'))
recorder.recordings_dir = self.get_settings('save_path')
audio_manager = WindowsAudioManager()
audio_manager.set_default_input_device(self.get_settings('input_device')['index'])
recorder.refresh_stream()

View File

@ -7,7 +7,14 @@ from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
import json import json
class WindowsAudioManager: class WindowsAudioManager:
def __init__(self): _instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.init()
return cls._instance
def init(self):
""" """
Initialize Windows audio device and volume management. Initialize Windows audio device and volume management.
""" """
@ -42,8 +49,27 @@ class WindowsAudioManager:
} }
for dev in self.devices if dev['max_output_channels'] > 0 for dev in self.devices if dev['max_output_channels'] > 0
] ]
def get_default_device(self, kind='input'):
"""
Get the default audio device.
:param kind: 'input' or 'output'
:return: Default audio device information
"""
if kind == 'input':
dev = self.devices[self.default_input]
return [
{
'index': dev['index'],
'name': dev['name'],
'max_input_channels': dev['max_input_channels'],
'default_samplerate': dev['default_samplerate']
}
]
def set_default_input_device(self, device_index): def set_default_input_device(self, device_index):
if(device_index is None):
return self.get_current_input_device_sample_rate()
""" """
Set the default input audio device. Set the default input audio device.

12
electron-ui/.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@ -0,0 +1,7 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off"
}
}

View File

@ -0,0 +1,6 @@
const tailwindcss = require('@tailwindcss/postcss');
const autoprefixer = require('autoprefixer');
module.exports = {
plugins: [tailwindcss, autoprefixer],
};

View File

@ -0,0 +1,54 @@
/**
* Base webpack config used across other specific configs
*/
import webpack from 'webpack';
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
import webpackPaths from './webpack.paths';
import { dependencies as externals } from '../../release/app/package.json';
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
stats: 'errors-only',
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
compilerOptions: {
module: 'nodenext',
moduleResolution: 'nodenext',
},
},
},
},
],
},
output: {
path: webpackPaths.srcPath,
// https://github.com/webpack/webpack/issues/1114
library: { type: 'commonjs2' },
},
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: [webpackPaths.srcPath, 'node_modules'],
// There is no need to add aliases here, the paths in tsconfig get mirrored
plugins: [new TsconfigPathsPlugins()],
},
plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })],
};
export default configuration;

View File

@ -0,0 +1,3 @@
/* eslint import/no-unresolved: off, import/no-self-import: off */
module.exports = require('./webpack.config.renderer.dev').default;

View File

@ -0,0 +1,63 @@
/**
* Webpack config for development electron main process
*/
import path from 'path';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.dllPath,
filename: '[name].bundle.dev.js',
library: {
type: 'umd',
},
},
plugins: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8888,
}),
new webpack.DefinePlugin({
'process.type': '"browser"',
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,83 @@
/**
* Webpack config for production electron main process
*/
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import TerserPlugin from 'terser-webpack-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
checkNodeEnv('production');
deleteSourceMaps();
const configuration: webpack.Configuration = {
devtool: 'source-map',
mode: 'production',
target: 'electron-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.distMainPath,
filename: '[name].js',
library: {
type: 'umd',
},
},
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8888,
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
START_MINIMIZED: false,
}),
new webpack.DefinePlugin({
'process.type': '"browser"',
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,71 @@
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-preload',
entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
output: {
path: webpackPaths.dllPath,
filename: 'preload.js',
library: {
type: 'umd',
},
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,77 @@
/**
* Builds the DLL for development electron renderer process
*/
import webpack from 'webpack';
import path from 'path';
import { merge } from 'webpack-merge';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import { dependencies } from '../../package.json';
import checkNodeEnv from '../scripts/check-node-env';
checkNodeEnv('development');
const dist = webpackPaths.dllPath;
const configuration: webpack.Configuration = {
context: webpackPaths.rootPath,
devtool: 'eval',
mode: 'development',
target: 'electron-renderer',
externals: ['fsevents', 'crypto-browserify'],
/**
* Use `module` from `webpack.config.renderer.dev.js`
*/
module: require('./webpack.config.renderer.dev').default.module,
entry: {
renderer: Object.keys(dependencies || {}),
},
output: {
path: dist,
filename: '[name].dev.dll.js',
library: {
name: 'renderer',
type: 'var',
},
},
plugins: [
new webpack.DllPlugin({
path: path.join(dist, '[name].json'),
name: '[name]',
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
options: {
context: webpackPaths.srcPath,
output: {
path: webpackPaths.dllPath,
},
},
}),
],
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,236 @@
import 'webpack-dev-server';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import chalk from 'chalk';
import { merge } from 'webpack-merge';
import { execSync, spawn } from 'child_process';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const port = process.env.PORT || 1212;
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
const skipDLLs =
module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
module.parent?.filename.includes('webpack.config.eslint');
/**
* Warn if the DLL is not built
*/
if (
!skipDLLs &&
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(c|a)ss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
},
},
},
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: [
'style-loader',
'css-loader',
'sass-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('@tailwindcss/postcss')],
},
},
},
],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
'file-loader',
],
},
],
},
plugins: [
...(skipDLLs
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload.js builder...');
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...');
let args = ['run', 'start:main'];
if (process.env.MAIN_ARGS) {
args = args.concat(
['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(),
);
}
spawn('npm', args, {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => {
preloadProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
return middlewares;
},
},
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,161 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import { merge } from 'webpack-merge';
import TerserPlugin from 'terser-webpack-plugin';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
checkNodeEnv('production');
deleteSourceMaps();
const configuration: webpack.Configuration = {
devtool: 'source-map',
mode: 'production',
target: ['web', 'electron-renderer'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distRendererPath,
publicPath: './',
filename: 'renderer.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('@tailwindcss/postcss')],
},
},
},
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('@tailwindcss/postcss')],
},
},
},
],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
'file-loader',
],
},
],
},
optimization: {
minimize: true,
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8889,
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: false,
}),
new webpack.DefinePlugin({
'process.type': '"renderer"',
}),
],
};
export default merge(baseConfig, configuration);

View File

@ -0,0 +1,42 @@
const path = require('path');
const rootPath = path.join(__dirname, '../..');
const erbPath = path.join(__dirname, '..');
const erbNodeModulesPath = path.join(erbPath, 'node_modules');
const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src');
const srcMainPath = path.join(srcPath, 'main');
const srcRendererPath = path.join(srcPath, 'renderer');
const releasePath = path.join(rootPath, 'release');
const appPath = path.join(releasePath, 'app');
const appPackagePath = path.join(appPath, 'package.json');
const appNodeModulesPath = path.join(appPath, 'node_modules');
const srcNodeModulesPath = path.join(srcPath, 'node_modules');
const distPath = path.join(appPath, 'dist');
const distMainPath = path.join(distPath, 'main');
const distRendererPath = path.join(distPath, 'renderer');
const buildPath = path.join(releasePath, 'build');
export default {
rootPath,
erbNodeModulesPath,
dllPath,
srcPath,
srcMainPath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRendererPath,
buildPath,
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1 @@
export default 'test-file-stub';

View File

@ -0,0 +1,8 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off"
}
}

View File

@ -0,0 +1,34 @@
// Check if the renderer and main bundles are built
import path from 'path';
import chalk from 'chalk';
import fs from 'fs';
import { TextEncoder, TextDecoder } from 'node:util';
import webpackPaths from '../configs/webpack.paths';
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
if (!fs.existsSync(mainPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The main process is not built yet. Build it by running "npm run build:main"',
),
);
}
if (!fs.existsSync(rendererPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
),
);
}
// JSDOM does not implement TextEncoder and TextDecoder
if (!global.TextEncoder) {
global.TextEncoder = TextEncoder;
}
if (!global.TextDecoder) {
// @ts-ignore
global.TextDecoder = TextDecoder;
}

View File

@ -0,0 +1,54 @@
import fs from 'fs';
import chalk from 'chalk';
import { execSync } from 'child_process';
import { dependencies } from '../../package.json';
if (dependencies) {
const dependenciesKeys = Object.keys(dependencies);
const nativeDeps = fs
.readdirSync('node_modules')
.filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
if (nativeDeps.length === 0) {
process.exit(0);
}
try {
// Find the reason for why the dependency is installed. If it is installed
// because of a devDependency then that is okay. Warn when it is installed
// because of a dependency
const { dependencies: dependenciesObject } = JSON.parse(
execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(),
);
const rootDependencies = Object.keys(dependenciesObject);
const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
dependenciesKeys.includes(rootDependency),
);
if (filteredRootDependencies.length > 0) {
const plural = filteredRootDependencies.length > 1;
console.log(`
${chalk.whiteBright.bgYellow.bold(
'Webpack does not work with native dependencies.',
)}
${chalk.bold(filteredRootDependencies.join(', '))} ${
plural ? 'are native dependencies' : 'is a native dependency'
} and should be installed inside of the "./release/app" folder.
First, uninstall the packages from "./package.json":
${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
${chalk.bold(
'Then, instead of installing the package to the root "./package.json":',
)}
${chalk.whiteBright.bgRed.bold('npm install your-package')}
${chalk.bold('Install the package to "./release/app/package.json"')}
${chalk.whiteBright.bgGreen.bold(
'cd ./release/app && npm install your-package',
)}
Read more about native dependencies at:
${chalk.bold(
'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure',
)}
`);
process.exit(1);
}
} catch {
console.log('Native dependencies could not be checked');
}
}

View File

@ -0,0 +1,16 @@
import chalk from 'chalk';
export default function checkNodeEnv(expectedEnv) {
if (!expectedEnv) {
throw new Error('"expectedEnv" not set');
}
if (process.env.NODE_ENV !== expectedEnv) {
console.log(
chalk.whiteBright.bgRed.bold(
`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`,
),
);
process.exit(2);
}
}

View File

@ -0,0 +1,16 @@
import chalk from 'chalk';
import detectPort from 'detect-port';
const port = process.env.PORT || '1212';
detectPort(port, (_err, availablePort) => {
if (port !== String(availablePort)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`,
),
);
} else {
process.exit(0);
}
});

View File

@ -0,0 +1,13 @@
import { rimrafSync } from 'rimraf';
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const foldersToRemove = [
webpackPaths.distPath,
webpackPaths.buildPath,
webpackPaths.dllPath,
];
foldersToRemove.forEach((folder) => {
if (fs.existsSync(folder)) rimrafSync(folder);
});

View File

@ -0,0 +1,15 @@
import fs from 'fs';
import path from 'path';
import { rimrafSync } from 'rimraf';
import webpackPaths from '../configs/webpack.paths';
export default function deleteSourceMaps() {
if (fs.existsSync(webpackPaths.distMainPath))
rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), {
glob: true,
});
if (fs.existsSync(webpackPaths.distRendererPath))
rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), {
glob: true,
});
}

View File

@ -0,0 +1,20 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { dependencies } from '../../release/app/package.json';
import webpackPaths from '../configs/webpack.paths';
if (
Object.keys(dependencies || {}).length > 0 &&
fs.existsSync(webpackPaths.appNodeModulesPath)
) {
const electronRebuildCmd =
'../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
const cmd =
process.platform === 'win32'
? electronRebuildCmd.replace(/\//g, '\\')
: electronRebuildCmd;
execSync(cmd, {
cwd: webpackPaths.appPath,
stdio: 'inherit',
});
}

View File

@ -0,0 +1,14 @@
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } =
webpackPaths;
if (fs.existsSync(appNodeModulesPath)) {
if (!fs.existsSync(srcNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
}
if (!fs.existsSync(erbNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction');
}
}

View File

@ -0,0 +1,38 @@
const { notarize } = require('@electron/notarize');
const { build } = require('../../package.json');
exports.default = async function notarizeMacos(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
if (process.env.CI !== 'true') {
console.warn('Skipping notarizing step. Packaging is not running in CI');
return;
}
if (
!(
'APPLE_ID' in process.env &&
'APPLE_ID_PASS' in process.env &&
'APPLE_TEAM_ID' in process.env
)
) {
console.warn(
'Skipping notarizing step. APPLE_ID, APPLE_ID_PASS, and APPLE_TEAM_ID env variables must be set',
);
return;
}
const appName = context.packager.appInfo.productFilename;
await notarize({
tool: 'notarytool',
appBundleId: build.appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
teamId: process.env.APPLE_TEAM_ID,
});
};

33
electron-ui/.eslintignore Normal file
View File

@ -0,0 +1,33 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# OSX
.DS_Store
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
# eslint ignores hidden directories by default:
# https://github.com/eslint/eslint/issues/8429
!.erb

41
electron-ui/.eslintrc.js Normal file
View File

@ -0,0 +1,41 @@
module.exports = {
extends: 'erb',
plugins: ['@typescript-eslint'],
rules: {
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/no-import-module-exports': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'react/require-default-props': 'off',
'react/jsx-no-bind': 'off',
'jsx-a11y/no-autofocus': 'off',
'no-console': 'off',
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
settings: {
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
moduleDirectory: ['node_modules', 'src/'],
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
typescript: {},
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
},
};

12
electron-ui/.gitattributes vendored Normal file
View File

@ -0,0 +1,12 @@
* text eol=lf
*.exe binary
*.png binary
*.jpg binary
*.jpeg binary
*.ico binary
*.icns binary
*.eot binary
*.otf binary
*.ttf binary
*.woff binary
*.woff2 binary

View File

@ -1,4 +1,27 @@
node_modules/ node_modules/
*.log *.log
.DS_Store .DS_Store
dist/ dist/
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts

35
electron-ui/assets/assets.d.ts vendored Normal file
View File

@ -0,0 +1,35 @@
type Styles = Record<string, string>;
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.scss' {
const content: Styles;
export default content;
}
declare module '*.sass' {
const content: Styles;
export default content;
}
declare module '*.css' {
const content: Styles;
export default content;
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

Binary file not shown.

BIN
electron-ui/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
electron-ui/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,23 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_b)">
<path d="M231.5 1V0.5H231H1H0.5V1V231V231.5H1H231H231.5V231V1ZM40.5 25C40.5 33.0082 34.0082 39.5 26 39.5C17.9918 39.5 11.5 33.0082 11.5 25C11.5 16.9918 17.9918 10.5 26 10.5C34.0082 10.5 40.5 16.9918 40.5 25ZM220.5 25C220.5 33.0082 214.008 39.5 206 39.5C197.992 39.5 191.5 33.0082 191.5 25C191.5 16.9918 197.992 10.5 206 10.5C214.008 10.5 220.5 16.9918 220.5 25ZM40.5 205C40.5 213.008 34.0082 219.5 26 219.5C17.9918 219.5 11.5 213.008 11.5 205C11.5 196.992 17.9918 190.5 26 190.5C34.0082 190.5 40.5 196.992 40.5 205ZM220.5 205C220.5 213.008 214.008 219.5 206 219.5C197.992 219.5 191.5 213.008 191.5 205C191.5 196.992 197.992 190.5 206 190.5C214.008 190.5 220.5 196.992 220.5 205ZM209.5 111C209.5 162.639 167.639 204.5 116 204.5C64.3613 204.5 22.5 162.639 22.5 111C22.5 59.3613 64.3613 17.5 116 17.5C167.639 17.5 209.5 59.3613 209.5 111Z" fill="white" stroke="white"/>
<path d="M63.5 146.5C63.5 149.959 60.8969 152.5 58 152.5C55.1031 152.5 52.5 149.959 52.5 146.5C52.5 143.041 55.1031 140.5 58 140.5C60.8969 140.5 63.5 143.041 63.5 146.5Z" stroke="white" stroke-width="5"/>
<path d="M54.9856 139.466C54.9856 139.466 51.1973 116.315 83.1874 93.1647C115.178 70.014 133.698 69.5931 133.698 69.5931" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M178.902 142.686C177.27 139.853 173.652 138.88 170.819 140.512C167.987 142.144 167.014 145.762 168.646 148.595C170.277 151.427 173.896 152.4 176.728 150.768C179.561 149.137 180.534 145.518 178.902 142.686Z" stroke="white" stroke-width="5"/>
<path d="M169.409 151.555C169.409 151.555 151.24 166.394 115.211 150.232C79.182 134.07 69.5718 118.232 69.5718 118.232" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M109.577 41.9707C107.966 44.8143 108.964 48.4262 111.808 50.038C114.651 51.6498 118.263 50.6512 119.875 47.8075C121.487 44.9639 120.488 41.3521 117.645 39.7403C114.801 38.1285 111.189 39.1271 109.577 41.9707Z" stroke="white" stroke-width="5"/>
<path d="M122.038 45.6467C122.038 45.6467 144.047 53.7668 148.412 93.0129C152.778 132.259 144.012 148.579 144.012 148.579" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M59.6334 105C59.6334 105 50.4373 82.1038 61.3054 73.3616C72.1736 64.6194 96 69.1987 96 69.1987" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M149.532 66.9784C149.532 66.9784 174.391 68.9134 177.477 82.6384C180.564 96.3634 165.799 115.833 165.799 115.833" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M138.248 163.363C138.248 163.363 124.023 183.841 110.618 179.573C97.2129 175.305 87.8662 152.728 87.8662 152.728" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M116 119C120.418 119 124 115.642 124 111.5C124 107.358 120.418 104 116 104C111.582 104 108 107.358 108 111.5C108 115.642 111.582 119 116 119Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b" x="-4" y="-4" width="240" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImage" stdDeviation="2"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,286 @@
{ {
"name": "audio-clipper", "name": "electron-react-boilerplate",
"version": "1.0.0", "description": "A foundation for scalable desktop apps",
"main": "src/main.js", "keywords": [
"dependencies": { "electron",
"chokidar": "^3.5.3", "boilerplate",
"react",
"electron-reload": "^2.0.0-alpha.1", "typescript",
"python-shell": "^5.0.0", "ts",
"wavefile": "^11.0.0", "sass",
"wavesurfer.js": "^6.6.4" "webpack",
}, "hot",
"scripts": { "reload"
"start": "electron .", ],
"dev": "electron . --enable-logging", "homepage": "https://github.com/electron-react-boilerplate/electron-react-boilerplate#readme",
"build": "electron-builder", "bugs": {
"build:win": "electron-builder --win", "url": "https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues"
"build:mac": "electron-builder --mac", },
"build:linux": "electron-builder --linux" "repository": {
}, "type": "git",
"build": { "url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
"appId": "com.michalcourson.cliptrimserivce", },
"productName": "ClipTrim", "license": "MIT",
"directories": { "author": {
"output": "dist" "name": "Electron React Boilerplate Maintainers",
}, "email": "electronreactboilerplate@gmail.com",
"extraResources": [ "url": "https://electron-react-boilerplate.js.org"
{ },
"from": "../audio-service", "contributors": [
"to": "audio-service", {
"filter": ["**/*"] "name": "Amila Welihinda",
} "email": "amilajack@gmail.com",
], "url": "https://github.com/amilajack"
"win": {
"target": ["nsis"],
"icon": "build/icon.ico"
},
"mac": {
"target": ["dmg"],
"icon": "build/icon.icns"
},
"linux": {
"target": ["AppImage"],
"icon": "build/icon.png"
}
},
"devDependencies": {
"electron-builder": "^25.1.8",
"electron": "^13.1.7"
} }
],
"main": "./.erb/dll/main.bundle.dev.js",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest"
},
"browserslist": [
"extends browserslist-config-erb"
],
"prettier": {
"singleQuote": true,
"overrides": [
{
"files": [
".prettierrc",
".eslintrc"
],
"options": {
"parser": "json"
}
}
]
},
"jest": {
"moduleDirectories": [
"node_modules",
"release/app/node_modules",
"src"
],
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost/"
},
"testPathIgnorePatterns": [
"release/app/dist",
".erb/dll"
],
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
}
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@electron/notarize": "^3.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@material-tailwind/react": "^2.1.10",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"@wavesurfer/react": "^1.0.12",
"electron-debug": "^4.1.0",
"electron-log": "^5.3.2",
"electron-updater": "^6.3.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.3.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"socketio": "^1.0.0",
"tailwindcss": "^4.1.18",
"wavesurfer.js": "^7.12.1"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@svgr/webpack": "^8.1.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "22.13.10",
"@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4",
"@types/react-test-renderer": "^19.0.0",
"@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"autoprefixer": "^10.4.24",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^9.1.2",
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
"detect-port": "^2.1.0",
"electron": "^35.0.2",
"electron-builder": "^25.1.8",
"electron-devtools-installer": "^4.0.0",
"electronmon": "^2.0.3",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.1.0",
"eslint-import-resolver-typescript": "^4.1.1",
"eslint-import-resolver-webpack": "^0.13.10",
"eslint-plugin-compat": "^6.0.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"postcss": "^8.5.6",
"postcss-loader": "^8.2.0",
"prettier": "^3.5.3",
"react-refresh": "^0.16.0",
"react-test-renderer": "^19.0.0",
"rimraf": "^6.0.1",
"sass": "^1.86.0",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.2.6",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^5.8.2",
"url-loader": "^4.1.1",
"webpack": "^5.98.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.0",
"webpack-merge": "^6.0.1"
},
"build": {
"productName": "ElectronReact",
"appId": "org.erb.ElectronReact",
"asar": true,
"afterSign": ".erb/scripts/notarize.js",
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"mac": {
"notarize": false,
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
},
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
"nsis"
]
},
"linux": {
"target": [
"AppImage"
],
"category": "Development"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"extraResources": [
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "electron-react-boilerplate",
"repo": "electron-react-boilerplate"
}
},
"collective": {
"url": "https://opencollective.com/electron-react-boilerplate-594"
},
"devEngines": {
"runtime": {
"name": "node",
"version": ">=14.x",
"onFail": "error"
},
"packageManager": {
"name": "npm",
"version": ">=7.x",
"onFail": "error"
}
},
"electronmon": {
"patterns": [
"!**/**",
"src/main/**",
".erb/dll/**"
],
"logLevel": "quiet"
}
} }

View File

@ -0,0 +1,14 @@
{
"name": "electron-react-boilerplate",
"version": "4.6.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "electron-react-boilerplate",
"version": "4.6.0",
"hasInstallScript": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,18 @@
{
"name": "electron-react-boilerplate",
"version": "4.6.0",
"description": "A foundation for scalable desktop apps",
"license": "MIT",
"author": {
"name": "Electron React Boilerplate Maintainers",
"email": "electronreactboilerplate@gmail.com",
"url": "https://github.com/electron-react-boilerplate"
},
"main": "./dist/main/main.js",
"scripts": {
"rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"postinstall": "npm run rebuild && npm run link-modules",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts"
},
"dependencies": {}
}

View File

@ -1,68 +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

@ -0,0 +1,5 @@
const AudioChannels = {
LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer',
} as const;
export default AudioChannels;

View File

@ -0,0 +1,18 @@
import { ipcMain } from 'electron';
import fs from 'fs';
import AudioChannels from './channels';
import { LoadAudioBufferArgs, LoadAudioBufferResult } from './types';
export default function registerAudioIpcHandlers() {
ipcMain.handle(
AudioChannels.LOAD_AUDIO_BUFFER,
async (_, args: LoadAudioBufferArgs): Promise<LoadAudioBufferResult> => {
try {
const buffer = await fs.promises.readFile(args.filePath);
return { buffer };
} catch (err: any) {
return { error: err.message };
}
},
);
}

View File

@ -0,0 +1,8 @@
export interface LoadAudioBufferArgs {
filePath: string;
}
export interface LoadAudioBufferResult {
buffer?: Buffer;
error?: string;
}

View File

@ -0,0 +1,8 @@
const SettingsChannels = {
GET_DEFAULTS: 'settings:get-defaults',
GET_SETTINGS: 'settings:get-settings',
SET_SETTINGS: 'settings:set-settings',
GET_INPUT_DEVICES: 'settings:get-input-devices',
} as const;
export default SettingsChannels;

View File

@ -1,480 +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

@ -0,0 +1,140 @@
/* eslint global-require: off, no-console: off, promise/always-return: off */
/**
* This module executes inside of electron's main process. You can start
* electron renderer process from here and communicate with the other processes
* through IPC.
*
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import fs from 'fs';
import path from 'path';
import { app, BrowserWindow, shell, ipcMain } from 'electron';
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './util';
import registerFileIpcHandlers from '../ipc/audio/main';
class AppUpdater {
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
autoUpdater.checkForUpdatesAndNotify();
}
}
let mainWindow: BrowserWindow | null = null;
ipcMain.on('ipc-example', async (event, arg) => {
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
console.log(msgTemplate(arg));
event.reply('ipc-example', msgTemplate('pong'));
});
if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support');
sourceMapSupport.install();
}
const isDebug =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
if (isDebug) {
require('electron-debug').default();
}
const installExtensions = async () => {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS'];
return installer
.default(
extensions.map((name) => installer[name]),
forceDownload,
)
.catch(console.log);
};
const createWindow = async () => {
if (isDebug) {
await installExtensions();
}
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
mainWindow = new BrowserWindow({
show: false,
width: 1024,
height: 728,
icon: getAssetPath('icon.png'),
webPreferences: {
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
},
});
mainWindow.loadURL(resolveHtmlPath('index.html'));
mainWindow.on('ready-to-show', () => {
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
mainWindow.show();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
// Open urls in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
return { action: 'deny' };
});
registerFileIpcHandlers();
// Remove this if your app does not use auto updates
// eslint-disable-next-line
new AppUpdater();
};
/**
* Add event listeners...
*/
app.on('window-all-closed', () => {
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
if (process.platform !== 'darwin') {
app.quit();
}
});
app
.whenReady()
.then(() => {
createWindow();
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
});
})
.catch(console.log);

View File

@ -0,0 +1,290 @@
import {
app,
Menu,
shell,
BrowserWindow,
MenuItemConstructorOptions,
} from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
) {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
Menu.buildFromTemplate([
{
label: 'Inspect element',
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
label: 'Hide ElectronReact',
accelerator: 'Command+H',
selector: 'hide:',
},
{
label: 'Hide Others',
accelerator: 'Command+Shift+H',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'Command+Q',
click: () => {
app.quit();
},
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' },
{ label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' },
{ type: 'separator' },
{ label: 'Cut', accelerator: 'Command+X', selector: 'cut:' },
{ label: 'Copy', accelerator: 'Command+C', selector: 'copy:' },
{ label: 'Paste', accelerator: 'Command+V', selector: 'paste:' },
{
label: 'Select All',
accelerator: 'Command+A',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
},
{
label: 'Toggle Full Screen',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
},
{
label: 'Toggle Developer Tools',
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
},
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Toggle Full Screen',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
label: 'Minimize',
accelerator: 'Command+M',
selector: 'performMiniaturize:',
},
{ label: 'Close', accelerator: 'Command+W', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
label: 'Learn More',
click() {
shell.openExternal('https://electronjs.org');
},
},
{
label: 'Documentation',
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
},
{
label: 'Community Discussions',
click() {
shell.openExternal('https://www.electronjs.org/community');
},
},
{
label: 'Search Issues',
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
},
],
};
const subMenuView =
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
label: '&Open',
accelerator: 'Ctrl+O',
},
{
label: '&Close',
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
? [
{
label: '&Reload',
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
},
{
label: 'Toggle &Full Screen',
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
},
{
label: 'Toggle &Developer Tools',
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
},
]
: [
{
label: 'Toggle &Full Screen',
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
},
],
},
{
label: 'Help',
submenu: [
{
label: 'Learn More',
click() {
shell.openExternal('https://electronjs.org');
},
},
{
label: 'Documentation',
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
},
{
label: 'Community Discussions',
click() {
shell.openExternal('https://www.electronjs.org/community');
},
},
{
label: 'Search Issues',
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
},
],
},
];
return templateDefault;
}
}

View File

@ -0,0 +1,52 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import FileChannels from '../ipc/audio/channels';
import { LoadAudioBufferArgs, ReadTextArgs } from '../ipc/audio/types';
import AudioChannels from '../ipc/audio/channels';
// import '../ipc/file/preload'; // Import file API preload to ensure it runs and exposes the API
export type Channels = 'ipc-example';
const electronHandler = {
ipcRenderer: {
sendMessage(channel: Channels, ...args: unknown[]) {
ipcRenderer.send(channel, ...args);
},
on(channel: Channels, func: (...args: unknown[]) => void) {
const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
invoke: (event: string, ...args: unknown[]) =>
ipcRenderer.invoke(event, ...args),
},
};
contextBridge.exposeInMainWorld('electron', electronHandler);
export type ElectronHandler = typeof electronHandler;
const audioHandler = {
loadAudioBuffer: (filePath: string) =>
ipcRenderer.invoke(AudioChannels.LOAD_AUDIO_BUFFER, {
filePath,
} satisfies LoadAudioBufferArgs),
readText: (filePath: string) =>
ipcRenderer.invoke(AudioChannels.READ_TEXT, {
filePath,
} satisfies ReadTextArgs),
};
contextBridge.exposeInMainWorld('audio', audioHandler);
export type AudioHandler = typeof audioHandler;

View File

@ -0,0 +1,13 @@
/* eslint import/prefer-default-export: off */
import { URL } from 'url';
import path from 'path';
export function resolveHtmlPath(htmlFileName: string) {
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 1212;
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
}
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
}

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

@ -0,0 +1,118 @@
import { createSlice, configureStore } from '@reduxjs/toolkit';
import { ClipMetadata, MetadataState } from './types';
const initialState: MetadataState = {
collections: [],
};
const metadataSlice = createSlice({
name: 'metadata',
initialState,
reducers: {
setAllData(state, action) {
state.collections = action.payload.collections;
},
setCollections(state, action) {
const { collection, newMetadata } = action.payload;
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 collectionState = state.collections.find(
(col) => col.name === collection,
);
// console.log('Editing clip in collection:', collection, clip);
if (collectionState) {
const index = collectionState.clips.findIndex(
(c) => c.filename === clip.filename,
);
if (index !== -1) {
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) => {
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
.find((col) => col.name === collection)
?.clips.map((clip) => clip.filename) || [],
);
const newClips = collections[collection].filter(
(clip: ClipMetadata) => !existingFilenames.has(clip.filename),
);
// const collectionState = state.collections.find(
// (col) => col.name === collection,
// );
if (collectionState) {
collectionState.clips.push(...newClips);
}
});
},
},
});
export const store = configureStore({
reducer: metadataSlice.reducer,
});
// Can still subscribe to the store
// store.subscribe(() => console.log(store.getState()));
// Get the type of our store variable
export type AppStore = typeof store;
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch'];
export const { setCollections, addNewClips, addCollection } =
metadataSlice.actions;
export default metadataSlice.reducer;

View File

@ -0,0 +1,23 @@
export enum PlaybackType {
PlayStop = 'playStop',
PlayOverlap = 'playOverlap',
}
export interface ClipMetadata {
name: string;
filename: string;
volume: number;
startTime: number | undefined;
endTime: number | undefined;
playbackType: PlaybackType;
}
export interface CollectionState {
name: string;
id: number;
clips: ClipMetadata[];
}
export interface MetadataState {
collections: CollectionState[];
}

View File

@ -1,823 +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

@ -0,0 +1,54 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
* See https://github.com/webpack-contrib/sass-loader#imports
*/
@theme {
--color-midnight: #1E1E1E;
--color-plum: #6e44ba;
--color-plumDark: #4f3186;
--color-offwhite: #d4d4d4;
}
::-webkit-scrollbar {
height: 12px;
width: 12px;
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #303030;
-webkit-border-radius: 1ex;
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
}
::-webkit-scrollbar-corner {
background: #1e1e1e;
}
li {
list-style: none;
}
a {
text-decoration: none;
height: fit-content;
width: fit-content;
margin: 10px;
}
a:hover {
opacity: 1;
text-decoration: none;
}
.Hello {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
}

View File

@ -0,0 +1,162 @@
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 './App.css';
import ClipList from './components/ClipList';
import { useAppDispatch, useAppSelector } from './hooks';
import { store } from '../redux/main';
function MainPage() {
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();
dispatch({ type: 'metadata/setAllData', payload: data });
} catch (error) {
console.error('Error fetching metadata:', error);
}
};
fetchMetadata();
const intervalId = setInterval(fetchMetadata, 5000);
return () => clearInterval(intervalId);
}, [dispatch]);
useEffect(() => {
// Update selected collection if collections change
if (collections.length > 0 && !collections.includes(selectedCollection)) {
setSelectedCollection(collections[0]);
}
}, [collections, selectedCollection]);
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() {
return (
<Provider store={store}>
<Router>
<Routes>
<Route path="/" element={<MainPage />} />
</Routes>
</Router>
</Provider>
);
}

View File

@ -0,0 +1,415 @@
import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from 'react';
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 { useWavesurfer } from '@wavesurfer/react';
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 { ClipMetadata } from '../../redux/types';
import { useAppSelector } from '../hooks';
export interface AudioTrimmerProps {
metadata: ClipMetadata;
onSave?: (metadata: ClipMetadata) => void;
onDelete?: (metadata: ClipMetadata) => void;
onMove?: (newCollection: string, metadata: ClipMetadata) => void;
}
export default function AudioTrimmer({
metadata,
onSave,
onDelete,
onMove,
}: AudioTrimmerProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: metadata.filename });
// 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);
}, [metadata.name]);
const openEditDialog = () => setEditDialogOpen(true);
const closeEditDialog = () => setEditDialogOpen(false);
const handleDialogSave = () => {
if (nameInput.trim() && nameInput !== metadata.name) {
const updated = { ...metadata, name: nameInput.trim() };
if (onSave) onSave(updated);
}
closeEditDialog();
};
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const containerRef = useRef(null);
// const [clipStart, setClipStart] = useState<number | undefined>(undefined);
// const [clipEnd, setClipEnd] = useState<number | undefined>(undefined);
const plugins = useMemo(
() => [
RegionsPlugin.create(),
ZoomPlugin.create({
scale: 0.25,
}),
],
[],
);
const fileBaseName =
metadata.filename.split('\\').pop()?.split('/').pop() || 'Unknown';
const { wavesurfer, isReady, isPlaying } = useWavesurfer({
container: containerRef,
height: 100,
waveColor: '#ccb1ff',
progressColor: '#6e44ba',
hideScrollbar: true,
url: blobUrl,
plugins,
});
// Add this ref to always have the latest metadata
const metadataRef = useRef(metadata);
useEffect(() => {
metadataRef.current = metadata;
}, [metadata]);
const onRegionCreated = useCallback(
(newRegion: any) => {
if (wavesurfer === null) return;
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
let isNew = metadataRef.current.startTime === undefined;
allRegions.forEach((region) => {
if (region.id !== newRegion.id) {
if (
region.start === newRegion.start &&
region.end === newRegion.end
) {
newRegion.remove();
return;
}
region.remove();
isNew = !(region.start === 0 && region.end === 0);
// console.log('Region replace:', newRegion, region);
}
});
if (isNew) {
console.log('Region created:', metadataRef.current);
const updated = {
...metadataRef.current,
startTime: newRegion.start,
endTime: newRegion.end,
};
if (onSave) {
onSave(updated);
}
}
},
[plugins, wavesurfer, onSave],
);
const onRegionUpdated = useCallback(
(newRegion: any) => {
if (wavesurfer === null) return;
const updated = {
...metadataRef.current,
startTime: newRegion.start,
endTime: newRegion.end,
};
if (onSave) {
onSave(updated);
}
},
[onSave, wavesurfer],
);
useEffect(() => {
const plugin = plugins[0] as RegionsPlugin;
if (!isReady) return;
// console.log('ready, setting up regions plugin', plugin, isReady);
if (
metadataRef.current.startTime !== undefined &&
metadataRef.current.endTime !== undefined
) {
// setClipStart(metadata.startTime);
// setClipEnd(metadata.endTime);
// console.log('Adding region from metadata:', metadata);=
const allRegions = plugin.getRegions();
// console.log('Existing regions:', allRegions);
if (
allRegions.length === 0 ||
(allRegions.length === 1 &&
allRegions[0].start === 0 &&
allRegions[0].end === 0)
) {
// console.log('adding region from metadata:', metadataRef.current);
plugin.addRegion({
start: metadataRef.current.startTime,
end: metadataRef.current.endTime,
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
}
} else {
// setClipStart(0);
// setClipEnd(wavesurfer ? wavesurfer.getDuration() : 0);
}
}, [isReady, plugins]);
useEffect(() => {
const plugin = plugins[0] as RegionsPlugin;
plugin.unAll();
plugin.on('region-created', onRegionCreated);
plugin.on('region-updated', onRegionUpdated);
plugin.enableDragSelection({
color: 'rgba(132, 81, 224, 0.3)',
});
}, [onRegionCreated, onRegionUpdated, plugins]);
useEffect(() => {
let url: string | null = null;
async function fetchAudio() {
// console.log('Loading audio buffer for file:', filename);
const buffer = await window.audio.loadAudioBuffer(metadata.filename);
// console.log('Received buffer:', buffer.buffer);
if (buffer.buffer && !buffer.error) {
const audioData = buffer.buffer
? new Uint8Array(buffer.buffer)
: buffer;
url = URL.createObjectURL(new Blob([audioData]));
// console.log('Created blob URL:', url);
setBlobUrl(url);
}
}
fetchAudio();
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [metadata.filename]);
const onPlayPause = () => {
if (wavesurfer === null) return;
if (isPlaying) {
wavesurfer.pause();
} else {
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
if (allRegions.length > 0) {
wavesurfer.play(allRegions[0].start, allRegions[0].end);
} else {
wavesurfer.play();
}
}
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(0);
return `${minutes}:${secs.padStart(2, '0')}`;
};
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
position: 'relative',
alignItems: 'stretch',
}}
className="shadow-[0_2px_8px_rgba(0,0,0,0.5)] m-2 rounded-lg bg-darkDrop"
>
<div
// eslint-disable-next-line react/jsx-props-no-spreading
{...attributes}
// eslint-disable-next-line react/jsx-props-no-spreading
{...listeners}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '10px',
borderRadius: '5px 0 0 5px',
cursor: 'grab',
}}
className="bg-neutral-800"
/>
{/* <div className="flex flex-col"> */}
<div className="ml-4 mr-2 p-2">
<div className="grid justify-items-stretch grid-cols-2">
<div className="mb-5px flex flex-col">
<span
className="font-bold text-lg text-white mb-1 cursor-pointer"
onClick={openEditDialog}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openEditDialog();
}
}}
title="Click to edit name"
tabIndex={0}
role="button"
style={{ outline: 'none' }}
>
{metadata.name}
</span>
<span className="text-sm text-neutral-500">{fileBaseName}</span>
</div>
<Dialog
open={editDialogOpen}
onClose={closeEditDialog}
slotProps={{
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
}}
>
<DialogTitle>Edit Clip Name</DialogTitle>
<DialogContent>
<textarea
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 resize-y"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
rows={3}
onFocus={(event) => event.target.select()}
aria-label="Edit clip name"
style={{ minHeight: '3em' }}
/>
</DialogContent>
<DialogActions>
<button
type="button"
onClick={closeEditDialog}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Cancel
</button>
<button
type="button"
onClick={handleDialogSave}
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
>
Save
</button>
</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"
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
onClick={onPlayPause}
>
{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"
onClick={() => setDeleteDialogOpen(true)}
>
<DeleteIcon />
</button>
</div>
</div>
<div className="m-1 wavesurfer-scroll-container">
<div ref={containerRef} className="wavesurfer-inner" />
</div>
<div className="grid justify-items-stretch grid-cols-2 text-neutral-500">
<div className="m-1 flex justify-start">
<text className="text-sm ">
Clip: {formatTime(metadata.startTime ?? 0)} -{' '}
{formatTime(metadata.endTime ?? 0)}
</text>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,205 @@
import React from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import AudioTrimmer from './AudioTrimer';
import { ClipMetadata } from '../../redux/types';
import { useAppDispatch, useAppSelector } from '../hooks';
export interface ClipListProps {
collection: string;
}
export default function ClipList({ collection }: ClipListProps) {
const metadata = useAppSelector(
(state) =>
state.collections.find((col) => col.name === collection) || { clips: [] },
);
const dispatch = useAppDispatch();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
async function handleDragEnd(event: any) {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = metadata.clips.findIndex(
(item) => item.filename === active.id,
);
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',
payload: { collection, newMetadata },
});
try {
const response = await fetch(
'http://localhost:5010/meta/collection/clips/reorder',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: collection,
clips: newMetadata.clips,
}),
},
);
const data = await response.json();
console.log('handle reorder return:', data.collections);
dispatch({ type: 'metadata/setAllData', payload: data });
} catch (error) {
console.error('Error saving new clip order:', error);
}
// setMetadata(newMetadata);
}
}
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({
type: 'metadata/editClip',
payload: { collection, clip: meta },
});
const response = await fetch(
'http://localhost:5010/meta/collection/clips/edit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: collection,
clip: meta,
}),
},
);
await response.json();
// console.log('handle clip save return:', data.collections);
dispatch({
type: 'metadata/editClip',
payload: { collection, clip: meta },
});
} catch (error) {
console.error('Error saving clip metadata:', error);
}
}
return (
<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.clips.map((item) => item.filename)}
strategy={verticalListSortingStrategy}
>
{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>
// <div className="min-h-screen flex flex-col justify-center bg-midnight text-offwhite">
// <AudioTrimmer
// title="audio_capture_20251206_123108.wav"
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
// // section="Section 1"
// />
// <AudioTrimmer
// title="audio_capture_20251206_123108.wav"
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
// // section="Section 1"
// />
// <AudioTrimmer
// title="audio_capture_20251206_123108.wav"
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
// // section="Section 1"
// />
// </div>
);
}

View File

@ -0,0 +1,6 @@
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, RootState } from '../redux/main';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<RootState>();

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline'"
/>
<title>Hello Electron React!</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,13 @@
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root') as HTMLElement;
const root = createRoot(container);
root.render(<App />);
// calling IPC exposed from preload script
window.electron?.ipcRenderer.once('ipc-example', (arg) => {
// eslint-disable-next-line no-console
console.log(arg);
});
window.electron?.ipcRenderer.sendMessage('ipc-example', ['ping']);

Some files were not shown because too many files have changed in this diff Show More