Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a471041e7 | |||
| ab57d8ef22 | |||
| 69c9d80a82 | |||
| 8c83819a17 | |||
| ad07bf7fe6 | |||
| bc40f9abe3 | |||
| e7f649ae0b | |||
| e34903b20f | |||
| 192c959d39 | |||
| 60123d7450 | |||
| 8265951bd4 | |||
| 757d5ef1a7 | |||
| d78c49d0ad | |||
| 089023e7cf | |||
| db97747f2e | |||
| 1e7141c43f | |||
| 8fda2a03af | |||
| 47cdaa76b6 | |||
| d49ac95fa2 | |||
| f2718282c7 | |||
| 86e30e6ec3 | |||
| b8f26496a0 | |||
| a761b81dd1 | |||
| c1948182ec | |||
| 8f367c9264 | |||
| 9af8626dab | |||
| 60355d176c | |||
| d6f4d4166b | |||
| f9fdfb629b | |||
| f3b883602e | |||
| 5516ce9212 | |||
| 0346efd504 | |||
| c292350b25 | |||
| 17bace5eaf |
70
.github/upgrades/dotnet-upgrade-plan.md
vendored
Normal file
@ -0,0 +1,70 @@
|
||||
# .NET 8.0 Upgrade Plan
|
||||
|
||||
## Execution Steps
|
||||
|
||||
Execute steps below sequentially one by one in the order they are listed.
|
||||
|
||||
1. Validate that an .NET 8.0 SDK required for this upgrade is installed on the machine and if not, help to get it installed.
|
||||
2. Ensure that the SDK version specified in global.json files is compatible with the .NET 8.0 upgrade.
|
||||
3. Upgrade ClipTrimDotNet\ClipTrimDotNet.csproj
|
||||
4. Run unit tests to validate upgrade in the projects listed below:
|
||||
- ClientTest\ClientTest.csproj
|
||||
|
||||
## Settings
|
||||
|
||||
This section contains settings and data used by execution steps.
|
||||
|
||||
### Excluded projects
|
||||
|
||||
| Project name | Description |
|
||||
|:-----------------------------------------------|:---------------------------:|
|
||||
|
||||
### Aggregate NuGet packages modifications across all projects
|
||||
|
||||
NuGet packages used across all selected projects or their dependencies that need version update in projects that reference them.
|
||||
|
||||
| Package Name | Current Version | New Version | Description |
|
||||
|:------------------------------------|:---------------:|:-----------:|:----------------------------------------------|
|
||||
| Microsoft.Bcl.AsyncInterfaces | 10.0.2 | 8.0.0 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.DependencyInjection | 10.0.2 | 8.0.1 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.DependencyInjection.Abstractions | 10.0.2 | 8.0.2 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.Logging | 10.0.2 | 8.0.1 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.Logging.Abstractions | 10.0.2 | 8.0.3 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.Options | 10.0.2 | 8.0.2 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Extensions.Primitives | 10.0.2 | 8.0.0 | Recommended for .NET 8.0 |
|
||||
| System.Diagnostics.DiagnosticSource | 10.0.2 | 8.0.1 | Recommended for .NET 8.0 |
|
||||
| System.Drawing.Common | 9.0.10 | 8.0.24 | Recommended for .NET 8.0 |
|
||||
| System.IO.Pipelines | 10.0.2 | 8.0.0 | Recommended for .NET 8.0 |
|
||||
| System.Security.AccessControl | 4.7.0 | 6.0.1 | Recommended for .NET 8.0 |
|
||||
| System.Text.Encodings.Web | 10.0.2 | 8.0.0 | Recommended for .NET 8.0 |
|
||||
| System.Text.Json | 10.0.2 | 8.0.6 | Recommended for .NET 8.0 |
|
||||
| Microsoft.Win32.Registry | 4.7.0 | | Functionality included with framework |
|
||||
| System.Buffers | 4.6.1 | | Functionality included with framework |
|
||||
| System.IO | 4.3.0 | | Functionality included with framework |
|
||||
| System.Memory | 4.6.3 | | Functionality included with framework |
|
||||
| System.Net.Http | 4.3.4 | | Functionality included with framework |
|
||||
| System.Numerics.Vectors | 4.6.1 | | Functionality included with framework |
|
||||
| System.Runtime | 4.3.0 | | Functionality included with framework |
|
||||
| System.Security.Cryptography.Algorithms | 4.3.0 | | Functionality included with framework |
|
||||
| System.Security.Cryptography.Encoding | 4.3.0 | | Functionality included with framework |
|
||||
| System.Security.Cryptography.Primitives | 4.3.0 | | Functionality included with framework |
|
||||
| System.Security.Cryptography.X509Certificates | 4.3.0 | | Functionality included with framework |
|
||||
| System.Security.Principal.Windows | 4.7.0 | | Functionality included with framework |
|
||||
| System.Threading.Tasks.Extensions | 4.6.3 | | Functionality included with framework |
|
||||
| System.ValueTuple | 4.6.1 | | Functionality included with framework |
|
||||
|
||||
### Project upgrade details
|
||||
|
||||
#### ClipTrimDotNet\ClipTrimDotNet.csproj modifications
|
||||
|
||||
Project properties changes:
|
||||
- Target framework should be changed from `.NETFramework,Version=v4.8` to `net8.0`
|
||||
- Project file should be converted to SDK-style
|
||||
|
||||
NuGet packages changes:
|
||||
- Update all packages listed in the NuGet packages table above as recommended
|
||||
- Remove packages whose functionality is now included with the framework
|
||||
|
||||
Other changes:
|
||||
- Ensure compatibility with .NET 8.0 APIs and features
|
||||
- Update code as needed for breaking changes
|
||||
1
audio-service/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
recordings/
|
||||
1
audio-service/build.bat
Normal file
@ -0,0 +1 @@
|
||||
python -m venv venv
|
||||
134
audio-service/metadata.json
Normal file
@ -0,0 +1,134 @@
|
||||
[
|
||||
{
|
||||
"name": "Uncategorized",
|
||||
"id": 0,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_195932.wav",
|
||||
"name": "Clip 20260226_195932",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_165611.wav",
|
||||
"name": "Clip 20260228_165611",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1.0
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_165646.wav",
|
||||
"name": "Clip 20260228_165646",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Test",
|
||||
"id": 1,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_183812.wav",
|
||||
"name": "Clip 20260226_183812",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_183607.wav",
|
||||
"name": "Clip 20260226_183607",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_183822.wav",
|
||||
"name": "Clip 20260226_183822",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184028.wav",
|
||||
"name": "Clip 20260226_184028",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184030.wav",
|
||||
"name": "Clip 20260226_184030",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184032.wav",
|
||||
"name": "Clip 20260226_184032",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184037.wav",
|
||||
"name": "Clip 20260226_184037",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184040.wav",
|
||||
"name": "Clip 20260226_184040",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184041.wav",
|
||||
"name": "Clip 20260226_184041",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260226_184042.wav",
|
||||
"name": "Clip 20260226_184042",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_092721.wav",
|
||||
"name": "Clip 20260228_092721",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1,
|
||||
"startTime": 6.438382145377559,
|
||||
"endTime": 14.277258292166426
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "playOverlap",
|
||||
"startTime": 27.64044943820222,
|
||||
"volume": 0.31
|
||||
},
|
||||
{
|
||||
"endTime": 30,
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260220_200442.wav",
|
||||
"name": "Test",
|
||||
"playbackType": "playOverlap",
|
||||
"startTime": 26.14685314685314,
|
||||
"volume": 0.64
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_085116.wav",
|
||||
"name": "pp",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_120955.wav",
|
||||
"name": "nose",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,6 +1,6 @@
|
||||
sounddevice==0.5.1
|
||||
numpy==1.22.3
|
||||
python-osc==1.9.3
|
||||
scipy==1.10.1
|
||||
comtypes==1.4.8
|
||||
pycaw==20240210
|
||||
Flask==3.1.3
|
||||
flask_cors==6.0.2
|
||||
flask_socketio==5.6.1
|
||||
numpy==2.4.2
|
||||
scipy==1.17.1
|
||||
sounddevice==0.5.5
|
||||
|
||||
17
audio-service/settings.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"input_device": {
|
||||
"channels": 2,
|
||||
"default_samplerate": 48000,
|
||||
"index": 55,
|
||||
"name": "VM Mic mix (VB-Audio Voicemeeter VAIO)"
|
||||
},
|
||||
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings",
|
||||
"recording_length": 30,
|
||||
"output_device": {
|
||||
"channels": 2,
|
||||
"default_samplerate": 48000,
|
||||
"index": 45,
|
||||
"name": "VM to Discord (VB-Audio Voicemeeter VAIO)"
|
||||
},
|
||||
"http_port": 5010
|
||||
}
|
||||
BIN
audio-service/src/__pycache__/audio_clip.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/audio_io.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/audio_recorder.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/main.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/metadata_manager.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/osc_server.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/settings.cpython-313.pyc
Normal file
BIN
audio-service/src/__pycache__/windows_audio.cpython-313.pyc
Normal file
64
audio-service/src/audio_clip.py
Normal file
@ -0,0 +1,64 @@
|
||||
import scipy.signal
|
||||
import scipy.io.wavfile as wavfile
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
class AudioClip:
|
||||
def __init__(self, metadata, target_sample_rate=44100):
|
||||
"""
|
||||
metadata: dict with keys 'filename', 'start', 'end' (seconds)
|
||||
target_sample_rate: sample rate for playback
|
||||
"""
|
||||
self.metadata = metadata
|
||||
self.file_path = metadata['filename']
|
||||
self.start = metadata.get('startTime', 0)
|
||||
self.end = metadata.get('endTime', None)
|
||||
self.target_sample_rate = target_sample_rate
|
||||
self.volume = metadata.get('volume', 1.0)
|
||||
self.finished = False
|
||||
self.audio_data, self.sample_rate = self._load_and_process_audio()
|
||||
print(f"AudioClip created for {self.file_path} with start={self.start}s, end={self.end}s, sample_rate={self.sample_rate}Hz, length={len(self.audio_data)/self.sample_rate:.2f}s")
|
||||
self.position = 0 # sample index for playback
|
||||
|
||||
def _load_and_process_audio(self):
|
||||
# Load audio file
|
||||
sample_rate, data = wavfile.read(self.file_path)
|
||||
# Convert to float32
|
||||
if data.dtype != np.float32:
|
||||
data = data.astype(np.float32) / np.max(np.abs(data))
|
||||
# Convert to mono if needed
|
||||
if len(data.shape) > 1:
|
||||
data = np.mean(data, axis=1)
|
||||
# Resample if needed
|
||||
if sample_rate != self.target_sample_rate:
|
||||
num_samples = int(len(data) * self.target_sample_rate / sample_rate)
|
||||
data = scipy.signal.resample(data, num_samples)
|
||||
sample_rate = self.target_sample_rate
|
||||
# Cache only the clip region
|
||||
start_sample = int(self.start * sample_rate)
|
||||
end_sample = int(self.end * sample_rate) if self.end else len(data)
|
||||
cached = data[start_sample:end_sample]
|
||||
cached *= self.volume # Apply volume
|
||||
return cached, sample_rate
|
||||
|
||||
def get_samples(self, num_samples):
|
||||
# Return next chunk for playback
|
||||
if self.position >= len(self.audio_data):
|
||||
self.finished = True
|
||||
return np.zeros(num_samples, dtype=np.float32)
|
||||
end_pos = min(self.position + num_samples, len(self.audio_data))
|
||||
chunk = self.audio_data[self.position:end_pos]
|
||||
self.position = end_pos
|
||||
if self.position >= len(self.audio_data):
|
||||
self.finished = True
|
||||
# Pad if chunk is short
|
||||
if len(chunk) < num_samples:
|
||||
chunk = np.pad(chunk, (0, num_samples - len(chunk)), mode='constant')
|
||||
return chunk
|
||||
|
||||
def is_finished(self):
|
||||
return self.finished
|
||||
|
||||
def reset(self):
|
||||
self.position = 0
|
||||
self.finished = False
|
||||
168
audio-service/src/audio_io.py
Normal file
@ -0,0 +1,168 @@
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import os
|
||||
from datetime import datetime
|
||||
import scipy.io.wavfile as wavfile
|
||||
from metadata_manager import MetaDataManager
|
||||
from audio_clip import AudioClip
|
||||
|
||||
|
||||
# AudioClip class for clip playback
|
||||
|
||||
|
||||
class AudioIO:
|
||||
_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):
|
||||
self.duration = 30
|
||||
self.channels = 2
|
||||
self.input_sample_rate = 44100
|
||||
self.output_sample_rate = 44100
|
||||
self.buffer = np.zeros((int(self.duration * self.input_sample_rate), self.channels), dtype=np.float32)
|
||||
self.recordings_dir = "recordings"
|
||||
|
||||
sd.default.latency = 'low'
|
||||
|
||||
self.socket = None
|
||||
|
||||
self.in_stream = sd.InputStream(
|
||||
callback=self.record_callback
|
||||
)
|
||||
|
||||
self.out_stream = sd.OutputStream(
|
||||
callback=self.playback_callback,
|
||||
latency=3
|
||||
)
|
||||
|
||||
self.clip_map = {}
|
||||
|
||||
|
||||
def refresh_streams(self):
|
||||
was_active = self.in_stream.active
|
||||
if was_active:
|
||||
self.in_stream.stop()
|
||||
self.out_stream.stop()
|
||||
|
||||
self.buffer = np.zeros((int(self.duration * self.input_sample_rate), self.channels), dtype=np.float32)
|
||||
# print(f"AudioRecorder initialized with duration={self.duration}s, sample_rate={self.sample_rate}Hz, channels={self.channels}")
|
||||
self.in_stream = sd.InputStream(
|
||||
callback=self.record_callback
|
||||
)
|
||||
|
||||
self.out_stream = sd.OutputStream(
|
||||
callback=self.playback_callback
|
||||
)
|
||||
|
||||
if was_active:
|
||||
self.in_stream.start()
|
||||
self.out_stream.start()
|
||||
|
||||
|
||||
|
||||
def record_callback(self, indata, frames, time, status):
|
||||
if status:
|
||||
# print(f"Recording status: {status}")
|
||||
pass
|
||||
|
||||
# Circular buffer implementation
|
||||
self.buffer = np.roll(self.buffer, -frames, axis=0)
|
||||
self.buffer[-frames:] = indata
|
||||
|
||||
def playback_callback(self, outdata, frames, time, status):
|
||||
if status:
|
||||
# print(f"Playback status: {status}")
|
||||
pass
|
||||
|
||||
outdata.fill(0)
|
||||
|
||||
# Iterate over a copy of the items to avoid modifying the dictionary during iteration
|
||||
for clip_id, clip_list in list(self.clip_map.items()):
|
||||
for clip in clip_list[:]: # Iterate over a copy of the list
|
||||
if not clip.is_finished():
|
||||
samples = clip.get_samples(frames)
|
||||
outdata[:] += samples.reshape(-1, 1) # Mix into output
|
||||
if clip.is_finished():
|
||||
self.clip_map[clip_id].remove(clip)
|
||||
if len(self.clip_map[clip_id]) == 0:
|
||||
del self.clip_map[clip_id]
|
||||
break # Exit inner loop since the key is deleted
|
||||
|
||||
|
||||
def save_last_n_seconds(self):
|
||||
# Create output directory if it doesn't exist
|
||||
os.makedirs(self.recordings_dir, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = os.path.join(self.recordings_dir, f"audio_capture_{timestamp}.wav")
|
||||
|
||||
# Normalize audio to prevent clipping
|
||||
audio_data = self.buffer / np.max(np.abs(self.buffer)) * .5
|
||||
|
||||
# Convert float32 to int16 for WAV file
|
||||
audio_data_int16 = (audio_data * 32767).astype(np.int16)
|
||||
|
||||
# Write buffer to file
|
||||
wavfile.write(filename, int(self.input_sample_rate), audio_data_int16)
|
||||
|
||||
meta = MetaDataManager()
|
||||
|
||||
clip_metadata = {
|
||||
"filename": filename,
|
||||
"name": f"Clip {timestamp}",
|
||||
"playbackType":"playStop",
|
||||
"volume": 1.0,
|
||||
}
|
||||
|
||||
meta.add_clip_to_collection("Uncategorized", clip_metadata )
|
||||
self.socket.emit('new_clip', clip_metadata)
|
||||
|
||||
return clip_metadata
|
||||
|
||||
def set_buffer_duration(self, duration):
|
||||
self.duration = duration
|
||||
self.buffer = np.zeros((int(duration * self.input_sample_rate), self.channels), dtype=np.float32)
|
||||
|
||||
def set_recording_directory(self, directory):
|
||||
self.recordings_dir = directory
|
||||
|
||||
def start_recording(self):
|
||||
if(self.in_stream.active):
|
||||
# print("Already recording")
|
||||
return
|
||||
# print('number of channels', self.channels)
|
||||
|
||||
self.in_stream.start()
|
||||
self.out_stream.start()
|
||||
self.output_sample_rate = self.out_stream.samplerate
|
||||
self.input_sample_rate = self.in_stream.samplerate
|
||||
|
||||
def stop_recording(self):
|
||||
if(not self.in_stream.active):
|
||||
# print("Already stopped")
|
||||
return
|
||||
|
||||
self.in_stream.stop()
|
||||
self.out_stream.stop()
|
||||
|
||||
def is_recording(self):
|
||||
return self.in_stream.active
|
||||
|
||||
def play_clip(self, clip_metadata):
|
||||
print(f"Playing clip: {clip_metadata}")
|
||||
clip_id = clip_metadata.get("filename")
|
||||
if clip_metadata.get("playbackType") == "playStop":
|
||||
if clip_id in self.clip_map:
|
||||
del self.clip_map[clip_id]
|
||||
return
|
||||
else:
|
||||
self.clip_map[clip_id] = []
|
||||
if clip_id not in self.clip_map:
|
||||
self.clip_map[clip_id] = []
|
||||
self.clip_map[clip_id].append(AudioClip(clip_metadata, target_sample_rate=self.output_sample_rate))
|
||||
@ -1,75 +0,0 @@
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import os
|
||||
from datetime import datetime
|
||||
import scipy.io.wavfile as wavfile
|
||||
|
||||
class AudioRecorder:
|
||||
def __init__(self, duration=30, sample_rate=44100, channels=2, recordings_dir='recordings'):
|
||||
"""
|
||||
Initialize audio recorder with configurable parameters.
|
||||
|
||||
:param duration: Length of audio buffer in seconds
|
||||
:param sample_rate: Audio sample rate (if None, use default device sample rate)
|
||||
:param channels: Number of audio channels
|
||||
"""
|
||||
|
||||
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):
|
||||
"""
|
||||
Circular buffer callback for continuous recording.
|
||||
|
||||
:param indata: Input audio data
|
||||
:param frames: Number of frames
|
||||
:param time: Timestamp
|
||||
:param status: Recording status
|
||||
"""
|
||||
if status:
|
||||
print(f"Recording status: {status}")
|
||||
|
||||
# Circular buffer implementation
|
||||
self.buffer = np.roll(self.buffer, -frames, axis=0)
|
||||
self.buffer[-frames:] = indata
|
||||
|
||||
def save_last_n_seconds(self):
|
||||
"""
|
||||
Save the last n seconds of audio to a file.
|
||||
|
||||
:param output_dir: Directory to save recordings
|
||||
:return: Path to saved audio file
|
||||
"""
|
||||
# Create output directory if it doesn't exist
|
||||
os.makedirs(self.recordings_dir, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = os.path.join(self.recordings_dir, f"audio_capture_{timestamp}.wav")
|
||||
|
||||
# Normalize audio to prevent clipping
|
||||
audio_data = self.buffer / np.max(np.abs(self.buffer)) * .5
|
||||
|
||||
# Convert float32 to int16 for WAV file
|
||||
audio_data_int16 = (audio_data * 32767).astype(np.int16)
|
||||
|
||||
# Write buffer to file
|
||||
wavfile.write(filename, int(self.sample_rate), audio_data_int16)
|
||||
|
||||
return filename
|
||||
|
||||
def start_recording(self):
|
||||
"""
|
||||
Start continuous audio recording with circular buffer.
|
||||
"""
|
||||
print('number of channels', self.channels)
|
||||
stream = sd.InputStream(
|
||||
samplerate=self.sample_rate,
|
||||
channels=self.channels,
|
||||
callback=self.record_callback
|
||||
)
|
||||
stream.start()
|
||||
return stream
|
||||
@ -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)
|
||||
@ -1,92 +1,94 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from osc_server import OSCRecordingServer
|
||||
from audio_recorder import AudioRecorder
|
||||
from audio_io import AudioIO
|
||||
from windows_audio import WindowsAudioManager
|
||||
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, emit
|
||||
import threading
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", logger=True, engineio_logger=True, async_mode='eventlet')
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
print("Client connected")
|
||||
emit('full_data', MetaDataManager().collections)
|
||||
|
||||
@socketio.on('record_clip')
|
||||
def record_clip():
|
||||
io = AudioIO()
|
||||
io.save_last_n_seconds();
|
||||
|
||||
@socketio.on('play_clip')
|
||||
def play_clip(data):
|
||||
io = AudioIO()
|
||||
print(f"Received play_clip event with data: {data}")
|
||||
if data:
|
||||
io.play_clip(data)
|
||||
|
||||
|
||||
def main():
|
||||
# Create argument parser
|
||||
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
|
||||
parser.add_argument('--osc-port',
|
||||
type=int,
|
||||
help='OSC server port number',
|
||||
default=5005)
|
||||
default=5010)
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
audio_manager = WindowsAudioManager()
|
||||
settings = SettingsManager()
|
||||
meta = MetaDataManager()
|
||||
|
||||
|
||||
|
||||
# Ensure save path exists
|
||||
os.makedirs(args.save_path, exist_ok=True)
|
||||
audio_manager=WindowsAudioManager()
|
||||
# Handle input device selection
|
||||
input_device = None
|
||||
devices = audio_manager.list_audio_devices('input')
|
||||
if args.input_device:
|
||||
try:
|
||||
# Try to convert to integer first (for device index)
|
||||
input_device = int(args.input_device)
|
||||
except ValueError:
|
||||
# If not an integer, treat as device name
|
||||
os.makedirs(settings.get_settings('save_path'), exist_ok=True)
|
||||
|
||||
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']),
|
||||
)
|
||||
io = AudioIO()
|
||||
io.start_recording()
|
||||
|
||||
# settings.socket = socketio
|
||||
io.socket = socketio
|
||||
meta.socket = socketio
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(recording_bp)
|
||||
app.register_blueprint(device_bp)
|
||||
app.register_blueprint(metadata_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
print(f"Starting Flask server on port {settings.get_settings('http_port')}")
|
||||
# app.run(host='127.0.0.1', port=settings.get_settings('http_port'), debug=False, use_reloader=True)
|
||||
socketio.run(app, host='127.0.0.1', port=settings.get_settings('http_port'), debug=False, use_reloader=True, allow_unsafe_werkzeug=True)
|
||||
|
||||
# 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
|
||||
try:
|
||||
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")
|
||||
# try:
|
||||
# print(f"Starting OSC Recording Server on port {args.osc_port}")
|
||||
# # osc_server.run_server()
|
||||
# except KeyboardInterrupt:
|
||||
# print("\nServer stopped by user.")
|
||||
# except Exception as e:
|
||||
# print(f"Error starting server: {e}")
|
||||
# sys.exit(1)
|
||||
|
||||
|
||||
osc_server.run_server()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped by user.")
|
||||
except Exception as e:
|
||||
print(f"Error starting server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
audio-service/src/metadata.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
114
audio-service/src/metadata_manager.py
Normal file
@ -0,0 +1,114 @@
|
||||
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):
|
||||
self.socket = None
|
||||
# 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)
|
||||
if not self.socket is None:
|
||||
self.socket.emit('collection_updated', collection)
|
||||
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")
|
||||
]
|
||||
if not self.socket is None:
|
||||
self.socket.emit('collection_updated', collection)
|
||||
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)
|
||||
if not self.socket is None:
|
||||
self.socket.emit('collection_updated', source_collection)
|
||||
self.socket.emit('collection_updated', target_collection)
|
||||
|
||||
|
||||
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
|
||||
if not self.socket is None:
|
||||
self.socket.emit('collection_updated', collection)
|
||||
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
|
||||
if not self.socket is None:
|
||||
self.socket.emit('collection_updated', collection)
|
||||
self.save_metadata()
|
||||
|
||||
def save_metadata(self):
|
||||
with open(self.metadata_file, "w") as f:
|
||||
json.dump(self.collections, f, indent=4)
|
||||
@ -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()
|
||||
BIN
audio-service/src/recordings/audio_capture_20260214_092540.wav
Normal file
BIN
audio-service/src/routes/__pycache__/device.cpython-313.pyc
Normal file
BIN
audio-service/src/routes/__pycache__/metadata.cpython-313.pyc
Normal file
BIN
audio-service/src/routes/__pycache__/recording.cpython-313.pyc
Normal file
BIN
audio-service/src/routes/__pycache__/settings.cpython-313.pyc
Normal file
37
audio-service/src/routes/device.py
Normal file
@ -0,0 +1,37 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from windows_audio import WindowsAudioManager
|
||||
from audio_io import AudioIO
|
||||
|
||||
device_bp = Blueprint('device', __name__)
|
||||
|
||||
audio_manager = WindowsAudioManager()
|
||||
recorder = AudioIO()
|
||||
|
||||
# @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
|
||||
106
audio-service/src/routes/metadata.py
Normal file
@ -0,0 +1,106 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@metadata_bp.route('/ws/test', methods=['POST'])
|
||||
def test_websocket():
|
||||
MetaDataManager().socket.emit('test_event', {'data': 'Test message from metadata route'})
|
||||
return jsonify({'status': 'success'})
|
||||
57
audio-service/src/routes/recording.py
Normal file
@ -0,0 +1,57 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from audio_io import AudioIO
|
||||
import os
|
||||
|
||||
recording_bp = Blueprint('recording', __name__)
|
||||
|
||||
@recording_bp.route('/record/start', methods=['POST'])
|
||||
def start_recording():
|
||||
recorder = AudioIO()
|
||||
print('HTTP: Starting audio recording')
|
||||
recorder.start_recording()
|
||||
return jsonify({'status': 'recording started'})
|
||||
|
||||
@recording_bp.route('/record/stop', methods=['POST'])
|
||||
def stop_recording():
|
||||
recorder = AudioIO()
|
||||
# print('HTTP: Stopping audio recording')
|
||||
recorder.stop_recording()
|
||||
return jsonify({'status': 'recording stopped'})
|
||||
|
||||
@recording_bp.route('/record/save', methods=['POST'])
|
||||
def save_recording():
|
||||
recorder = AudioIO()
|
||||
# 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 = AudioIO()
|
||||
# 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(f"Playing clip")
|
||||
# print('HTTP: Starting audio playback')
|
||||
clip = request.json
|
||||
try:
|
||||
io = AudioIO()
|
||||
io.play_clip(clip)
|
||||
# os.remove(filename)
|
||||
return jsonify({'status': 'success'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
32
audio-service/src/routes/settings.py
Normal file
@ -0,0 +1,32 @@
|
||||
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/update', methods=['POST'])
|
||||
def set_all_settings():
|
||||
settings = request.json.get('settings')
|
||||
print (f"Received settings update: {settings}")
|
||||
if settings is None:
|
||||
return jsonify({'status': 'error', 'message': 'Settings are required'}), 400
|
||||
try:
|
||||
for name, value in settings.items():
|
||||
print(f"Updating setting '{name}' to '{value}'")
|
||||
SettingsManager().set_settings(name, value)
|
||||
return jsonify({'status': 'success', 'settings': settings})
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
10
audio-service/src/settings.json
Normal 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
|
||||
}
|
||||
101
audio-service/src/settings.py
Normal file
@ -0,0 +1,101 @@
|
||||
import os
|
||||
import json
|
||||
from audio_io import AudioIO
|
||||
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
|
||||
print("Initializing SettingsManager", os.getcwd())
|
||||
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,
|
||||
"output_device": None,
|
||||
"save_path": os.path.join(os.getcwd(), "recordings"),
|
||||
"recording_length": 15
|
||||
}
|
||||
audio_manager = WindowsAudioManager()
|
||||
|
||||
input_devices = audio_manager.list_audio_devices('input')
|
||||
output_devices = audio_manager.list_audio_devices('output')
|
||||
# print("Available input devices:")
|
||||
# for i, dev in enumerate(input_devices):
|
||||
# print(i, dev['name'])
|
||||
# print("Available output devices:")
|
||||
# for i, dev in enumerate(output_devices):
|
||||
# print(i, dev['name'])
|
||||
# print(f"Available input devices: {input_devices}")
|
||||
# print(f"Available output devices: {output_devices}")
|
||||
input = None
|
||||
output = None
|
||||
|
||||
if("input_device" in self.settings):
|
||||
input = self.settings["input_device"]
|
||||
if("output_device" in self.settings):
|
||||
output = self.settings["output_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 input_devices):
|
||||
# print(f"Using saved input device index: {input}")
|
||||
pass
|
||||
else:
|
||||
input = input_devices[0] if input_devices else None
|
||||
self.settings["input_device"] = input
|
||||
|
||||
#see if output device is in "devices", if not set to the first index
|
||||
if output is not None and any(d['name'] == output["name"] for d in output_devices):
|
||||
# print(f"Using saved output device index: {output}")
|
||||
pass
|
||||
else:
|
||||
output = output_devices[0] if output_devices else None
|
||||
self.settings["output_device"] = output
|
||||
|
||||
if not "http_port" in self.settings:
|
||||
self.settings["http_port"] = 5010
|
||||
|
||||
|
||||
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):
|
||||
if(name not in self.settings):
|
||||
raise ValueError(f"Setting '{name}' not found.")
|
||||
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 = AudioIO()
|
||||
# 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'])
|
||||
audio_manager.set_default_output_device(self.get_settings('output_device')['index'])
|
||||
|
||||
recorder.refresh_streams()
|
||||
|
||||
@ -1,17 +1,32 @@
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import comtypes
|
||||
import comtypes.client
|
||||
from comtypes import CLSCTX_ALL
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
import json
|
||||
|
||||
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.
|
||||
"""
|
||||
self.devices = sd.query_devices()
|
||||
host_apis = sd.query_hostapis()
|
||||
wasapi_device_indexes = None
|
||||
for api in host_apis:
|
||||
if api['name'].lower() == 'Windows WASAPI'.lower():
|
||||
wasapi_device_indexes = api['devices']
|
||||
break
|
||||
# print(f"Host APIs: {host_apis}")
|
||||
# print(f"WASAPI Device Indexes: {wasapi_device_indexes}")
|
||||
wasapi_device_indexes = set(wasapi_device_indexes) if wasapi_device_indexes is not None else set()
|
||||
self.devices = [dev for dev in sd.query_devices() if dev['index'] in wasapi_device_indexes]
|
||||
# self.devices = sd.query_devices()
|
||||
# print(f"devices: {self.devices}")
|
||||
|
||||
self.default_input = sd.default.device[0]
|
||||
self.default_output = sd.default.device[1]
|
||||
|
||||
@ -27,7 +42,7 @@ class WindowsAudioManager:
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'max_input_channels': dev['max_input_channels'],
|
||||
'channels': dev['max_input_channels'],
|
||||
'default_samplerate': dev['default_samplerate']
|
||||
}
|
||||
for dev in self.devices if dev['max_input_channels'] > 0
|
||||
@ -37,13 +52,32 @@ class WindowsAudioManager:
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'max_output_channels': dev['max_output_channels'],
|
||||
'channels': dev['max_output_channels'],
|
||||
'default_samplerate': dev['default_samplerate']
|
||||
}
|
||||
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):
|
||||
if(device_index is None):
|
||||
return 0
|
||||
"""
|
||||
Set the default input audio device.
|
||||
|
||||
@ -57,41 +91,18 @@ class WindowsAudioManager:
|
||||
device_info = sd.query_devices(device_index)
|
||||
return device_info['default_samplerate']
|
||||
|
||||
def get_current_input_device_sample_rate(self):
|
||||
def set_default_output_device(self, device_index):
|
||||
if(device_index is None):
|
||||
return self.get_current_output_device_sample_rate()
|
||||
"""
|
||||
Get the sample rate of the current input device.
|
||||
Set the default output audio device.
|
||||
|
||||
:return: Sample rate of the current input device
|
||||
:param device_index: Index of the audio device
|
||||
:return: Sample rate of the selected device
|
||||
"""
|
||||
device_info = sd.query_devices(self.default_input)
|
||||
sd.default.device[1] = device_index
|
||||
self.default_output = device_index
|
||||
|
||||
# Get the sample rate of the selected device
|
||||
device_info = sd.query_devices(device_index)
|
||||
return device_info['default_samplerate']
|
||||
|
||||
def get_system_volume(self):
|
||||
"""
|
||||
Get the system master volume.
|
||||
|
||||
:return: Current system volume (0.0 to 1.0)
|
||||
"""
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
interface = devices.Activate(
|
||||
IAudioEndpointVolume._iid_,
|
||||
CLSCTX_ALL,
|
||||
None
|
||||
)
|
||||
volume = interface.QueryInterface(IAudioEndpointVolume)
|
||||
return volume.GetMasterVolumeLevelScalar()
|
||||
|
||||
def set_system_volume(self, volume_level):
|
||||
"""
|
||||
Set the system master volume.
|
||||
|
||||
:param volume_level: Volume level (0.0 to 1.0)
|
||||
"""
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
interface = devices.Activate(
|
||||
IAudioEndpointVolume._iid_,
|
||||
CLSCTX_ALL,
|
||||
None
|
||||
)
|
||||
volume = interface.QueryInterface(IAudioEndpointVolume)
|
||||
volume.SetMasterVolumeLevelScalar(volume_level, None)
|
||||
12
electron-ui/.editorconfig
Normal 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
|
||||
7
electron-ui/.erb/configs/.eslintrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off"
|
||||
}
|
||||
}
|
||||
6
electron-ui/.erb/configs/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
const tailwindcss = require('@tailwindcss/postcss');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
|
||||
module.exports = {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
};
|
||||
54
electron-ui/.erb/configs/webpack.config.base.ts
Normal 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;
|
||||
3
electron-ui/.erb/configs/webpack.config.eslint.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/* eslint import/no-unresolved: off, import/no-self-import: off */
|
||||
|
||||
module.exports = require('./webpack.config.renderer.dev').default;
|
||||
63
electron-ui/.erb/configs/webpack.config.main.dev.ts
Normal 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);
|
||||
83
electron-ui/.erb/configs/webpack.config.main.prod.ts
Normal 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);
|
||||
71
electron-ui/.erb/configs/webpack.config.preload.dev.ts
Normal 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);
|
||||
77
electron-ui/.erb/configs/webpack.config.renderer.dev.dll.ts
Normal 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);
|
||||
236
electron-ui/.erb/configs/webpack.config.renderer.dev.ts
Normal 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);
|
||||
161
electron-ui/.erb/configs/webpack.config.renderer.prod.ts
Normal 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);
|
||||
42
electron-ui/.erb/configs/webpack.paths.ts
Normal 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,
|
||||
};
|
||||
32
electron-ui/.erb/img/erb-banner.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
electron-ui/.erb/img/erb-logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
6
electron-ui/.erb/img/palette-sponsor-banner.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
electron-ui/.erb/mocks/fileMock.js
Normal file
@ -0,0 +1 @@
|
||||
export default 'test-file-stub';
|
||||
8
electron-ui/.erb/scripts/.eslintrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import/no-extraneous-dependencies": "off"
|
||||
}
|
||||
}
|
||||
34
electron-ui/.erb/scripts/check-build-exists.ts
Normal 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;
|
||||
}
|
||||
54
electron-ui/.erb/scripts/check-native-dep.js
Normal 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');
|
||||
}
|
||||
}
|
||||
16
electron-ui/.erb/scripts/check-node-env.js
Normal 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);
|
||||
}
|
||||
}
|
||||
16
electron-ui/.erb/scripts/check-port-in-use.js
Normal 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);
|
||||
}
|
||||
});
|
||||
13
electron-ui/.erb/scripts/clean.js
Normal 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);
|
||||
});
|
||||
15
electron-ui/.erb/scripts/delete-source-maps.js
Normal 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,
|
||||
});
|
||||
}
|
||||
20
electron-ui/.erb/scripts/electron-rebuild.js
Normal 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',
|
||||
});
|
||||
}
|
||||
14
electron-ui/.erb/scripts/link-modules.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
38
electron-ui/.erb/scripts/notarize.js
Normal 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
@ -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
@ -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
@ -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
|
||||
23
electron-ui/.gitignore
vendored
@ -2,3 +2,26 @@ node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
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
@ -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;
|
||||
}
|
||||
8
electron-ui/assets/entitlements.mac.plist
Normal 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>
|
||||
BIN
electron-ui/assets/icon.icns
Normal file
BIN
electron-ui/assets/icon.ico
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
electron-ui/assets/icon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
23
electron-ui/assets/icon.svg
Normal 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 |
BIN
electron-ui/assets/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
electron-ui/assets/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
electron-ui/assets/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 954 B |
BIN
electron-ui/assets/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
electron-ui/assets/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
electron-ui/assets/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
electron-ui/assets/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
electron-ui/assets/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
electron-ui/assets/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
electron-ui/assets/icons/96x96.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
22578
electron-ui/package-lock.json
generated
@ -1,51 +1,286 @@
|
||||
{
|
||||
"name": "audio-clipper",
|
||||
"version": "1.0.0",
|
||||
"main": "src/main.js",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"python-shell": "^5.0.0",
|
||||
"wavefile": "^11.0.0",
|
||||
"wavesurfer.js": "^6.6.4"
|
||||
"name": "electron-react-boilerplate",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"keywords": [
|
||||
"electron",
|
||||
"boilerplate",
|
||||
"react",
|
||||
"typescript",
|
||||
"ts",
|
||||
"sass",
|
||||
"webpack",
|
||||
"hot",
|
||||
"reload"
|
||||
],
|
||||
"homepage": "https://github.com/electron-react-boilerplate/electron-react-boilerplate#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --enable-logging",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:linux": "electron-builder --linux"
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.michalcourson.cliptrimserivce",
|
||||
"productName": "ClipTrim",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Electron React Boilerplate Maintainers",
|
||||
"email": "electronreactboilerplate@gmail.com",
|
||||
"url": "https://electron-react-boilerplate.js.org"
|
||||
},
|
||||
"extraResources": [
|
||||
"contributors": [
|
||||
{
|
||||
"from": "../audio-service",
|
||||
"to": "audio-service",
|
||||
"filter": ["**/*"]
|
||||
"name": "Amila Welihinda",
|
||||
"email": "amilajack@gmail.com",
|
||||
"url": "https://github.com/amilajack"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": ["nsis"],
|
||||
"icon": "build/icon.ico"
|
||||
"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"
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg"],
|
||||
"icon": "build/icon.icns"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage"],
|
||||
"icon": "build/icon.png"
|
||||
"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": "^13.1.7"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
14
electron-ui/release/app/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
electron-ui/release/app/package.json
Normal 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": {}
|
||||
}
|
||||
16
electron-ui/settings.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"input_device": {
|
||||
"index": 49,
|
||||
"name": "Microphone (Logi C615 HD WebCam)",
|
||||
"channels": 1,
|
||||
"default_samplerate": 48000.0
|
||||
},
|
||||
"output_device": {
|
||||
"index": 40,
|
||||
"name": "Speakers (Realtek(R) Audio)",
|
||||
"channels": 2,
|
||||
"default_samplerate": 48000.0
|
||||
},
|
||||
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\electron-ui\\recordings",
|
||||
"recording_length": 15
|
||||
}
|
||||
@ -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">×</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>
|
||||
7
electron-ui/src/ipc/audio/channels.ts
Normal file
@ -0,0 +1,7 @@
|
||||
const AudioChannels = {
|
||||
LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer',
|
||||
GET_PORT: 'audio:getPort',
|
||||
RESTART_SERVICE: 'audio:restartService',
|
||||
} as const;
|
||||
|
||||
export default AudioChannels;
|
||||
40
electron-ui/src/ipc/audio/main.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import fs from 'fs';
|
||||
import AudioChannels from './channels';
|
||||
import { LoadAudioBufferArgs, LoadAudioBufferResult } from './types';
|
||||
import PythonSubprocessManager from '../../main/service';
|
||||
|
||||
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 };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(AudioChannels.GET_PORT, async () => {
|
||||
try {
|
||||
if (PythonSubprocessManager.instance?.portNumber) {
|
||||
return { port: PythonSubprocessManager.instance.portNumber };
|
||||
}
|
||||
|
||||
return { error: 'Port number not available yet.' };
|
||||
} catch (err: any) {
|
||||
return { error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(AudioChannels.RESTART_SERVICE, async () => {
|
||||
try {
|
||||
PythonSubprocessManager.instance?.restart();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
27
electron-ui/src/ipc/audio/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface LoadAudioBufferArgs {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface LoadAudioBufferResult {
|
||||
buffer?: Buffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetPortResult {
|
||||
port?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SetPortArgs {
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface SetPortResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RestartServiceResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
8
electron-ui/src/ipc/settings/channels.ts
Normal 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;
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
146
electron-ui/src/main/main.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/* 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';
|
||||
import PythonSubprocessManager from './service';
|
||||
|
||||
const pythonManager = new PythonSubprocessManager('src/main.py');
|
||||
|
||||
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
|
||||
pythonManager.stop();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
// pythonManager.start();
|
||||
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);
|
||||
290
electron-ui/src/main/menu.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
electron-ui/src/main/preload.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// Disable no-unused-vars, broken for spread args
|
||||
/* eslint no-unused-vars: off */
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { LoadAudioBufferArgs } 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),
|
||||
|
||||
getPort: () => ipcRenderer.invoke(AudioChannels.GET_PORT),
|
||||
restartService: () => ipcRenderer.invoke(AudioChannels.RESTART_SERVICE),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('audio', audioHandler);
|
||||
|
||||
export type AudioHandler = typeof audioHandler;
|
||||
79
electron-ui/src/main/service.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
export default class PythonSubprocessManager {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
public static instance: PythonSubprocessManager | null = null;
|
||||
|
||||
private process: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
private scriptPath: string;
|
||||
|
||||
private working_dir: string = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'audio-service',
|
||||
);
|
||||
|
||||
public portNumber: number | null = null;
|
||||
|
||||
constructor(scriptPath: string) {
|
||||
this.scriptPath = scriptPath;
|
||||
PythonSubprocessManager.instance = this;
|
||||
}
|
||||
|
||||
start(args: string[] = []): void {
|
||||
if (this.process) {
|
||||
throw new Error('Process already running.');
|
||||
}
|
||||
console.log(`Using Python working directory at: ${this.working_dir}`);
|
||||
console.log(`Starting Python subprocess with script: ${this.scriptPath}`);
|
||||
this.process = spawn(
|
||||
'venv/Scripts/python.exe',
|
||||
[this.scriptPath, ...args],
|
||||
{
|
||||
cwd: this.working_dir,
|
||||
detached: false,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
this.process.stdout.on('data', (data: Buffer) => {
|
||||
// console.log(`Python stdout: ${data.toString()}`);
|
||||
});
|
||||
this.process.stderr.on('data', (data: Buffer) => {
|
||||
// console.error(`Python stderr: ${data.toString()}`);
|
||||
const lines = data.toString().split('\n');
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const line of lines) {
|
||||
const match = line.match(/Running on .*:(\d+)/);
|
||||
if (match) {
|
||||
const port = parseInt(match[1], 10);
|
||||
console.log(`Detected port: ${port}`);
|
||||
this.portNumber = port;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.process.on('exit', () => {
|
||||
console.log('Python subprocess exited.');
|
||||
this.process = null;
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
restart(args: string[] = []): void {
|
||||
this.stop();
|
||||
this.start(args);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return !!this.process && !this.process.killed;
|
||||
}
|
||||
}
|
||||
13
electron-ui/src/main/util.ts
Normal 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)}`;
|
||||
}
|
||||
@ -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;
|
||||
97
electron-ui/src/redux/main.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
addNewClip(state, action) {
|
||||
const { clip } = action.payload;
|
||||
state.collections.forEach((collection) => {
|
||||
if (collection.name === 'Uncategorized') {
|
||||
collection.clips.push(clip);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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, addNewClip, addCollection } =
|
||||
metadataSlice.actions;
|
||||
export default metadataSlice.reducer;
|
||||
23
electron-ui/src/redux/types.ts
Normal 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[];
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||