Compare commits
43 Commits
c292350b25
...
react_migr
| Author | SHA1 | Date | |
|---|---|---|---|
| d3d5270889 | |||
| 017a2ae5a4 | |||
| 791abef1ef | |||
| 31cc3079a8 | |||
| 5e50b29625 | |||
| d37cd773f8 | |||
| 801966e8d8 | |||
| 39395fd846 | |||
| 510b92f669 | |||
| aefb3f2648 | |||
| a613b26ba8 | |||
| 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 |
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
|
||||
4
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"idf.pythonInstallPath": "C:\\Users\\mickl\\.espressif\\tools\\idf-python\\3.11.2\\python.exe"
|
||||
{
|
||||
"idf.pythonInstallPath": "C:\\Users\\mickl\\.espressif\\tools\\idf-python\\3.11.2\\python.exe"
|
||||
}
|
||||
2
audio-service/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
recordings/
|
||||
__pycache__/
|
||||
1
audio-service/build.bat
Normal file
@ -0,0 +1 @@
|
||||
python -m venv venv
|
||||
24
audio-service/metadata.json
Normal file
@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"name": "Uncategorized",
|
||||
"id": 0,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings\\audio_capture_20260228_210924.wav",
|
||||
"name": "Clip 20260228_210924",
|
||||
"playbackType": "playStop",
|
||||
"volume": 1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Test",
|
||||
"id": 1,
|
||||
"clips": []
|
||||
},
|
||||
{
|
||||
"name": "New",
|
||||
"id": 2,
|
||||
"clips": []
|
||||
}
|
||||
]
|
||||
@ -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
|
||||
}
|
||||
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 windows_audio import WindowsAudioManager
|
||||
import sounddevice as sd
|
||||
|
||||
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)
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
# 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
|
||||
|
||||
print(devices)
|
||||
for i, device in enumerate(devices):
|
||||
if args.input_device.lower() in device['name'].lower():
|
||||
input_device = device['index']
|
||||
print(f"Using input device: {device['name']}")
|
||||
break
|
||||
|
||||
# Create AudioRecorder with specified parameters
|
||||
recorder = AudioRecorder(
|
||||
duration=args.recording_length,
|
||||
recordings_dir=args.save_path,
|
||||
# channels=min(2, devices[input_device]['max_input_channels']),
|
||||
)
|
||||
|
||||
# Create OSC server with specified port
|
||||
osc_server = OSCRecordingServer(
|
||||
recorder=recorder,
|
||||
port=args.osc_port,
|
||||
audio_manager=audio_manager
|
||||
)
|
||||
|
||||
osc_server.set_audio_device(None, str(input_device))
|
||||
osc_server.start_recording(None)
|
||||
|
||||
# Run the OSC server
|
||||
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")
|
||||
|
||||
|
||||
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__":
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
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')
|
||||
|
||||
# OSC port argument
|
||||
parser.add_argument('--osc-port',
|
||||
type=int,
|
||||
help='OSC server port number',
|
||||
default=5010)
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
audio_manager = WindowsAudioManager()
|
||||
settings = SettingsManager()
|
||||
meta = MetaDataManager()
|
||||
|
||||
|
||||
|
||||
# Ensure save path exists
|
||||
os.makedirs(settings.get_settings('save_path'), exist_ok=True)
|
||||
|
||||
|
||||
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=False, allow_unsafe_werkzeug=True)
|
||||
|
||||
|
||||
|
||||
# Run the OSC server
|
||||
# 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)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
118
audio-service/src/metadata_manager.py
Normal file
@ -0,0 +1,118 @@
|
||||
import os
|
||||
import json
|
||||
from platformdirs import user_data_dir
|
||||
|
||||
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
|
||||
file_path = user_data_dir("ClipTrim")
|
||||
os.makedirs(file_path, exist_ok=True)
|
||||
print(file_path)
|
||||
self.metadata_file = os.path.join(file_path, "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
@ -1,4 +1,4 @@
|
||||
[ViewState]
|
||||
Mode=
|
||||
Vid=
|
||||
FolderType=Generic
|
||||
[ViewState]
|
||||
Mode=
|
||||
Vid=
|
||||
FolderType=Generic
|
||||
|
||||
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
|
||||
}
|
||||
105
audio-service/src/settings.py
Normal file
@ -0,0 +1,105 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
from platformdirs import user_data_dir
|
||||
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())
|
||||
file_path = user_data_dir("ClipTrim")
|
||||
os.makedirs(file_path, exist_ok=True)
|
||||
self.settings_file = os.path.join(file_path, "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(file_path, "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,97 +1,108 @@
|
||||
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):
|
||||
"""
|
||||
Initialize Windows audio device and volume management.
|
||||
"""
|
||||
self.devices = sd.query_devices()
|
||||
self.default_input = sd.default.device[0]
|
||||
self.default_output = sd.default.device[1]
|
||||
|
||||
def list_audio_devices(self, kind='input'):
|
||||
"""
|
||||
List available audio devices.
|
||||
|
||||
:param kind: 'input' or 'output'
|
||||
:return: List of audio devices
|
||||
"""
|
||||
if kind == 'input':
|
||||
return [
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'max_input_channels': dev['max_input_channels'],
|
||||
'default_samplerate': dev['default_samplerate']
|
||||
}
|
||||
for dev in self.devices if dev['max_input_channels'] > 0
|
||||
]
|
||||
elif kind == 'output':
|
||||
return [
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'max_output_channels': dev['max_output_channels'],
|
||||
'default_samplerate': dev['default_samplerate']
|
||||
}
|
||||
for dev in self.devices if dev['max_output_channels'] > 0
|
||||
]
|
||||
|
||||
def set_default_input_device(self, device_index):
|
||||
"""
|
||||
Set the default input audio device.
|
||||
|
||||
:param device_index: Index of the audio device
|
||||
:return: Sample rate of the selected device
|
||||
"""
|
||||
sd.default.device[0] = device_index
|
||||
self.default_input = device_index
|
||||
|
||||
# Get the sample rate of the selected device
|
||||
device_info = sd.query_devices(device_index)
|
||||
return device_info['default_samplerate']
|
||||
|
||||
def get_current_input_device_sample_rate(self):
|
||||
"""
|
||||
Get the sample rate of the current input device.
|
||||
|
||||
:return: Sample rate of the current input device
|
||||
"""
|
||||
device_info = sd.query_devices(self.default_input)
|
||||
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)
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import json
|
||||
|
||||
class WindowsAudioManager:
|
||||
_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.
|
||||
"""
|
||||
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]
|
||||
|
||||
def list_audio_devices(self, kind='input'):
|
||||
"""
|
||||
List available audio devices.
|
||||
|
||||
:param kind: 'input' or 'output'
|
||||
:return: List of audio devices
|
||||
"""
|
||||
if kind == 'input':
|
||||
return [
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'channels': dev['max_input_channels'],
|
||||
'default_samplerate': dev['default_samplerate']
|
||||
}
|
||||
for dev in self.devices if dev['max_input_channels'] > 0
|
||||
]
|
||||
elif kind == 'output':
|
||||
return [
|
||||
{
|
||||
'index': dev['index'],
|
||||
'name': dev['name'],
|
||||
'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.
|
||||
|
||||
:param device_index: Index of the audio device
|
||||
:return: Sample rate of the selected device
|
||||
"""
|
||||
sd.default.device[0] = device_index
|
||||
self.default_input = device_index
|
||||
|
||||
# Get the sample rate of the selected device
|
||||
device_info = sd.query_devices(device_index)
|
||||
return device_info['default_samplerate']
|
||||
|
||||
def set_default_output_device(self, device_index):
|
||||
if(device_index is None):
|
||||
return self.get_current_output_device_sample_rate()
|
||||
"""
|
||||
Set the default output audio device.
|
||||
|
||||
:param device_index: Index of the audio device
|
||||
:return: Sample rate of the selected device
|
||||
"""
|
||||
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']
|
||||
@ -1,4 +1,4 @@
|
||||
const tailwindcss = require('@tailwindcss/postcss');
|
||||
const tailwindcss = require('tailwindcss');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
|
||||
module.exports = {
|
||||
|
||||
@ -14,6 +14,9 @@ module.exports = {
|
||||
'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,
|
||||
|
||||
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.1 KiB |
BIN
electron-ui/assets/tray_icon.png
Normal file
|
After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
electron-ui/dll_err.txt
Normal file
@ -1,121 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
interface AudioTrimmerProps {
|
||||
filePath: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
const AudioTrimmer: React.FC<AudioTrimmerProps> = ({ filePath, section }) => {
|
||||
const [trimStart, setTrimStart] = useState<number>(0);
|
||||
const [trimEnd, setTrimEnd] = useState<number>(0);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||
const wavesurferRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTrimInfo = async () => {
|
||||
const savedTrimInfo = await ipcRenderer.invoke('get-trim-info', section, path.basename(filePath));
|
||||
setTrimStart(savedTrimInfo.trimStart || 0);
|
||||
setTrimEnd(savedTrimInfo.trimEnd || 0);
|
||||
};
|
||||
|
||||
loadTrimInfo();
|
||||
|
||||
wavesurferRef.current = WaveSurfer.create({
|
||||
container: waveformRef.current!,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
responsive: true,
|
||||
height: 100,
|
||||
hideScrollbar: true,
|
||||
plugins: [
|
||||
RegionsPlugin.create({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
wavesurferRef.current.load(`file://${filePath}`);
|
||||
|
||||
wavesurferRef.current.on('ready', () => {
|
||||
wavesurferRef.current.addRegion({
|
||||
start: trimStart,
|
||||
end: trimEnd,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
});
|
||||
});
|
||||
|
||||
wavesurferRef.current.on('region-update-end', (region: any) => {
|
||||
setTrimStart(region.start);
|
||||
setTrimEnd(region.end);
|
||||
});
|
||||
|
||||
return () => {
|
||||
wavesurferRef.current.destroy();
|
||||
};
|
||||
}, [filePath, section, trimStart, trimEnd]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (isPlaying) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
wavesurferRef.current.play(trimStart, trimEnd);
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const handleSaveTrim = async () => {
|
||||
const newTitle = prompt('Enter a title for the trimmed audio:');
|
||||
if (newTitle) {
|
||||
await ipcRenderer.invoke('save-trimmed-file', {
|
||||
originalFilePath: filePath,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
title: newTitle,
|
||||
});
|
||||
alert('Trimmed audio saved successfully!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmDelete = confirm('Are you sure you want to delete this audio file?');
|
||||
if (confirmDelete) {
|
||||
await ipcRenderer.invoke('delete-file', filePath);
|
||||
alert('File deleted successfully!');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audio-trimmer-item">
|
||||
<div className="audio-trimmer-header">
|
||||
<div className="audio-trimmer-title">{path.basename(filePath)}</div>
|
||||
<div className="audio-trimmer-controls">
|
||||
<button onClick={handlePlayPause}>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<button onClick={handleSaveTrim}>Save Trim</button>
|
||||
<button onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={waveformRef} className="waveform"></div>
|
||||
<div className="trim-info">
|
||||
<div>Start: {formatTime(trimStart)}</div>
|
||||
<div>End: {formatTime(trimEnd)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default AudioTrimmer;
|
||||
@ -1,138 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
interface AudioTrimmerProps {
|
||||
filePath: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
const AudioTrimmer: React.FC<AudioTrimmerProps> = ({ filePath, section }) => {
|
||||
const [trimStart, setTrimStart] = useState(0);
|
||||
const [trimEnd, setTrimEnd] = useState(0);
|
||||
const [title, setTitle] = useState('');
|
||||
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTrimInfo = async () => {
|
||||
const savedTrimInfo = await ipcRenderer.invoke('get-trim-info', section, path.basename(filePath));
|
||||
setTrimStart(savedTrimInfo.trimStart || 0);
|
||||
setTrimEnd(savedTrimInfo.trimEnd || 0);
|
||||
setTitle(savedTrimInfo.title || path.basename(filePath));
|
||||
};
|
||||
|
||||
loadTrimInfo();
|
||||
}, [filePath, section]);
|
||||
|
||||
useEffect(() => {
|
||||
if (waveformRef.current) {
|
||||
wavesurferRef.current = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
responsive: true,
|
||||
height: 100,
|
||||
hideScrollbar: true,
|
||||
plugins: [
|
||||
RegionsPlugin.create({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
wavesurferRef.current.load(`file://${filePath}`);
|
||||
|
||||
wavesurferRef.current.on('ready', () => {
|
||||
wavesurferRef.current?.addRegion({
|
||||
start: trimStart,
|
||||
end: trimEnd,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
});
|
||||
});
|
||||
|
||||
wavesurferRef.current.on('region-update-end', (region) => {
|
||||
setTrimStart(region.start);
|
||||
setTrimEnd(region.end);
|
||||
});
|
||||
|
||||
return () => {
|
||||
wavesurferRef.current?.destroy();
|
||||
};
|
||||
}
|
||||
}, [filePath, trimStart, trimEnd]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (wavesurferRef.current) {
|
||||
if (wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
wavesurferRef.current.play(trimStart, trimEnd);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTrim = async () => {
|
||||
const newTitle = title.trim();
|
||||
await ipcRenderer.invoke('save-trimmed-file', {
|
||||
originalFilePath: filePath,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
title: newTitle,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmDelete = window.confirm('Are you sure you want to delete this audio file?');
|
||||
if (confirmDelete) {
|
||||
await ipcRenderer.invoke('delete-file', filePath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audio-trimmer-item" data-filepath={filePath}>
|
||||
<div className="audio-trimmer-header">
|
||||
<div className="audio-trimmer-title-container">
|
||||
<div className="audio-trimmer-title">{title}</div>
|
||||
<div className="audio-trimmer-filename">{path.basename(filePath)}</div>
|
||||
</div>
|
||||
<div className="audio-trimmer-controls">
|
||||
<button className="play-pause-btn" onClick={handlePlayPause}>
|
||||
Play/Pause
|
||||
</button>
|
||||
<button className="save-trim" onClick={handleSaveTrim}>
|
||||
Save
|
||||
</button>
|
||||
<button className="delete-btn" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="waveform-container" ref={waveformRef}></div>
|
||||
<div className="trim-info">
|
||||
<div className="trim-time">
|
||||
<span>Start: </span>
|
||||
<span>{formatTime(trimStart)}</span>
|
||||
</div>
|
||||
<div className="trim-time">
|
||||
<span>End: </span>
|
||||
<span>{formatTime(trimEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default AudioTrimmer;
|
||||
@ -1,16 +0,0 @@
|
||||
// This file is intended for defining TypeScript types and interfaces that can be used throughout the application.
|
||||
|
||||
export interface TrimInfo {
|
||||
title?: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
export interface AudioTrimmerProps {
|
||||
filePath: string;
|
||||
section: string;
|
||||
savedTrimInfo: TrimInfo;
|
||||
onSave: (trimInfo: TrimInfo) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
2344
electron-ui/package-lock.json
generated
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"name": "cliptrim-ui",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"keywords": [
|
||||
"electron",
|
||||
@ -12,27 +12,7 @@
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Electron React Boilerplate Maintainers",
|
||||
"email": "electronreactboilerplate@gmail.com",
|
||||
"url": "https://electron-react-boilerplate.js.org"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Amila Welihinda",
|
||||
"email": "amilajack@gmail.com",
|
||||
"url": "https://github.com/amilajack"
|
||||
}
|
||||
],
|
||||
"main": "./.erb/dll/main.bundle.dev.js",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
@ -42,14 +22,15 @@
|
||||
"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",
|
||||
"package": "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"
|
||||
"test": "jest",
|
||||
"build:win": "electron-builder --win"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends browserslist-config-erb"
|
||||
@ -101,8 +82,16 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@electron/notarize": "^3.0.0",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@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/postcss": "^4.1.18",
|
||||
"@wavesurfer/react": "^1.0.12",
|
||||
"electron-debug": "^4.1.0",
|
||||
@ -110,14 +99,18 @@
|
||||
"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",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socketio": "^1.0.0",
|
||||
"wavesurfer.js": "^7.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
@ -129,7 +122,7 @@
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"browserslist-config-erb": "^0.0.3",
|
||||
"chalk": "^4.1.2",
|
||||
"concurrently": "^9.1.2",
|
||||
@ -161,7 +154,7 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-loader": "^8.2.0",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"react-refresh": "^0.16.0",
|
||||
"react-test-renderer": "^19.0.0",
|
||||
@ -169,6 +162,7 @@
|
||||
"sass": "^1.86.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-jest": "^29.2.6",
|
||||
"ts-loader": "^9.5.2",
|
||||
@ -182,9 +176,10 @@
|
||||
"webpack-dev-server": "^5.2.0",
|
||||
"webpack-merge": "^6.0.1"
|
||||
},
|
||||
"version": "2.0.0",
|
||||
"build": {
|
||||
"productName": "ElectronReact",
|
||||
"appId": "org.erb.ElectronReact",
|
||||
"productName": "ClipTrim",
|
||||
"appId": "com.michalcourson.cliptrimserivce",
|
||||
"asar": true,
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"asarUnpack": "**\\*.{node,dll}",
|
||||
@ -225,7 +220,8 @@
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
@ -239,13 +235,18 @@
|
||||
"output": "release/build"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../audio-service",
|
||||
"to": "audio-service",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.json",
|
||||
"!**/recordings/*",
|
||||
"!**/src/__pycache__/*"
|
||||
]
|
||||
},
|
||||
"./assets/**"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "electron-react-boilerplate",
|
||||
"repo": "electron-react-boilerplate"
|
||||
}
|
||||
]
|
||||
},
|
||||
"collective": {
|
||||
"url": "https://opencollective.com/electron-react-boilerplate-594"
|
||||
|
||||
8
electron-ui/release/app/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"name": "cliptrim",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"name": "cliptrim",
|
||||
"version": "2.0.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"name": "cliptrim",
|
||||
"version": "2.0.2",
|
||||
"description": "Clip and trim",
|
||||
"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",
|
||||
|
||||
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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
electron-ui/src/assets/tray_icon.png
Normal file
|
After Width: | Height: | Size: 780 B |
7
electron-ui/src/ipc/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/main.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { dialog, 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/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;
|
||||
}
|
||||
@ -10,11 +10,23 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, BrowserWindow, shell, ipcMain } from 'electron';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
ipcMain,
|
||||
Tray,
|
||||
Menu,
|
||||
dialog,
|
||||
} from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import MenuBuilder from './menu';
|
||||
import { resolveHtmlPath } from './util';
|
||||
import registerFileIpcHandlers from '../ipc/main';
|
||||
import PythonSubprocessManager from './service';
|
||||
|
||||
const pythonManager = new PythonSubprocessManager('src/main.py');
|
||||
|
||||
class AppUpdater {
|
||||
constructor() {
|
||||
@ -24,7 +36,8 @@ class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let mainWindow: BrowserWindow;
|
||||
let tray: Tray | null = null;
|
||||
|
||||
ipcMain.on('ipc-example', async (event, arg) => {
|
||||
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
|
||||
@ -74,7 +87,7 @@ const createWindow = async () => {
|
||||
show: false,
|
||||
width: 1024,
|
||||
height: 728,
|
||||
icon: getAssetPath('icon.png'),
|
||||
icon: getAssetPath('icon.png'), // Set app icon
|
||||
webPreferences: {
|
||||
preload: app.isPackaged
|
||||
? path.join(__dirname, 'preload.js')
|
||||
@ -95,12 +108,30 @@ const createWindow = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
mainWindow.on('close', (event) => {
|
||||
console.log('close event triggered');
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
const menuBuilder = new MenuBuilder(mainWindow);
|
||||
menuBuilder.buildMenu();
|
||||
registerFileIpcHandlers();
|
||||
|
||||
ipcMain.handle('select-directory', async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory'], // Key property to select a folder
|
||||
});
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
// Send the selected directory path back to the renderer process
|
||||
return result.filePaths[0];
|
||||
}
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
return { error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Open urls in the user's browser
|
||||
mainWindow.webContents.setWindowOpenHandler((edata) => {
|
||||
@ -108,20 +139,34 @@ const createWindow = async () => {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
ipcMain.handle('load-audio-buffer', async (event, filePath) => {
|
||||
try {
|
||||
// console.log(`Loading audio file: ${filePath}`);
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
// console.log(buffer);
|
||||
return buffer;
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Remove this if your app does not use auto updates
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
console.log('asset path: ', getAssetPath('tray_icon.png'));
|
||||
tray = new Tray(getAssetPath('tray_icon.png'));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show',
|
||||
click: () => {
|
||||
mainWindow?.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
pythonManager.stop();
|
||||
tray?.destroy();
|
||||
mainWindow.close();
|
||||
mainWindow.destroy();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
tray.setToolTip('ClipTrim');
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.on('double-click', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -131,14 +176,19 @@ const createWindow = async () => {
|
||||
app.on('window-all-closed', () => {
|
||||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
// pythonManager.stop();
|
||||
// Do not quit app, keep tray active
|
||||
// if (process.platform !== 'darwin') {
|
||||
// app.quit();
|
||||
// }
|
||||
});
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
// if (app.isPackaged) {
|
||||
pythonManager.start();
|
||||
// }
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
// Disable no-unused-vars, broken for spread args
|
||||
/* eslint no-unused-vars: off */
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { LoadAudioBufferArgs } from '../ipc/types';
|
||||
import AudioChannels from '../ipc/channels';
|
||||
// import '../ipc/file/preload'; // Import file API preload to ensure it runs and exposes the API
|
||||
|
||||
export type Channels = 'ipc-example';
|
||||
|
||||
@ -22,11 +25,25 @@ const electronHandler = {
|
||||
ipcRenderer.once(channel, (_event, ...args) => func(...args));
|
||||
},
|
||||
|
||||
loadAudioBuffer: (filePath: string) =>
|
||||
ipcRenderer.invoke('load-audio-buffer', filePath),
|
||||
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;
|
||||
|
||||
81
electron-ui/src/main/service.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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) {
|
||||
// for some reason, process.kill() doens't work well with flask. todo: investigate further
|
||||
// spawn('taskkill', ['/pid', `${this.process.pid}`, '/f', '/t']);
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
restart(args: string[] = []): void {
|
||||
this.stop();
|
||||
this.start(args);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return !!this.process && !this.process.killed;
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Audio Clip Trimmer</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="titlebar"></div>
|
||||
<div class="app-container">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>Collections</h3>
|
||||
<div id="collections-list"></div>
|
||||
<button id="add-collection-btn" class="add-collection-btn">
|
||||
+ New Collection
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div id="nav-buttons">
|
||||
<button id="settings-btn" class="nav-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="restart-btn" class="nav-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<div class="audio-trimmers-section">
|
||||
<div id="audio-trimmers-list" class="audio-trimmers-list">
|
||||
<!-- Audio trimmers will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal">×</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>
|
||||
@ -1,483 +0,0 @@
|
||||
const { app, BrowserWindow, ipcMain, Tray, Menu, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const spawn = require('child_process').spawn;
|
||||
require('electron-reload')(__dirname);
|
||||
const fs = require('fs').promises;
|
||||
const chokidar = require('chokidar');
|
||||
const wavefile = require('wavefile');
|
||||
const MetadataManager = require('./metatadata');
|
||||
|
||||
const { webContents } = require('electron');
|
||||
|
||||
// import { app, BrowserWindow, ipcMain, Tray, Menu, dialog } from "electron";
|
||||
// import path from "path";
|
||||
// import os from "os";
|
||||
// import spawn from 'child_process';
|
||||
// import fs from "fs";
|
||||
// import chokidar from "chokidar";
|
||||
// import wavefile from "wavefile";
|
||||
// import MetadataManager from "./metatadata.cjs";
|
||||
// import { webContents } from "electron";
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
let audioServiceProcess;
|
||||
|
||||
const metadataPath = path.join(app.getPath('userData'), 'audio_metadata.json');
|
||||
const metadataManager = new MetadataManager(metadataPath);
|
||||
|
||||
async function createPythonService() {
|
||||
const pythonPath =
|
||||
process.platform === 'win32'
|
||||
? path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'audio-service',
|
||||
'venv',
|
||||
'Scripts',
|
||||
'python.exe',
|
||||
)
|
||||
: path.join(__dirname, '..', 'audio-service', 'venv', 'bin', 'python');
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'audio-service',
|
||||
'src',
|
||||
'main.py',
|
||||
);
|
||||
|
||||
// Load settings to pass as arguments
|
||||
const settings = await loadSettings(); // Assuming you have a method to load settings
|
||||
|
||||
const args = [
|
||||
scriptPath,
|
||||
'--recording-length',
|
||||
settings.recordingLength.toString(),
|
||||
'--save-path',
|
||||
path.join(settings.outputFolder, 'original'),
|
||||
'--osc-port',
|
||||
settings.oscPort.toString(), // Or make this configurable
|
||||
];
|
||||
|
||||
// Add input device if specified
|
||||
if (settings.inputDevice) {
|
||||
const devices = await listAudioDevices();
|
||||
args.push(
|
||||
'--input-device',
|
||||
devices.find((device) => device.id === settings.inputDevice)?.name,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(args);
|
||||
|
||||
audioServiceProcess = spawn(pythonPath, args, {
|
||||
detached: false,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
audioServiceProcess.stdout.on('data', (data) => {
|
||||
console.log(`Audio Service: ${data}`);
|
||||
});
|
||||
|
||||
audioServiceProcess.stderr.on('data', (data) => {
|
||||
console.error(`Audio Service Error: ${data}`);
|
||||
});
|
||||
|
||||
audioServiceProcess.on('close', (code) => {
|
||||
console.log(`Audio Service process exited with code ${code}`);
|
||||
audioServiceProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(path.join(__dirname, 'assets', 'tray-icon.png')); // You'll need to create this icon
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
// Properly terminate the Python service
|
||||
|
||||
stopService();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setToolTip('Audio Trimmer');
|
||||
tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
async function checkNewWavFile(filePath) {
|
||||
// Only process .wav files
|
||||
if (path.extname(filePath).toLowerCase() === '.wav') {
|
||||
try {
|
||||
await metadataManager.addUntrimmedFile(filePath);
|
||||
|
||||
// Notify renderer if window is ready
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('new-untrimmed-file', filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding untrimmed file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopService() {
|
||||
if (audioServiceProcess) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
|
||||
} else {
|
||||
audioServiceProcess.kill('SIGTERM');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error killing audio service:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restartService() {
|
||||
// Properly terminate the Python service
|
||||
stopService();
|
||||
//delay for 2 seconds
|
||||
setTimeout(createPythonService, 4000);
|
||||
//createPythonService();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
const settingsData = await fs.readFile(settingsPath, 'utf8');
|
||||
return JSON.parse(settingsData);
|
||||
} catch (error) {
|
||||
// If no settings file exists, return default settings
|
||||
return {
|
||||
recordingLength: 30,
|
||||
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
|
||||
inputDevice: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function listAudioDevices() {
|
||||
try {
|
||||
// Use a webContents to access navigator.mediaDevices
|
||||
|
||||
const contents = webContents.getAllWebContents()[0];
|
||||
|
||||
const devices = await contents.executeJavaScript(`
|
||||
navigator.mediaDevices.enumerateDevices()
|
||||
.then(devices => devices.filter(device => device.kind === 'audioinput'))
|
||||
.then(audioDevices => audioDevices.map(device => ({
|
||||
id: device.deviceId,
|
||||
name: device.label || 'Unknown Microphone'
|
||||
})))
|
||||
`);
|
||||
|
||||
return devices;
|
||||
} catch (error) {
|
||||
console.error('Error getting input devices:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function createWindow() {
|
||||
// Initialize metadata
|
||||
await metadataManager.initialize();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
frame: false,
|
||||
|
||||
// titleBarOverlay: {
|
||||
// color: '#1e1e1e',
|
||||
// symbolColor: '#ffffff',
|
||||
// height: 30
|
||||
// },
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
// Add these to help with graphics issues
|
||||
},
|
||||
// These additional options can help with graphics rendering
|
||||
backgroundColor: '#1e1e1e',
|
||||
...(process.platform !== 'darwin'
|
||||
? {
|
||||
titleBarOverlay: {
|
||||
color: '#262626',
|
||||
symbolColor: '#ffffff',
|
||||
height: 30,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
mainWindow.loadFile('src/index.html');
|
||||
|
||||
// Create Python ser
|
||||
const settings = await loadSettings(); // Assuming you have a method to load settings
|
||||
const recordingsPath = path.join(settings.outputFolder, 'original');
|
||||
// Ensure recordings directory exists
|
||||
try {
|
||||
await fs.mkdir(recordingsPath, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating recordings directory:', error);
|
||||
}
|
||||
|
||||
// Watch for new WAV files
|
||||
const watcher = chokidar.watch(recordingsPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
depth: 0,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 2000,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
fs.readdir(recordingsPath).then((files) => {
|
||||
files.forEach((file) => {
|
||||
checkNewWavFile(path.join(recordingsPath, file));
|
||||
});
|
||||
});
|
||||
|
||||
watcher.on('add', async (filePath) => {
|
||||
await checkNewWavFile(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-collections', () => {
|
||||
return metadataManager.getCollections();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-collection-files', (event, collectionPath) => {
|
||||
return metadataManager.getFilesInCollection(collectionPath);
|
||||
});
|
||||
|
||||
ipcMain.handle('add-untrimmed-file', (event, filePath) => {
|
||||
return metadataManager.addUntrimmedFile(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'save-trimmed-file',
|
||||
(event, fileName, previousPath, savePath, trimStart, trimEnd, title) => {
|
||||
return metadataManager.saveTrimmedFile(
|
||||
fileName,
|
||||
previousPath,
|
||||
savePath,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
title,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('restart', (event) => {
|
||||
restartService();
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-old-file', (event, outputFolder, section, title) => {
|
||||
if (section === 'untrimmed') return;
|
||||
const collectionPath = path.join(outputFolder, section);
|
||||
const outputFilePath = path.join(collectionPath, `${title}.wav`);
|
||||
fs.unlink(outputFilePath);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'save-trimmed-audio',
|
||||
async (
|
||||
event,
|
||||
{
|
||||
originalFilePath,
|
||||
outputFolder,
|
||||
collectionName,
|
||||
title,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
// Ensure the collection folder exists
|
||||
const collectionPath = path.join(outputFolder, collectionName);
|
||||
await fs.mkdir(collectionPath, { recursive: true });
|
||||
|
||||
// Generate output file path
|
||||
const outputFilePath = path.join(collectionPath, `${title}.wav`);
|
||||
|
||||
// Read the original WAV file
|
||||
const originalWaveFile = new wavefile.WaveFile(
|
||||
await fs.readFile(originalFilePath),
|
||||
);
|
||||
|
||||
// Calculate trim points in samples
|
||||
const sampleRate = originalWaveFile.fmt.sampleRate;
|
||||
const startSample = Math.floor(trimStart * sampleRate);
|
||||
const endSample = Math.floor(trimEnd * sampleRate);
|
||||
|
||||
// Extract trimmed audio samples
|
||||
const originalSamples = originalWaveFile.getSamples(false);
|
||||
const trimmedSamples = [
|
||||
originalSamples[0].slice(startSample, endSample),
|
||||
originalSamples[1].slice(startSample, endSample),
|
||||
];
|
||||
|
||||
// Normalize samples if they are Int16 or Int32
|
||||
let normalizedSamples;
|
||||
const bitDepth = originalWaveFile.fmt.bitsPerSample;
|
||||
|
||||
// if (bitDepth === 16) {
|
||||
// // For 16-bit audio, convert to Float32
|
||||
// normalizedSamples = [new Float32Array(trimmedSamples[0].length),new Float32Array(trimmedSamples[0].length)];
|
||||
// for (let i = 0; i < trimmedSamples[0].length; i++) {
|
||||
// normalizedSamples[0][i] = trimmedSamples[0][i] / 32768.0;
|
||||
// normalizedSamples[1][i] = trimmedSamples[1][i] / 32768.0;
|
||||
// }
|
||||
// } else if (bitDepth === 32) {
|
||||
// // For 32-bit float audio, just convert to Float32
|
||||
// normalizedSamples = [new Float32Array(trimmedSamples[0]),new Float32Array(trimmedSamples[1])];
|
||||
// } else {
|
||||
// throw new Error(`Unsupported bit depth: ${bitDepth}`);
|
||||
// }
|
||||
|
||||
// Create a new WaveFile with normalized samples
|
||||
const trimmedWaveFile = new wavefile.WaveFile();
|
||||
trimmedWaveFile.fromScratch(
|
||||
originalWaveFile.fmt.numChannels,
|
||||
sampleRate,
|
||||
bitDepth, // Always use 32-bit float
|
||||
trimmedSamples,
|
||||
);
|
||||
|
||||
// Write the trimmed WAV file
|
||||
await fs.writeFile(outputFilePath, trimmedWaveFile.toBuffer());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: outputFilePath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error saving trimmed audio:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle('delete-file', async (event, filePath) => {
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
return metadataManager.deletefile(filePath, settings.outputFolder);
|
||||
} catch (error) {
|
||||
console.error('Error Deleting file:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('add-new-collection', (event, collectionName) => {
|
||||
try {
|
||||
return metadataManager.addNewCollection(collectionName);
|
||||
} catch (error) {
|
||||
console.error('Error adding collection:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
ipcMain.handle('get-trim-info', (event, collectionName, filePath) => {
|
||||
return metadataManager.getTrimInfo(collectionName, filePath);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'set-trim-info',
|
||||
(event, collectionName, filePath, trim_info) => {
|
||||
return metadataManager.setTrimInfo(collectionName, filePath, trim_info);
|
||||
},
|
||||
);
|
||||
|
||||
// Add these IPC handlers
|
||||
ipcMain.handle('select-output-folder', async (event) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
return result.filePaths[0] || '';
|
||||
});
|
||||
|
||||
ipcMain.handle('get-default-settings', () => {
|
||||
return {
|
||||
recordingLength: 30,
|
||||
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
|
||||
inputDevice: null,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('save-settings', async (event, settings) => {
|
||||
try {
|
||||
// Ensure output folder exists
|
||||
await fs.mkdir(settings.outputFolder, { recursive: true });
|
||||
|
||||
// Save settings to a file
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
restartService();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('load-settings', async () => {
|
||||
return loadSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-input-devices', async () => {
|
||||
return await listAudioDevices();
|
||||
});
|
||||
|
||||
// Minimize to tray instead of closing
|
||||
mainWindow.on('close', (event) => {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
// Create system tray
|
||||
createTray();
|
||||
|
||||
// Launch Python audio service
|
||||
createPythonService();
|
||||
}
|
||||
app.disableHardwareAcceleration();
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Do nothing - we handle closing via tray
|
||||
});
|
||||
|
||||
// Ensure Python service is killed when app quits
|
||||
app.on('before-quit', () => {
|
||||
if (audioServiceProcess) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
|
||||
} else {
|
||||
audioServiceProcess.kill('SIGTERM');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error killing audio service:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
@ -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;
|
||||
@ -1,818 +0,0 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
// const path = require('path');
|
||||
const WaveSurfer = require('wavesurfer.js');
|
||||
const RegionsPlugin = require('wavesurfer.js/dist/plugin/wavesurfer.regions.js');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Settings Modal Logic
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsBtn = document.getElementById('settings-btn');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const closeModalBtn = document.querySelector('.close-modal');
|
||||
const saveSettingsBtn = document.getElementById('save-settings');
|
||||
const selectOutputFolderBtn = document.getElementById('select-output-folder');
|
||||
const recordingLengthInput = document.getElementById('recording-length');
|
||||
const oscPortInput = document.getElementById('osc-port');
|
||||
const outputFolderInput = document.getElementById('output-folder');
|
||||
const inputDeviceSelect = document.getElementById('input-device');
|
||||
|
||||
// Open settings modal
|
||||
settingsBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
// Request microphone permissions first
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// Load current settings
|
||||
const settings = await ipcRenderer.invoke('load-settings');
|
||||
|
||||
// Populate input devices
|
||||
const devices = await ipcRenderer.invoke('get-input-devices');
|
||||
|
||||
if (devices.length === 0) {
|
||||
inputDeviceSelect.innerHTML = '<option>No microphones found</option>';
|
||||
} else {
|
||||
inputDeviceSelect.innerHTML = devices
|
||||
.map(
|
||||
(device) => `<option value="${device.id}">${device.name}</option>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Set current settings
|
||||
recordingLengthInput.value = settings.recordingLength;
|
||||
outputFolderInput.value = settings.outputFolder;
|
||||
inputDeviceSelect.value = settings.inputDevice;
|
||||
oscPortInput.value = settings.oscPort;
|
||||
|
||||
settingsModal.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Error loading settings or devices:', error);
|
||||
alert('Please grant microphone permissions to list audio devices');
|
||||
}
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await ipcRenderer.invoke('restart');
|
||||
} catch (error) {
|
||||
console.error('Error restarting:', error);
|
||||
alert('Failed to restart Clipper');
|
||||
}
|
||||
});
|
||||
|
||||
// Close settings modal
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
settingsModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Select output folder
|
||||
selectOutputFolderBtn.addEventListener('click', async () => {
|
||||
const folderPath = await ipcRenderer.invoke('select-output-folder');
|
||||
if (folderPath) {
|
||||
outputFolderInput.value = folderPath;
|
||||
}
|
||||
});
|
||||
|
||||
// Save settings
|
||||
saveSettingsBtn.addEventListener('click', async () => {
|
||||
const settings = {
|
||||
recordingLength: parseInt(recordingLengthInput.value),
|
||||
oscPort: parseInt(oscPortInput.value),
|
||||
outputFolder: outputFolderInput.value,
|
||||
inputDevice: inputDeviceSelect.value,
|
||||
};
|
||||
|
||||
const saved = await ipcRenderer.invoke('save-settings', settings);
|
||||
if (saved) {
|
||||
settingsModal.style.display = 'none';
|
||||
} else {
|
||||
alert('Failed to save settings');
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal if clicked outside
|
||||
window.addEventListener('click', (event) => {
|
||||
if (event.target === settingsModal) {
|
||||
settingsModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
const audioTrimmersList = document.getElementById('audio-trimmers-list');
|
||||
const collectionsList = document.getElementById('collections-list');
|
||||
//const currentSectionTitle = document.getElementById("current-section-title");
|
||||
|
||||
// Global state to persist wavesurfer instances and trimmer states
|
||||
const globalState = {
|
||||
wavesurferInstances: {},
|
||||
trimmerStates: {},
|
||||
currentSection: 'untrimmed',
|
||||
trimmerElements: {},
|
||||
};
|
||||
// Utility function to format time
|
||||
function formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Populate collections list
|
||||
async function populateCollectionsList() {
|
||||
const collections = await ipcRenderer.invoke('get-collections');
|
||||
|
||||
collectionsList.innerHTML = '';
|
||||
|
||||
// Always add Untrimmed section first
|
||||
const untrimmedItem = document.createElement('div');
|
||||
untrimmedItem.classList.add('collection-item');
|
||||
untrimmedItem.textContent = 'Untrimmed';
|
||||
untrimmedItem.dataset.collection = 'untrimmed';
|
||||
|
||||
untrimmedItem.addEventListener('click', () => {
|
||||
loadCollectionFiles('untrimmed');
|
||||
});
|
||||
|
||||
collectionsList.appendChild(untrimmedItem);
|
||||
|
||||
// Add other collections
|
||||
collections.forEach((collection) => {
|
||||
if (collection === 'untrimmed') {
|
||||
return;
|
||||
}
|
||||
const collectionItem = document.createElement('div');
|
||||
collectionItem.classList.add('collection-item');
|
||||
collectionItem.textContent = collection;
|
||||
collectionItem.dataset.collection = collection;
|
||||
|
||||
collectionItem.addEventListener('click', () => {
|
||||
loadCollectionFiles(collection);
|
||||
});
|
||||
|
||||
collectionsList.appendChild(collectionItem);
|
||||
});
|
||||
}
|
||||
|
||||
// Modify loadCollectionFiles function
|
||||
async function loadCollectionFiles(collection) {
|
||||
if (collection !== globalState.currentSection) {
|
||||
//Clear existing trimmers and reset global state
|
||||
Object.keys(globalState.trimmerElements).forEach((filePath) => {
|
||||
const trimmerElement = globalState.trimmerElements[filePath];
|
||||
if (trimmerElement && trimmerElement.parentNode) {
|
||||
trimmerElement.parentNode.removeChild(trimmerElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset global state
|
||||
globalState.trimmerElements = {};
|
||||
globalState.wavesurferInstances = {};
|
||||
globalState.trimmerStates = {};
|
||||
}
|
||||
|
||||
// Reset active states
|
||||
document.querySelectorAll('.collection-item').forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
// Set active state only for existing items
|
||||
const activeItem = document.querySelector(
|
||||
`.collection-item[data-collection="${collection}"]`,
|
||||
);
|
||||
|
||||
// Only add active class if the item exists
|
||||
if (activeItem) {
|
||||
activeItem.classList.add('active');
|
||||
}
|
||||
|
||||
// Update section title and global state
|
||||
//currentSectionTitle.textContent = collection;
|
||||
globalState.currentSection = collection;
|
||||
|
||||
// Load files
|
||||
const files = await ipcRenderer.invoke('get-collection-files', collection);
|
||||
|
||||
// Add new trimmers with saved trim information
|
||||
for (const file of files) {
|
||||
const filePath = file.originalPath || file.fileName;
|
||||
|
||||
// If loading a collection, use saved trim information
|
||||
//if (collection !== "untrimmed") {
|
||||
// Store trim information in global state before creating trimmer
|
||||
// globalState.trimmerStates[filePath] = {
|
||||
// trimStart: file.trimStart || 0,
|
||||
// trimEnd: file.trimEnd || 0,
|
||||
// regionStart: file.trimStart || 0,
|
||||
// regionEnd: file.trimEnd || 0,
|
||||
// originalPath: file.originalPath,
|
||||
// };
|
||||
//}
|
||||
|
||||
createAudioTrimmer(filePath, collection);
|
||||
}
|
||||
}
|
||||
// Create audio trimmer for a single file
|
||||
async function createAudioTrimmer(filePath, section) {
|
||||
// Check if trimmer already exists
|
||||
if (globalState.trimmerElements[filePath]) {
|
||||
return globalState.trimmerElements[filePath];
|
||||
}
|
||||
|
||||
const savedTrimInfo = await ipcRenderer.invoke(
|
||||
'get-trim-info',
|
||||
globalState.currentSection,
|
||||
path.basename(filePath),
|
||||
);
|
||||
// Create trimmer container
|
||||
const trimmerContainer = document.createElement('div');
|
||||
trimmerContainer.classList.add('audio-trimmer-item');
|
||||
trimmerContainer.dataset.filepath = filePath;
|
||||
|
||||
// Create header with title and controls
|
||||
const trimmerHeader = document.createElement('div');
|
||||
trimmerHeader.classList.add('audio-trimmer-header');
|
||||
|
||||
// Title container
|
||||
const titleContainer = document.createElement('div');
|
||||
titleContainer.classList.add('audio-trimmer-title-container');
|
||||
|
||||
if (savedTrimInfo.title) {
|
||||
// Title
|
||||
const title = document.createElement('div');
|
||||
title.classList.add('audio-trimmer-title');
|
||||
title.textContent = savedTrimInfo.title;
|
||||
titleContainer.appendChild(title);
|
||||
|
||||
// Filename
|
||||
const fileName = document.createElement('div');
|
||||
fileName.classList.add('audio-trimmer-filename');
|
||||
fileName.textContent = path.basename(filePath);
|
||||
titleContainer.appendChild(fileName);
|
||||
} else {
|
||||
// Title (using filename if no custom title)
|
||||
const title = document.createElement('div');
|
||||
title.classList.add('audio-trimmer-title');
|
||||
title.textContent = path.basename(filePath);
|
||||
titleContainer.appendChild(title);
|
||||
|
||||
// Filename
|
||||
const fileName = document.createElement('div');
|
||||
fileName.classList.add('audio-trimmer-filename');
|
||||
fileName.textContent = 'hidden';
|
||||
fileName.style.opacity = 0;
|
||||
titleContainer.appendChild(fileName);
|
||||
}
|
||||
|
||||
// Controls container
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.classList.add('audio-trimmer-controls');
|
||||
|
||||
// Play/Pause and Save buttons
|
||||
const playPauseBtn = document.createElement('button');
|
||||
playPauseBtn.classList.add('play-pause-btn');
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const saveTrimButton = document.createElement('button');
|
||||
saveTrimButton.classList.add('save-trim');
|
||||
saveTrimButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const deletebutton = document.createElement('button');
|
||||
deletebutton.classList.add('play-pause-btn');
|
||||
deletebutton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
controlsContainer.appendChild(playPauseBtn);
|
||||
controlsContainer.appendChild(saveTrimButton);
|
||||
controlsContainer.appendChild(deletebutton);
|
||||
|
||||
// Assemble header
|
||||
trimmerHeader.appendChild(titleContainer);
|
||||
trimmerHeader.appendChild(controlsContainer);
|
||||
trimmerContainer.appendChild(trimmerHeader);
|
||||
|
||||
// Waveform container
|
||||
const waveformContainer = document.createElement('div');
|
||||
waveformContainer.classList.add('waveform-container');
|
||||
const waveformId = `waveform-${path.basename(
|
||||
filePath,
|
||||
path.extname(filePath),
|
||||
)}`;
|
||||
waveformContainer.innerHTML = `
|
||||
<div id="${waveformId}" class="waveform"></div>
|
||||
`;
|
||||
trimmerContainer.appendChild(waveformContainer);
|
||||
|
||||
// Time displays
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.classList.add('trim-info');
|
||||
timeInfo.innerHTML = `
|
||||
<div class="trim-time">
|
||||
<span>Start: </span>
|
||||
<span class="trim-start-time">0:00</span>
|
||||
</div>
|
||||
<div class="trim-time">
|
||||
<span>End: </span>
|
||||
<span class="trim-end-time">0:00</span>
|
||||
</div>
|
||||
`;
|
||||
// const zoomContainer = document.createElement('div');
|
||||
// zoomContainer.className = 'zoom-controls';
|
||||
// zoomContainer.innerHTML = `
|
||||
// <button class="zoom-in">+</button>
|
||||
// <button class="zoom-out">-</button>
|
||||
// <input type="range" min="1" max="200" value="100" class="zoom-slider">
|
||||
// `;
|
||||
// timeInfo.appendChild(zoomContainer);
|
||||
|
||||
// const zoomInBtn = zoomContainer.querySelector('.zoom-in');
|
||||
// const zoomOutBtn = zoomContainer.querySelector('.zoom-out');
|
||||
// const zoomSlider = zoomContainer.querySelector('.zoom-slider');
|
||||
|
||||
// // Zoom functionality
|
||||
// const updateZoom = (zoomLevel) => {
|
||||
// // Get the current scroll position and width
|
||||
// const scrollContainer = wavesurfer.container.querySelector('wave');
|
||||
// const currentScroll = scrollContainer.scrollLeft;
|
||||
// const containerWidth = scrollContainer.clientWidth;
|
||||
|
||||
// // Calculate the center point of the current view
|
||||
// //const centerTime = wavesurfer.getCurrentTime();
|
||||
|
||||
// // Apply zoom
|
||||
// wavesurfer.zoom(zoomLevel);
|
||||
|
||||
// // Recalculate scroll to keep the center point in view
|
||||
// const newDuration = wavesurfer.getDuration();
|
||||
// const pixelsPerSecond = wavesurfer.drawer.width / newDuration;
|
||||
// const centerPixel = centerTime * pixelsPerSecond;
|
||||
|
||||
// // Adjust scroll to keep the center point in the same relative position
|
||||
// const newScrollLeft = centerPixel - (containerWidth / 2);
|
||||
// scrollContainer.scrollLeft = Math.max(0, newScrollLeft);
|
||||
// console.log(currentScroll, newScrollLeft);
|
||||
// };
|
||||
|
||||
// zoomInBtn.addEventListener('click', () => {
|
||||
// const currentZoom = parseInt(zoomSlider.value);
|
||||
// zoomSlider.value = Math.min(currentZoom + 20, 200);
|
||||
// updateZoom(zoomSlider.value);
|
||||
// });
|
||||
|
||||
// zoomOutBtn.addEventListener('click', () => {
|
||||
// const currentZoom = parseInt(zoomSlider.value);
|
||||
// zoomSlider.value = Math.max(currentZoom - 20, 1);
|
||||
// updateZoom(zoomSlider.value);
|
||||
// });
|
||||
|
||||
// zoomSlider.addEventListener('input', (e) => {
|
||||
// updateZoom(e.target.value);
|
||||
// });
|
||||
|
||||
trimmerContainer.appendChild(timeInfo);
|
||||
|
||||
// Add to list and global state
|
||||
audioTrimmersList.appendChild(trimmerContainer);
|
||||
globalState.trimmerElements[filePath] = trimmerContainer;
|
||||
|
||||
// Determine the file to load (original or current)
|
||||
const fileToLoad =
|
||||
section === 'untrimmed'
|
||||
? filePath
|
||||
: globalState.trimmerStates[filePath]?.originalPath || filePath;
|
||||
|
||||
// Setup wavesurfer
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: `#${waveformId}`,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
responsive: true,
|
||||
height: 100,
|
||||
hideScrollbar: true,
|
||||
// barWidth: 2,
|
||||
// barRadius: 3,
|
||||
cursorWidth: 1,
|
||||
backend: 'WebAudio',
|
||||
plugins: [
|
||||
RegionsPlugin.create({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
dragSelection: {
|
||||
slop: 20,
|
||||
},
|
||||
}),
|
||||
// ZoomPlugin.create({
|
||||
// // the amount of zoom per wheel step, e.g. 0.5 means a 50% magnification per scroll
|
||||
// scale: 0.5,
|
||||
// // Optionally, specify the maximum pixels-per-second factor while zooming
|
||||
// maxZoom: 100,
|
||||
// }),
|
||||
],
|
||||
});
|
||||
|
||||
// Store wavesurfer instance in global state
|
||||
globalState.wavesurferInstances[filePath] = wavesurfer;
|
||||
|
||||
// Use existing trim state or create new one
|
||||
globalState.trimmerStates[filePath] = globalState.trimmerStates[filePath] ||
|
||||
savedTrimInfo || {
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
regionStart: undefined,
|
||||
regionEnd: undefined,
|
||||
originalPath: fileToLoad,
|
||||
};
|
||||
const startTimeDisplay = timeInfo.querySelector('.trim-start-time');
|
||||
const endTimeDisplay = timeInfo.querySelector('.trim-end-time');
|
||||
|
||||
// Load audio file
|
||||
wavesurfer.load(`file://${fileToLoad}`);
|
||||
|
||||
// Setup play/pause button
|
||||
playPauseBtn.onclick = () => {
|
||||
const instanceState = globalState.trimmerStates[filePath];
|
||||
if (wavesurfer.isPlaying()) {
|
||||
wavesurfer.pause();
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
// Always start from the trim start
|
||||
wavesurfer.play(instanceState.trimStart, instanceState.trimEnd);
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// When audio is ready
|
||||
wavesurfer.on('ready', async () => {
|
||||
const instanceState = globalState.trimmerStates[filePath];
|
||||
|
||||
// Set trim times based on saved state or full duration
|
||||
if (instanceState.trimStart) {
|
||||
// Create initial region covering trim or full duration
|
||||
wavesurfer.clearRegions();
|
||||
const region = wavesurfer.addRegion({
|
||||
start: instanceState.trimStart,
|
||||
end: instanceState.trimEnd,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
});
|
||||
}
|
||||
instanceState.trimStart = instanceState.trimStart || 0;
|
||||
instanceState.trimEnd = instanceState.trimEnd || wavesurfer.getDuration();
|
||||
|
||||
// Update time displays
|
||||
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
|
||||
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
|
||||
|
||||
// Store region details
|
||||
instanceState.regionStart = instanceState.trimStart;
|
||||
instanceState.regionEnd = instanceState.trimEnd;
|
||||
|
||||
// Listen for region updates
|
||||
wavesurfer.on('region-update-end', async (updatedRegion) => {
|
||||
// Ensure the region doesn't exceed audio duration
|
||||
instanceState.trimStart = Math.max(0, updatedRegion.start);
|
||||
instanceState.trimEnd = Math.min(
|
||||
wavesurfer.getDuration(),
|
||||
updatedRegion.end,
|
||||
);
|
||||
|
||||
// Update time displays
|
||||
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
|
||||
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
|
||||
|
||||
// Store updated region details
|
||||
instanceState.regionStart = instanceState.trimStart;
|
||||
instanceState.regionEnd = instanceState.trimEnd;
|
||||
|
||||
globalState.trimmerStates[filePath] = instanceState;
|
||||
|
||||
// Adjust region if it exceeds bounds
|
||||
updatedRegion.update({
|
||||
start: instanceState.trimStart,
|
||||
end: instanceState.trimEnd,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle region creation
|
||||
wavesurfer.on('region-created', (newRegion) => {
|
||||
// Remove all other regions
|
||||
Object.keys(wavesurfer.regions.list).forEach((id) => {
|
||||
if (wavesurfer.regions.list[id] !== newRegion) {
|
||||
wavesurfer.regions.list[id].remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset to trim start when audio finishes
|
||||
wavesurfer.on('finish', () => {
|
||||
wavesurfer.setCurrentTime(instanceState.trimStart);
|
||||
playPauseBtn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
`;
|
||||
});
|
||||
|
||||
// Save trimmed audio functionality
|
||||
saveTrimButton.addEventListener('click', async () => {
|
||||
try {
|
||||
// Get current collections
|
||||
const collections = await ipcRenderer.invoke('get-collections');
|
||||
|
||||
// Create a dialog to select or create a collection
|
||||
const dialogHtml = `
|
||||
<div id="save-collection-dialog"
|
||||
style="
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background-color: #2a2a2a;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
">
|
||||
<div style="">
|
||||
<input type="text" id="new-save-title" placeholder="Title">
|
||||
</div>
|
||||
<select id="existing-collections" style="width: 100%; margin-top: 10px;">
|
||||
${collections
|
||||
.map((col) =>
|
||||
col === 'untrimmed'
|
||||
? ''
|
||||
: `<option value="${col}" ${
|
||||
globalState.currentSection === col ? 'selected' : ''
|
||||
}>${col}</option>`,
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
|
||||
<div style="margin-top: 10px; display: flex; justify-content: space-between;">
|
||||
<button class="play-pause-btn" id="cancel-save-btn" style="width: 48%; ">Cancel</button>
|
||||
<button class="play-pause-btn" id="save-to-collection-btn" style="width: 48%;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create dialog overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
|
||||
overlay.style.zIndex = '999';
|
||||
overlay.innerHTML = dialogHtml;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const existingCollectionsSelect = overlay.querySelector(
|
||||
'#existing-collections',
|
||||
);
|
||||
|
||||
const newSaveTitleInput = overlay.querySelector('#new-save-title');
|
||||
const createCollectionBtn = overlay.querySelector(
|
||||
'#create-collection-btn',
|
||||
);
|
||||
const saveToCollectionBtn = overlay.querySelector(
|
||||
'#save-to-collection-btn',
|
||||
);
|
||||
const cancelSaveBtn = overlay.querySelector('#cancel-save-btn');
|
||||
|
||||
if (savedTrimInfo.title) {
|
||||
newSaveTitleInput.value = savedTrimInfo.title;
|
||||
}
|
||||
|
||||
// Save to collection
|
||||
saveToCollectionBtn.addEventListener('click', async () => {
|
||||
const newTitle = document
|
||||
.getElementById('new-save-title')
|
||||
.value.trim();
|
||||
const settings = await ipcRenderer.invoke('load-settings');
|
||||
|
||||
const selectedCollection = existingCollectionsSelect.value;
|
||||
|
||||
if (!selectedCollection) {
|
||||
alert('Please select or create a collection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke(
|
||||
'delete-old-file',
|
||||
settings.outputFolder,
|
||||
globalState.currentSection,
|
||||
savedTrimInfo.title,
|
||||
);
|
||||
await ipcRenderer.invoke(
|
||||
'save-trimmed-file',
|
||||
path.basename(filePath),
|
||||
globalState.currentSection,
|
||||
selectedCollection,
|
||||
instanceState.trimStart,
|
||||
instanceState.trimEnd,
|
||||
newTitle,
|
||||
);
|
||||
|
||||
const saveResult = await ipcRenderer.invoke(
|
||||
'save-trimmed-audio',
|
||||
{
|
||||
originalFilePath: filePath,
|
||||
outputFolder: settings.outputFolder,
|
||||
collectionName: selectedCollection,
|
||||
title: newTitle,
|
||||
trimStart: instanceState.trimStart,
|
||||
trimEnd: instanceState.trimEnd,
|
||||
},
|
||||
);
|
||||
|
||||
if (saveResult.success) {
|
||||
// Close save dialog
|
||||
// Remove dialog
|
||||
document.body.removeChild(overlay);
|
||||
trimmerContainer.remove();
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
await populateCollectionsList();
|
||||
|
||||
// Optional: Show success message
|
||||
//alert(`Trimmed audio saved to ${saveResult.filePath}`);
|
||||
} else {
|
||||
alert(`Failed to save trimmed audio: ${saveResult.error}`);
|
||||
}
|
||||
|
||||
// Refresh the view
|
||||
} catch (error) {
|
||||
alert('Error saving file: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
cancelSaveBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating save dialog:', error);
|
||||
}
|
||||
});
|
||||
deletebutton.addEventListener('click', async () => {
|
||||
// Create confirmation dialog
|
||||
const confirmDelete = confirm(
|
||||
`Are you sure you want to delete this audio file?\nThis will remove the original file and any trimmed versions.`,
|
||||
);
|
||||
|
||||
if (confirmDelete) {
|
||||
try {
|
||||
// Delete original file
|
||||
await ipcRenderer.invoke('delete-file', filePath);
|
||||
|
||||
// Remove from UI
|
||||
trimmerContainer.remove();
|
||||
|
||||
// Optional: Notify user
|
||||
alert('File deleted successfully');
|
||||
|
||||
// Refresh the current section view
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
await populateCollectionsList();
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return trimmerContainer;
|
||||
}
|
||||
|
||||
// Initial load of untrimmed files and collections
|
||||
await loadCollectionFiles('untrimmed');
|
||||
await populateCollectionsList();
|
||||
|
||||
// Listen for new untrimmed files
|
||||
ipcRenderer.on('new-untrimmed-file', async (event, filePath) => {
|
||||
// Refresh the untrimmed section
|
||||
await loadCollectionFiles('untrimmed');
|
||||
await populateCollectionsList();
|
||||
});
|
||||
|
||||
// Periodic refresh
|
||||
setInterval(async () => {
|
||||
await populateCollectionsList();
|
||||
await loadCollectionFiles(globalState.currentSection);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Add collection button handler
|
||||
document
|
||||
.getElementById('add-collection-btn')
|
||||
.addEventListener('click', async () => {
|
||||
try {
|
||||
// Create a dialog to input new collection name
|
||||
const dialogHtml = `
|
||||
<div id="new-collection-dialog" style="
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #2a2a2a;
|
||||
padding: 0px 10px 10px 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
">
|
||||
<h4>Create New Collection</h4>
|
||||
<input
|
||||
type="text"
|
||||
id="new-collection-input"
|
||||
placeholder="Enter collection name"
|
||||
style="width: 100%; align-self: center; padding: 10px; margin-bottom: 10px;"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
<button id="create-collection-cancel-btn" class="play-pause-btn" style="width: 48%; ">Cancel</button>
|
||||
<button id="create-collection-confirm-btn" class="play-pause-btn" style="width: 48%; ">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create dialog overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
|
||||
overlay.style.zIndex = '999';
|
||||
overlay.innerHTML = dialogHtml;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const newCollectionInput = overlay.querySelector('#new-collection-input');
|
||||
const createCollectionConfirmBtn = overlay.querySelector(
|
||||
'#create-collection-confirm-btn',
|
||||
);
|
||||
const createCollectionCancelBtn = overlay.querySelector(
|
||||
'#create-collection-cancel-btn',
|
||||
);
|
||||
|
||||
// Create collection when confirm button is clicked
|
||||
createCollectionConfirmBtn.addEventListener('click', async () => {
|
||||
const newCollectionName = newCollectionInput.value.trim();
|
||||
|
||||
if (newCollectionName) {
|
||||
try {
|
||||
await ipcRenderer.invoke('add-new-collection', newCollectionName);
|
||||
|
||||
// Remove dialog
|
||||
document.body.removeChild(overlay);
|
||||
|
||||
// Refresh collections list
|
||||
await populateCollectionsList();
|
||||
} catch (error) {
|
||||
// Show error in the dialog
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.textContent = error.message;
|
||||
errorDiv.style.color = 'red';
|
||||
errorDiv.style.marginTop = '10px';
|
||||
overlay.querySelector('div').appendChild(errorDiv);
|
||||
}
|
||||
} else {
|
||||
// Show error if input is empty
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.textContent = 'Collection name cannot be empty';
|
||||
errorDiv.style.color = 'red';
|
||||
errorDiv.style.marginTop = '10px';
|
||||
overlay.querySelector('div').appendChild(errorDiv);
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button closes the dialog
|
||||
createCollectionCancelBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Focus the input when dialog opens
|
||||
newCollectionInput.focus();
|
||||
} catch (error) {
|
||||
console.error('Error creating new collection dialog:', error);
|
||||
}
|
||||
});
|
||||
@ -1,355 +0,0 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #1e1e1e;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 30px;
|
||||
background: #262626;
|
||||
-webkit-app-region: drag;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: calc(100vh);
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: #1e1e1e;
|
||||
border-right: 1px solid #303030;
|
||||
padding: 5px 20px 20px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #393939;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.sidebar-section .add-collection-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #6e44ba;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.sidebar-section .add-collection-btn:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
.section-item, .collection-item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.section-item:hover, .collection-item:hover {
|
||||
background-color: #303030;
|
||||
}
|
||||
|
||||
.section-item.active, .collection-item.active {
|
||||
background-color: rgba(110, 68, 186, 0.3);
|
||||
color: #ccb1ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmers-section {
|
||||
background-color: #1e1e1e;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmers-list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
.audio-trimmers-list::-webkit-scrollbar { /* WebKit */
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.audio-trimmer-item {
|
||||
position: relative;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 0px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.audio-trimmer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.audio-trimmer-title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-trimmer-title {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.audio-trimmer-filename {
|
||||
color: #888;
|
||||
font-weight: regular;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-trimmer-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.trim-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* margin-bottom: 20px; */
|
||||
}
|
||||
|
||||
.trim-time {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
.play-pause-btn, .save-trim {
|
||||
background-color: #6e44ba;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.play-pause-btn:hover, .save-trim:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
.play-pause-btn svg, .save-trim svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #383838;
|
||||
border-radius: 5px;
|
||||
border-color:#303030;
|
||||
color: #ffffffd3;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input{
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #383838;
|
||||
border-radius: 5px;
|
||||
border-color:#303030;
|
||||
color: #ffffffd3;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select:active {
|
||||
border-color: #303030;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Settings Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: #2a2a2a;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 500px;
|
||||
color: #ffffffd3;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.close-modal {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#save-settings {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #6e44ba;
|
||||
color: #ffffffd3;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#save-settings:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
#select-output-folder {
|
||||
width: 15%;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
background-color: #6e44ba;
|
||||
color: #ffffffd3;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#select-output-folder:hover {
|
||||
background-color: #4f3186;
|
||||
}
|
||||
|
||||
|
||||
#input-device {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
#output-folder {
|
||||
width: 84%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #ffffffd3;
|
||||
}
|
||||
|
||||
/* Zoom controls styling */
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.zoom-controls button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-controls .zoom-slider {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#recording-length, #osc-port {
|
||||
width: 20%;
|
||||
}
|
||||
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,8 +1,10 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* @import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities'; */
|
||||
/*
|
||||
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
|
||||
* See https://github.com/webpack-contrib/sass-loader#imports
|
||||
@ -10,19 +12,25 @@
|
||||
|
||||
@theme {
|
||||
--color-midnight: #1E1E1E;
|
||||
--color-plum: #4f3186;
|
||||
--color-plum: #6e44ba;
|
||||
--color-plumDark: #4f3186;
|
||||
--color-offwhite: #d4d4d4;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #4f3186;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #303030;
|
||||
-webkit-border-radius: 1ex;
|
||||
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
li {
|
||||
|
||||
@ -1,59 +1,232 @@
|
||||
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
// import 'tailwindcss/tailwind.css';
|
||||
import icon from '../../assets/icon.svg';
|
||||
import './App.css';
|
||||
import AudioTrimmer from './components/AudioTrimer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import io from 'socket.io-client';
|
||||
// import 'tailwindcss/tailwind.css';
|
||||
import './App.css';
|
||||
import ClipList from './components/ClipList';
|
||||
import { useAppDispatch, useAppSelector } from './hooks';
|
||||
import { store } from '../redux/main';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SettingsPage from './Settings';
|
||||
import { apiFetch, getBaseUrl } from './api';
|
||||
|
||||
function MainPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const collections = useAppSelector((state) =>
|
||||
state.collections.map((col) => col.name),
|
||||
);
|
||||
const [selectedCollection, setSelectedCollection] = useState<string>(
|
||||
collections[0] || 'Uncategorized',
|
||||
);
|
||||
const [newCollectionOpen, setNewCollectionOpen] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState<string>('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let newSocket: any = null;
|
||||
const initializeSocket = async () => {
|
||||
const baseUrl = await getBaseUrl();
|
||||
newSocket = io(baseUrl);
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Connected to WebSocket server');
|
||||
});
|
||||
newSocket.on('full_data', (data: any) => {
|
||||
console.log('Received full_data from server:', data);
|
||||
dispatch({
|
||||
type: 'metadata/setAllData',
|
||||
payload: { collections: data },
|
||||
});
|
||||
});
|
||||
newSocket.on('new_clip', (data: any) => {
|
||||
console.log('Received new_clips from server:', data);
|
||||
dispatch({
|
||||
type: 'metadata/addNewClip',
|
||||
payload: { clip: data },
|
||||
});
|
||||
});
|
||||
};
|
||||
initializeSocket();
|
||||
return () => {
|
||||
if (newSocket) {
|
||||
newSocket.off('connect');
|
||||
newSocket.off('full_data');
|
||||
newSocket.off('new_clip');
|
||||
newSocket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update selected collection if collections change
|
||||
if (collections.length > 0 && !collections.includes(selectedCollection)) {
|
||||
setSelectedCollection(collections[0]);
|
||||
}
|
||||
}, [collections, selectedCollection]);
|
||||
|
||||
const handleNewCollectionSave = () => {
|
||||
if (
|
||||
newCollectionName.trim() &&
|
||||
!collections.includes(newCollectionName.trim())
|
||||
) {
|
||||
dispatch({
|
||||
type: 'metadata/addCollection',
|
||||
payload: newCollectionName.trim(),
|
||||
});
|
||||
setSelectedCollection(newCollectionName.trim());
|
||||
fetch('http://localhost:5010/meta/collections/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newCollectionName.trim() }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error creating collection:', err));
|
||||
}
|
||||
setNewCollectionOpen(false);
|
||||
setNewCollectionName('');
|
||||
};
|
||||
|
||||
function Hello() {
|
||||
return (
|
||||
<div className="min-h-screen min-w-screen flex flex-col items-center justify-center bg-midnight text-offwhite">
|
||||
{/* <div className="Hello">
|
||||
<img width="200" alt="icon" src={icon} />
|
||||
</div>
|
||||
<h1>electron-react-boilerplate</h1>
|
||||
<div className="Hello">
|
||||
<a
|
||||
href="https://electron-react-boilerplate.js.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<button type="button">
|
||||
<span role="img" aria-label="books">
|
||||
📚
|
||||
</span>
|
||||
Read our docs
|
||||
<div className="min-h-screen min-w-screen bg-midnight text-offwhite relative">
|
||||
{/* Left Nav Bar - sticky */}
|
||||
<Dialog
|
||||
open={newCollectionOpen}
|
||||
onClose={() => setNewCollectionOpen(false)}
|
||||
slotProps={{
|
||||
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Clip Name</DialogTitle>
|
||||
<DialogContent>
|
||||
<input
|
||||
autoFocus
|
||||
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center"
|
||||
type="text"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleNewCollectionSave();
|
||||
}}
|
||||
aria-label="New collection name"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCollectionOpen(false);
|
||||
setNewCollectionName('');
|
||||
}}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/electron-react-boilerplate"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<button type="button">
|
||||
<span role="img" aria-label="folded hands">
|
||||
🙏
|
||||
</span>
|
||||
Donate
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewCollectionSave}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</a>
|
||||
</div> */}
|
||||
<div className="bg-midnight min-w-screen">
|
||||
<AudioTrimmer
|
||||
title="audio_capture_20251206_123108.wav"
|
||||
filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
|
||||
// section="Section 1"
|
||||
/>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<nav
|
||||
className="w-48 h-screen sticky top-0 left-0 border-r border-neutral-700 bg-midnight flex flex-col p-2"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<div className="p-4 font-bold text-lg">Collections</div>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded text-left px-4 py-2 mb-2 bg-plumDark text-offwhite font-semibold hover:bg-plum"
|
||||
onClick={() => setNewCollectionOpen(true)}
|
||||
>
|
||||
+ Create Collection
|
||||
</button>
|
||||
</li>
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{collections.map((col) => (
|
||||
<li key={col}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full rounded text-left px-4 py-2 mt-2 hover:bg-plumDark ${selectedCollection === col ? 'bg-plum text-offwhite font-semibold' : 'text-offwhite'}`}
|
||||
onClick={() => setSelectedCollection(col)}
|
||||
>
|
||||
{col}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Settings Button at Bottom Left */}
|
||||
<div className="mt-auto mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-4 py-2 bg-neutral-800 text-offwhite hover:bg-plumDark text-left"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 8,
|
||||
width: 'calc(100% - 16px)',
|
||||
}}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className="absolute top-0 ml-[12rem] w-[calc(100%-12rem)] h-screen overflow-y-auto p-4"
|
||||
// style={{ left: '12rem', width: 'calc(100% - 12rem)' }}
|
||||
>
|
||||
<ClipList collection={selectedCollection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const theme = createTheme({
|
||||
colorSchemes: {
|
||||
light: false,
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#6e44ba', // plum
|
||||
dark: '#6e44ba', // plum
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4f3186', // plumDark
|
||||
dark: '#4f3186', // plumDark
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// colorSchemes: {
|
||||
// light: false,
|
||||
// dark: true,
|
||||
// },
|
||||
});
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Hello />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
290
electron-ui/src/renderer/Settings.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// import { ipcRenderer } from 'electron';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import './App.css';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { IconButton } from '@mui/material';
|
||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||
import { apiFetch } from './api';
|
||||
|
||||
type AudioDevice = {
|
||||
index: number;
|
||||
name: string;
|
||||
default_sample_rate: number;
|
||||
channels: number;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
http_port: number;
|
||||
input_device: AudioDevice;
|
||||
output_device: AudioDevice;
|
||||
recording_length: number;
|
||||
save_path: string;
|
||||
};
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
http_port: 0,
|
||||
input_device: { index: 0, name: '', default_sample_rate: 0, channels: 0 },
|
||||
output_device: { index: 0, name: '', default_sample_rate: 0, channels: 0 },
|
||||
recording_length: 0,
|
||||
save_path: '',
|
||||
};
|
||||
|
||||
async function fetchAudioDevices(
|
||||
type: 'input' | 'output',
|
||||
): Promise<AudioDevice[]> {
|
||||
// Replace with actual backend call
|
||||
// Example: return window.api.getAudioDevices();
|
||||
return apiFetch(`device/list?device_type=${type}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.devices as AudioDevice[])
|
||||
.catch((error) => {
|
||||
console.error('Error fetching audio devices:', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<Settings> {
|
||||
// Replace with actual backend call
|
||||
// Example: return window.api.getAudioDevices();
|
||||
console.log('Fetching settings from backend...');
|
||||
return apiFetch('settings')
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.settings as Settings)
|
||||
.catch((error) => {
|
||||
console.error('Error fetching settings:', error);
|
||||
return defaultSettings;
|
||||
});
|
||||
}
|
||||
|
||||
const sendSettingsToBackend = async (settings: Settings) => {
|
||||
// Replace with actual backend call
|
||||
// Example: window.api.updateSettings(settings);
|
||||
console.log('Settings updated:', settings);
|
||||
await apiFetch('settings/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log('Settings update response:', data);
|
||||
if (data.status === 'success') {
|
||||
window.audio.restartService();
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error updating settings:', error);
|
||||
});
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [inputDevices, setInputDevices] = useState<AudioDevice[]>([]);
|
||||
const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
.then((fetchedSettings) => {
|
||||
console.log('Fetched settings:', fetchedSettings);
|
||||
setSettings(fetchedSettings);
|
||||
return null;
|
||||
})
|
||||
.then(() => {
|
||||
return fetchAudioDevices('input');
|
||||
})
|
||||
.then((devices) => {
|
||||
setInputDevices(devices);
|
||||
// console.log('Input devices:', devices);
|
||||
return fetchAudioDevices('output');
|
||||
})
|
||||
.then((devices) => {
|
||||
setOutputDevices(devices);
|
||||
|
||||
// console.log('Output devices:', devices);
|
||||
return devices;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching audio devices:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, [settings]);
|
||||
|
||||
const handleChange = () => {
|
||||
sendSettingsToBackend(settings);
|
||||
// const { name, value } = e.target;
|
||||
// setSettings((prev) => ({
|
||||
// ...prev,
|
||||
// [name]: value,
|
||||
// }));
|
||||
};
|
||||
|
||||
const handleFolderChange = async () => {
|
||||
await window.electron.ipcRenderer
|
||||
.invoke('select-directory')
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
save_path: result,
|
||||
}));
|
||||
sendSettingsToBackend({
|
||||
...settings,
|
||||
save_path: result,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-screen min-h-screen bg-midnight text-offwhite flex items-center justify-center relative">
|
||||
<div className="w-3/4 min-w-[600px] max-w-[800px] self-start flex flex-col font-sans bg-midnight text-offwhite p-6 rounded-lg relative">
|
||||
{/* X Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6 right-6 text-3xl font-bold text-offwhite bg-transparent hover:text-plumDark"
|
||||
aria-label="Close settings"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="text-2xl font-bold mb-4">Settings</span>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<span>HTTP Port:</span>
|
||||
<TextField
|
||||
variant="standard"
|
||||
type="text"
|
||||
name="httpPort"
|
||||
value={settings.http_port}
|
||||
onBlur={() => handleChange()}
|
||||
onChange={(e) => {
|
||||
if (!Number.isNaN(Number(e.target.value))) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
http_port: Number(e.target.value),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-white w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<span>Input Audio Device:</span>
|
||||
<Select
|
||||
variant="standard"
|
||||
name="inputDevice"
|
||||
value={settings.input_device.index}
|
||||
onChange={(e) => {
|
||||
const newDevice = inputDevices.find(
|
||||
(dev) => dev.index === Number(e.target.value),
|
||||
);
|
||||
console.log('Selected input device index:', newDevice);
|
||||
if (newDevice) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
input_device: newDevice,
|
||||
}));
|
||||
sendSettingsToBackend({
|
||||
...settings,
|
||||
input_device: newDevice,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="ml-2 w-64"
|
||||
>
|
||||
{inputDevices.map((dev) => (
|
||||
<MenuItem key={dev.index} value={dev.index}>
|
||||
{dev.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<span>Output Audio Device:</span>
|
||||
<Select
|
||||
variant="standard"
|
||||
name="outputDevice"
|
||||
value={settings.output_device.index}
|
||||
onChange={(e) => {
|
||||
const newDevice = outputDevices.find(
|
||||
(dev) => dev.index === Number(e.target.value),
|
||||
);
|
||||
if (newDevice) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
output_device: newDevice,
|
||||
}));
|
||||
sendSettingsToBackend({
|
||||
...settings,
|
||||
output_device: newDevice,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="ml-2 w-64"
|
||||
>
|
||||
{outputDevices.map((dev) => (
|
||||
<MenuItem key={dev.index} value={dev.index}>
|
||||
{dev.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<span>Recording Length (seconds):</span>
|
||||
<TextField
|
||||
variant="standard"
|
||||
type="text"
|
||||
name="recordingLength"
|
||||
value={settings.recording_length}
|
||||
onChange={(e) => {
|
||||
if (!Number.isNaN(Number(e.target.value))) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
recording_length: Number(e.target.value),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
onBlur={() => handleChange()}
|
||||
className="ml-2 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<span>Clip Output Folder:</span>
|
||||
<div className="flex justify-end">
|
||||
<TextField
|
||||
variant="standard"
|
||||
type="text"
|
||||
name="savePath"
|
||||
value={settings.save_path}
|
||||
className="ml-2 w-[300px]"
|
||||
/>
|
||||
<IconButton
|
||||
component="label"
|
||||
size="small"
|
||||
tabIndex={-1}
|
||||
onClick={handleFolderChange}
|
||||
>
|
||||
<MoreHorizIcon />
|
||||
</IconButton>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={handleFolderChange}
|
||||
className="ml-2 px-3 py-1 rounded bg-plumDark text-offwhite hover:bg-plum"
|
||||
>
|
||||
<VisuallyHiddenInput type="file" />
|
||||
...
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
electron-ui/src/renderer/api.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const getBaseUrl = async () => {
|
||||
const port = await window.audio.getPort();
|
||||
if (port.error || !port.port) {
|
||||
return `http://localhost:5010`;
|
||||
}
|
||||
// You can store the base URL in localStorage, a config file, or state
|
||||
return `http://localhost:${port.port}`;
|
||||
};
|
||||
|
||||
export async function apiFetch(endpoint: string, options = {}) {
|
||||
const url = `${await getBaseUrl()}/${endpoint}`;
|
||||
return fetch(url, options);
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import { useWavesurfer } from '@wavesurfer/react';
|
||||
|
||||
export interface AudioTrimmerProps {
|
||||
filePath: string;
|
||||
section: string;
|
||||
title?: string;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
onSave?: (trimStart: number, trimEnd: number, title?: string) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function AudioTrimmer({
|
||||
filePath,
|
||||
section,
|
||||
title,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: AudioTrimmerProps) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const plugins = useMemo(() => [Regions.create()], []);
|
||||
|
||||
const { wavesurfer, isReady, isPlaying } = useWavesurfer({
|
||||
container: containerRef,
|
||||
height: 100,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
url: blobUrl,
|
||||
plugins,
|
||||
});
|
||||
|
||||
const onRegionCreated = useCallback(
|
||||
(newRegion: any) => {
|
||||
if (wavesurfer === null) return;
|
||||
const allRegions = plugins[0].getRegions();
|
||||
allRegions.forEach((region) => {
|
||||
if (region.id !== newRegion.id) {
|
||||
region.remove();
|
||||
}
|
||||
});
|
||||
},
|
||||
[plugins, wavesurfer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('ready, setting up regions plugin', wavesurfer);
|
||||
if (trimStart !== undefined && trimEnd !== undefined) {
|
||||
plugins[0].addRegion({
|
||||
start: trimStart,
|
||||
end: trimEnd,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
});
|
||||
}
|
||||
|
||||
plugins[0].enableDragSelection({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
});
|
||||
plugins[0].on('region-created', onRegionCreated);
|
||||
}, [isReady, plugins, wavesurfer, onRegionCreated, trimStart, trimEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
let url: string | null = null;
|
||||
async function fetchAudio() {
|
||||
// console.log('Loading audio buffer for file:', filePath);
|
||||
const buffer =
|
||||
await window.electron.ipcRenderer.loadAudioBuffer(filePath);
|
||||
if (buffer && !buffer.error) {
|
||||
const audioData = buffer.data ? new Uint8Array(buffer.data) : buffer;
|
||||
url = URL.createObjectURL(new Blob([audioData]));
|
||||
setBlobUrl(url);
|
||||
}
|
||||
}
|
||||
fetchAudio();
|
||||
return () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [filePath]);
|
||||
|
||||
const onPlayPause = () => {
|
||||
if (wavesurfer === null) return;
|
||||
if (isPlaying) {
|
||||
wavesurfer.pause();
|
||||
} else {
|
||||
const allRegions = plugins[0].getRegions();
|
||||
if (allRegions.length > 0) {
|
||||
wavesurfer.play(allRegions[0].start, allRegions[0].end);
|
||||
} else {
|
||||
wavesurfer.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shadow-[0_4px_6px_rgba(0,0,0,0.5)] m-2 p-4 rounded-lg bg-darkDrop">
|
||||
<div>
|
||||
<text className="m-2 font-bold text-lg">{title}</text>
|
||||
</div>
|
||||
|
||||
<div className="w-[100%] m-2 ">
|
||||
<div ref={containerRef} />
|
||||
<button type="button" onClick={onPlayPause}>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
electron-ui/src/renderer/components/ClipList.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import AudioTrimmer from './Trimmer/AudioTrimer';
|
||||
import { ClipMetadata } from '../../redux/types';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks';
|
||||
import { apiFetch } from '../api';
|
||||
|
||||
export interface ClipListProps {
|
||||
collection: string;
|
||||
}
|
||||
|
||||
export default function ClipList({ collection }: ClipListProps) {
|
||||
const metadata = useAppSelector(
|
||||
(state) =>
|
||||
state.collections.find((col) => col.name === collection) || { clips: [] },
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
console.log('Files dropped:', event.dataTransfer.files);
|
||||
const files = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith('audio/'),
|
||||
);
|
||||
if (files.length > 0) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
|
||||
// todo send the file to the backend and add to the collection
|
||||
|
||||
// fetch('http://localhost:5010/file/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData,
|
||||
// })
|
||||
// .then((res) => res.json())
|
||||
// .catch((err) => console.error('Error uploading files:', err));
|
||||
// Implement your onDrop logic here
|
||||
// onDrop(files, selectedCollection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
async function handleDragEnd(event: any) {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = metadata.clips.findIndex(
|
||||
(item) => item.filename === active.id,
|
||||
);
|
||||
const newIndex = metadata.clips.findIndex(
|
||||
(item) => item.filename === over.id,
|
||||
);
|
||||
const newMetadata = {
|
||||
...metadata,
|
||||
clips: arrayMove(metadata.clips, oldIndex, newIndex),
|
||||
};
|
||||
console.log('New order:', newMetadata);
|
||||
dispatch({
|
||||
type: 'metadata/setCollections',
|
||||
payload: { collection, newMetadata },
|
||||
});
|
||||
try {
|
||||
const response = await apiFetch('meta/collection/clips/reorder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: collection,
|
||||
clips: newMetadata.clips,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('handle reorder return:', data.collections);
|
||||
dispatch({ type: 'metadata/setAllData', payload: data });
|
||||
} catch (error) {
|
||||
console.error('Error saving new clip order:', error);
|
||||
}
|
||||
// setMetadata(newMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(meta: ClipMetadata) {
|
||||
dispatch({
|
||||
type: 'metadata/deleteClip',
|
||||
payload: { collection, clip: meta },
|
||||
});
|
||||
apiFetch('meta/collection/clips/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: collection,
|
||||
clip: meta,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error deleting clip:', err));
|
||||
console.log('Deleting clip:', meta);
|
||||
}
|
||||
|
||||
async function handleClipMove(targetCollection: string, meta: ClipMetadata) {
|
||||
console.log('Moving clip:', meta, 'to collection:', targetCollection);
|
||||
dispatch({
|
||||
type: 'metadata/moveClip',
|
||||
payload: { sourceCollection: collection, targetCollection, clip: meta },
|
||||
});
|
||||
apiFetch('meta/collection/clips/move', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceCollection: collection,
|
||||
targetCollection,
|
||||
clip: meta,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((err) => console.error('Error moving clip:', err));
|
||||
}
|
||||
|
||||
async function handleClipSave(meta: ClipMetadata) {
|
||||
try {
|
||||
dispatch({
|
||||
type: 'metadata/editClip',
|
||||
payload: { collection, clip: meta },
|
||||
});
|
||||
const response = await apiFetch('meta/collection/clips/edit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: collection,
|
||||
clip: meta,
|
||||
}),
|
||||
});
|
||||
await response.json();
|
||||
// console.log('handle clip save return:', data.collections);
|
||||
dispatch({
|
||||
type: 'metadata/editClip',
|
||||
payload: { collection, clip: meta },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving clip metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-full flex flex-col justify-start bg-midnight text-offwhite"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<SortableContext
|
||||
items={metadata.clips.map((item) => item.filename)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{metadata.clips.map((trimmer, idx) => (
|
||||
<React.Fragment key={trimmer.filename}>
|
||||
<AudioTrimmer
|
||||
metadata={trimmer}
|
||||
onSave={handleClipSave}
|
||||
onDelete={handleDelete}
|
||||
onMove={handleClipMove}
|
||||
/>
|
||||
{(idx + 1) % 10 === 0 && idx !== metadata.clips.length - 1 && (
|
||||
<div className="my-4 border-t border-gray-500">
|
||||
<p className="text-center text-sm text-gray-400">
|
||||
-- Page {Math.ceil((idx + 1) / 10) + 1} --
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* {metadata.map((trimmer) => (
|
||||
<AudioTrimmer
|
||||
key={trimmer.filename}
|
||||
filename={trimmer.filename}
|
||||
onSave={handleClipSave}
|
||||
/>
|
||||
))} */}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
// <div className="min-h-screen flex flex-col justify-center bg-midnight text-offwhite">
|
||||
// <AudioTrimmer
|
||||
// title="audio_capture_20251206_123108.wav"
|
||||
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
|
||||
// // section="Section 1"
|
||||
// />
|
||||
// <AudioTrimmer
|
||||
// title="audio_capture_20251206_123108.wav"
|
||||
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
|
||||
// // section="Section 1"
|
||||
// />
|
||||
// <AudioTrimmer
|
||||
// title="audio_capture_20251206_123108.wav"
|
||||
// filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
|
||||
// // section="Section 1"
|
||||
// />
|
||||
// </div>
|
||||
);
|
||||
}
|
||||
367
electron-ui/src/renderer/components/Trimmer/AudioTrimer.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { useWavesurfer } from '@wavesurfer/react';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ClipMetadata, PlaybackType } from '../../../redux/types';
|
||||
import { useAppSelector } from '../../hooks';
|
||||
import PlayStopIcon from '../icons/playStopIcon';
|
||||
import PlayOverlapIcon from '../icons/playOverlapIcon';
|
||||
import NameEditDialog from './dialogs/NameEditDialog';
|
||||
import DeleteClipDialog from './dialogs/DeleteClipDialog';
|
||||
import TitleBlock from './TitleBlock';
|
||||
import ClipButtonRow from './ClipButtonRow';
|
||||
|
||||
export interface AudioTrimmerProps {
|
||||
metadata: ClipMetadata;
|
||||
onSave?: (metadata: ClipMetadata) => void;
|
||||
onDelete?: (metadata: ClipMetadata) => void;
|
||||
onMove?: (newCollection: string, metadata: ClipMetadata) => void;
|
||||
}
|
||||
|
||||
export default function AudioTrimmer({
|
||||
metadata,
|
||||
onSave,
|
||||
onDelete,
|
||||
onMove,
|
||||
}: AudioTrimmerProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: metadata.filename });
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [volumeInput, setVolumeInput] = useState<number>(metadata.volume ?? 1);
|
||||
const collectionNames = useAppSelector((state) =>
|
||||
state.collections.map((col) => col.name),
|
||||
);
|
||||
|
||||
const handleDialogSave = (newName: string) => {
|
||||
if (newName.trim() && newName !== metadata.name) {
|
||||
const updated = { ...metadata, name: newName.trim() };
|
||||
if (onSave) onSave(updated);
|
||||
}
|
||||
setEditDialogOpen(false);
|
||||
};
|
||||
|
||||
const containerRef = useRef(null);
|
||||
// const [clipStart, setClipStart] = useState<number | undefined>(undefined);
|
||||
// const [clipEnd, setClipEnd] = useState<number | undefined>(undefined);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
RegionsPlugin.create(),
|
||||
ZoomPlugin.create({
|
||||
scale: 0.25,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { wavesurfer, isReady, isPlaying } = useWavesurfer({
|
||||
container: containerRef,
|
||||
height: 100,
|
||||
waveColor: '#ccb1ff',
|
||||
progressColor: '#6e44ba',
|
||||
hideScrollbar: true,
|
||||
plugins,
|
||||
});
|
||||
|
||||
// Add this ref to always have the latest metadata
|
||||
const metadataRef = useRef(metadata);
|
||||
useEffect(() => {
|
||||
metadataRef.current = metadata;
|
||||
}, [metadata]);
|
||||
|
||||
const onRegionCreated = useCallback(
|
||||
(newRegion: any) => {
|
||||
if (wavesurfer === null) return;
|
||||
|
||||
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
|
||||
let isNew = metadataRef.current.startTime === undefined;
|
||||
|
||||
allRegions.forEach((region) => {
|
||||
if (region.id !== newRegion.id) {
|
||||
if (
|
||||
region.start === newRegion.start &&
|
||||
region.end === newRegion.end
|
||||
) {
|
||||
newRegion.remove();
|
||||
return;
|
||||
}
|
||||
region.remove();
|
||||
isNew = !(region.start === 0 && region.end === 0);
|
||||
// console.log('Region replace:', newRegion, region);
|
||||
}
|
||||
});
|
||||
|
||||
if (isNew) {
|
||||
console.log('Region created:', metadataRef.current);
|
||||
const updated = {
|
||||
...metadataRef.current,
|
||||
startTime: newRegion.start,
|
||||
endTime: newRegion.end,
|
||||
};
|
||||
if (onSave) {
|
||||
onSave(updated);
|
||||
}
|
||||
}
|
||||
},
|
||||
[plugins, wavesurfer, onSave],
|
||||
);
|
||||
|
||||
const onRegionUpdated = useCallback(
|
||||
(newRegion: any) => {
|
||||
if (wavesurfer === null) return;
|
||||
|
||||
const updated = {
|
||||
...metadataRef.current,
|
||||
startTime: newRegion.start,
|
||||
endTime: newRegion.end,
|
||||
};
|
||||
if (onSave) {
|
||||
onSave(updated);
|
||||
}
|
||||
},
|
||||
[onSave, wavesurfer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const plugin = plugins[0] as RegionsPlugin;
|
||||
|
||||
if (!isReady) return;
|
||||
// console.log('ready, setting up regions plugin', plugin, isReady);
|
||||
if (
|
||||
metadataRef.current.startTime !== undefined &&
|
||||
metadataRef.current.endTime !== undefined
|
||||
) {
|
||||
// setClipStart(metadata.startTime);
|
||||
// setClipEnd(metadata.endTime);
|
||||
// console.log('Adding region from metadata:', metadata);=
|
||||
|
||||
const allRegions = plugin.getRegions();
|
||||
// console.log('Existing regions:', allRegions);
|
||||
if (
|
||||
allRegions.length === 0 ||
|
||||
(allRegions.length === 1 &&
|
||||
allRegions[0].start === 0 &&
|
||||
allRegions[0].end === 0)
|
||||
) {
|
||||
// console.log('adding region from metadata:', metadataRef.current);
|
||||
plugin.addRegion({
|
||||
start: metadataRef.current.startTime,
|
||||
end: metadataRef.current.endTime,
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
drag: false,
|
||||
resize: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// setClipStart(0);
|
||||
// setClipEnd(wavesurfer ? wavesurfer.getDuration() : 0);
|
||||
}
|
||||
}, [isReady, plugins]);
|
||||
|
||||
useEffect(() => {
|
||||
const plugin = plugins[0] as RegionsPlugin;
|
||||
plugin.unAll();
|
||||
plugin.on('region-created', onRegionCreated);
|
||||
plugin.on('region-updated', onRegionUpdated);
|
||||
plugin.enableDragSelection({
|
||||
color: 'rgba(132, 81, 224, 0.3)',
|
||||
});
|
||||
}, [onRegionCreated, onRegionUpdated, plugins]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = rootRef.current;
|
||||
if (!node) return;
|
||||
const observer = new window.IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(node);
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
async function fetchAudio() {
|
||||
const buffer = await window.audio.loadAudioBuffer(metadata.filename);
|
||||
if (cancelled) return;
|
||||
if (buffer.buffer && !buffer.error) {
|
||||
const audioData = buffer.buffer
|
||||
? new Uint8Array(buffer.buffer)
|
||||
: buffer;
|
||||
wavesurfer?.loadBlob(new Blob([audioData]));
|
||||
}
|
||||
}
|
||||
fetchAudio();
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, metadata.filename, wavesurfer]);
|
||||
|
||||
const onPlayPause = () => {
|
||||
if (wavesurfer === null) return;
|
||||
if (isPlaying) {
|
||||
wavesurfer.pause();
|
||||
} else {
|
||||
const allRegions = (plugins[0] as RegionsPlugin).getRegions();
|
||||
if (allRegions.length > 0) {
|
||||
wavesurfer.setVolume(metadata.volume ?? 1);
|
||||
wavesurfer.play(allRegions[0].start, allRegions[0].end);
|
||||
} else {
|
||||
wavesurfer.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(0);
|
||||
return `${minutes}:${secs.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
setNodeRef(el);
|
||||
rootRef.current = el;
|
||||
}}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
position: 'relative',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
className="shadow-[0_2px_8px_rgba(0,0,0,0.5)] m-2 rounded-lg bg-darkDrop"
|
||||
>
|
||||
<NameEditDialog
|
||||
open={editDialogOpen}
|
||||
onCancel={() => setEditDialogOpen(false)}
|
||||
startValue={metadata.name}
|
||||
onSave={handleDialogSave}
|
||||
/>
|
||||
|
||||
<DeleteClipDialog
|
||||
open={deleteDialogOpen}
|
||||
onCancel={() => setDeleteDialogOpen(false)}
|
||||
onDelete={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
if (onDelete) onDelete(metadata);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...attributes}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '10px',
|
||||
borderRadius: '5px 0 0 5px',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
className="bg-neutral-800"
|
||||
/>
|
||||
{/* <div className="flex flex-col"> */}
|
||||
<div className="ml-4 mr-2 p-2">
|
||||
<div className="grid justify-items-stretch grid-cols-2">
|
||||
<TitleBlock
|
||||
name={metadata.name}
|
||||
filename={metadata.filename}
|
||||
onNameClick={() => setEditDialogOpen(true)}
|
||||
/>
|
||||
<ClipButtonRow
|
||||
isPlaying={isPlaying}
|
||||
collectionNames={collectionNames}
|
||||
onPlayPause={onPlayPause}
|
||||
onMove={(collectionName) => {
|
||||
if (onMove !== undefined) {
|
||||
onMove(collectionName, metadata);
|
||||
}
|
||||
}}
|
||||
onDelete={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="m-1 wavesurfer-scroll-container">
|
||||
<div ref={containerRef} className="wavesurfer-inner" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
<span className="w-1/5 flex-none text-sm text-neutral-500 self-center">
|
||||
Clip: {formatTime(metadata.startTime ?? 0)} -{' '}
|
||||
{formatTime(metadata.endTime ?? 0)}
|
||||
</span>
|
||||
<div className="w-3/5 flex-1 flex justify-center items-center">
|
||||
<Slider
|
||||
value={volumeInput}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={(e, newValue) => setVolumeInput(newValue as number)}
|
||||
onChangeCommitted={(e, newValue) => {
|
||||
const newVolume = newValue as number;
|
||||
console.log('Volume change:', newVolume);
|
||||
if (onSave) onSave({ ...metadata, volume: newVolume });
|
||||
}}
|
||||
color="secondary"
|
||||
className="p-0 m-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/5 flex justify-end text-sm text-neutral-500">
|
||||
<ToggleButtonGroup value={metadata.playbackType}>
|
||||
<ToggleButton
|
||||
value="playStop"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (onSave)
|
||||
onSave({
|
||||
...metadata,
|
||||
playbackType: PlaybackType.PlayStop,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlayStopIcon />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
value="playOverlap"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (onSave)
|
||||
onSave({
|
||||
...metadata,
|
||||
playbackType: PlaybackType.PlayOverlap,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlayOverlapIcon />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import PauseIcon from '@mui/icons-material/Pause';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ClipButtonRow({
|
||||
isPlaying,
|
||||
collectionNames,
|
||||
onPlayPause,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
collectionNames: string[];
|
||||
onPlayPause: () => void;
|
||||
onMove?: (collectionName: string) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
||||
onClick={onPlayPause}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
|
||||
</button>
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
||||
onClick={() => setDropdownOpen((prev) => !prev)}
|
||||
>
|
||||
{dropdownOpen ? <ArrowDownwardIcon /> : <ArrowForwardIcon />}
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute z-10 mt-2 w-40 bg-midnight rounded-md shadow-lg">
|
||||
{collectionNames.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="block w-full text-left px-4 py-2 text-white hover:bg-plumDark"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
if (onMove) onMove(name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-11 w-11 rounded-md ml-1"
|
||||
onClick={() => onDelete && onDelete()}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
electron-ui/src/renderer/components/Trimmer/TitleBlock.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
export default function TitleBlock({
|
||||
name,
|
||||
filename,
|
||||
onNameClick,
|
||||
}: {
|
||||
name: string;
|
||||
filename: string;
|
||||
onNameClick: () => void;
|
||||
}) {
|
||||
const basename = filename.split('\\').pop()?.split('/').pop() || 'Unknown';
|
||||
return (
|
||||
<div className="mb-5px flex flex-col">
|
||||
<span
|
||||
className="font-bold text-lg text-white mb-1 cursor-pointer"
|
||||
onClick={onNameClick}
|
||||
onKeyDown={(e) => {}}
|
||||
title="Click to edit name"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">{basename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function DeleteClipDialog({
|
||||
open,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
slotProps={{
|
||||
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>Are you sure you want to delete this clip?</DialogContent>
|
||||
<DialogActions>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
autoFocus
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function NameEditDialog({
|
||||
open,
|
||||
startValue,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
startValue: string;
|
||||
onCancel: () => void;
|
||||
onSave: (newName: string) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState(startValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInput(startValue);
|
||||
}
|
||||
}, [open, startValue]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
slotProps={{
|
||||
paper: { sx: { backgroundColor: '#1a1a1a', color: 'white' } },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Clip Name</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
multiline
|
||||
variant="standard"
|
||||
className="font-bold text-lg bg-transparent outline-none border-b border-plum focus:border-plumDark text-white mb-1 w-full text-center resize-y"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
}}
|
||||
onFocus={(event) => event.target.select()}
|
||||
aria-label="Edit clip name"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(input)}
|
||||
className="bg-plum hover:bg-plumDark text-white font-bold h-10 px-4 rounded-md"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function PlayOverlapIcon({
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
}: {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Filled play arrow */}
|
||||
<polygon points="4,4 4,20 16,12" fill={color} />
|
||||
{/* Outlined play arrow (underneath and to the right) */}
|
||||
<polygon
|
||||
points="12,4 12,20 24,12"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
electron-ui/src/renderer/components/icons/playStopIcon.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
export default function PlayStopIcon({
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
}: {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 48 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Play/Stop Icon"
|
||||
>
|
||||
{/* Play Arrow */}
|
||||
<polygon points="4,4 20,12 4,20" fill={color} />
|
||||
{/* Stop Square */}
|
||||
<rect x="28" y="4" width="16" height="16" rx="2" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
6
electron-ui/src/renderer/hooks.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
import type { AppDispatch, RootState } from '../redux/main';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
export const useAppStore = useStore.withTypes<RootState>();
|
||||
@ -6,7 +6,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
<title>Hello Electron React!</title>
|
||||
<title>ClipTrim</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3
electron-ui/src/renderer/preload.d.ts
vendored
@ -1,9 +1,10 @@
|
||||
import { ElectronHandler } from '../main/preload';
|
||||
import { ElectronHandler, AudioHandler } from '../main/preload';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Window {
|
||||
electron: ElectronHandler;
|
||||
audio: AudioHandler;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
421
electron-ui/test_meta.json
Normal file
@ -0,0 +1,421 @@
|
||||
[
|
||||
{
|
||||
"name": "Uncategorized",
|
||||
"id": 0,
|
||||
"clips": []
|
||||
},
|
||||
{
|
||||
"name": "mason",
|
||||
"id": 1,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250105_131700.wav",
|
||||
"name": "lich",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 27.371372936207585,
|
||||
"endTime": 30
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250119_173448.wav",
|
||||
"name": "nic",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.897134459955918,
|
||||
"endTime": 10.62821454812639
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250119_173654.wav",
|
||||
"name": "racist",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 7.92372881355932,
|
||||
"endTime": 9.682203389830498
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250120_210843.wav",
|
||||
"name": "dildo",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.227565151875025,
|
||||
"endTime": 13.20035827476919
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250121_223502.wav",
|
||||
"name": "latter",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.440677966101688,
|
||||
"endTime": 12.499999999999996
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250124_214433.wav",
|
||||
"name": "ahh",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10,
|
||||
"endTime": 10.656779661016953
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250131_204903.wav",
|
||||
"name": "tight",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.7457627118644,
|
||||
"endTime": 11.52542372881357
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250308_104030.wav",
|
||||
"name": "rape",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 3.7923728813559365,
|
||||
"endTime": 5.677966101694913
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250328_212948.wav",
|
||||
"name": "wig",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.31779661016946,
|
||||
"endTime": 11.038135593220328
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250419_140818.wav",
|
||||
"name": "queef",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 12.47881355932203,
|
||||
"endTime": 13.347457627118642
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250503_183629.wav",
|
||||
"name": "wood",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.82627118644066,
|
||||
"endTime": 11.546610169491522
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250707_213558.wav",
|
||||
"name": "bam",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 13.728813559321997,
|
||||
"endTime": 14.300847457627134
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250707_222904.wav",
|
||||
"name": "uhh",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 13.199152542372879,
|
||||
"endTime": 14.830508474576275
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250808_194926.wav",
|
||||
"name": "rights",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.131355932203387,
|
||||
"endTime": 10.69915254237289
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250809_193435.wav",
|
||||
"name": "u r wet",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.983050847457612,
|
||||
"endTime": 10.14830508474577
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250809_222039.wav",
|
||||
"name": "run",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 4.216101694915256,
|
||||
"endTime": 11.038135593220332
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250814_215842.wav",
|
||||
"name": "suprise",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.927966101694913,
|
||||
"endTime": 14.300847457627116
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250920_174822.wav",
|
||||
"name": "my",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 5.736975857687425,
|
||||
"endTime": 6.202880135535784
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250920_174950.wav",
|
||||
"name": "whatsup",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 2.870606674248936,
|
||||
"endTime": 3.3193015062831197
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251018_211620.wav",
|
||||
"name": "cheeks",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.338983050847464,
|
||||
"endTime": 12.394067796610184
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251031_211310.wav",
|
||||
"name": "michal",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.516949152542374,
|
||||
"endTime": 12.415254237288133
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251107_222607.wav",
|
||||
"name": "blegh",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.216101694915253,
|
||||
"endTime": 9.957627118644073
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251115_201357.wav",
|
||||
"name": "bohemian",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.618644067796604,
|
||||
"endTime": 11.274508356463695
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251213_114932.wav",
|
||||
"name": "electro",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.915254237288137,
|
||||
"endTime": 13.771186440677946
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20260201_111049.wav",
|
||||
"name": "nword",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.834745762711867,
|
||||
"endTime": 10.911016949152565
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20260206_230124.wav",
|
||||
"name": "fist",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 12.458333333333336,
|
||||
"endTime": 13.708333333333327
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "jake",
|
||||
"id": 2,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250117_194006.wav",
|
||||
"name": "do it",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 14.152542372881365,
|
||||
"endTime": 14.936440677966102
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250121_223258.wav",
|
||||
"name": "cooch",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.538135593220337,
|
||||
"endTime": 10.656779661016952
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250215_214039.wav",
|
||||
"name": "domestic",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.40254237288135,
|
||||
"endTime": 13.05084745762703
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250412_134821.wav",
|
||||
"name": "god",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.927966101694915,
|
||||
"endTime": 13.834745762711863
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250606_212121.wav",
|
||||
"name": "poop\nmyself",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 7.881355932203395,
|
||||
"endTime": 12.055084745762716
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250719_120451.wav",
|
||||
"name": "tasmania",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.038135593220334,
|
||||
"endTime": 13.686440677966088
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250822_205916.wav",
|
||||
"name": "jews",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 11.122881355932197,
|
||||
"endTime": 12.097457627118638
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251026_211500.wav",
|
||||
"name": "terror",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.572033898305074,
|
||||
"endTime": 11.588983050847439
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251108_170721.wav",
|
||||
"name": "toon\ntown",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 5.148305084745765,
|
||||
"endTime": 8.411016949152545
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20260103_222442.wav",
|
||||
"name": "whooping",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 7.309322033898307,
|
||||
"endTime": 9.046610169491542
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20260107_210712.wav",
|
||||
"name": "no head",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.050847457627118,
|
||||
"endTime": 9.279661016949134
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "isaac",
|
||||
"id": 3,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250121_203752.wav",
|
||||
"name": "blow",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.800018422991895,
|
||||
"endTime": 11.453804347826084
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250125_002323.wav",
|
||||
"name": "frying",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.337093249867106,
|
||||
"endTime": 11.49862694147519
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250125_230923.wav",
|
||||
"name": "cum",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.728813559322031,
|
||||
"endTime": 9.894067796610173
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250126_131833.wav",
|
||||
"name": "liquid",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.487288135593221,
|
||||
"endTime": 11.86440677966102
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250131_220452.wav",
|
||||
"name": "nuts",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.135593220338984,
|
||||
"endTime": 8.983050847457633
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251101_205146.wav",
|
||||
"name": "hard",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.283898305084744,
|
||||
"endTime": 10.720338983050835
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "nat",
|
||||
"id": 4,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250125_171754.wav",
|
||||
"name": "hot dog",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 8.644067796610168,
|
||||
"endTime": 11.05932203389828
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250131_212540.wav",
|
||||
"name": "plink",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 10.40254237288135,
|
||||
"endTime": 12.012711864406779
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "misc",
|
||||
"id": 5,
|
||||
"clips": [
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250223_110900.wav",
|
||||
"name": "bounce",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 2.521186440677966,
|
||||
"endTime": 7.4152542372881225
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250228_221700.wav",
|
||||
"name": "avada",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 1.8220338983050826,
|
||||
"endTime": 5.338983050847453
|
||||
},
|
||||
{
|
||||
"filename": "C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20251212_192830.wav",
|
||||
"name": "sandler",
|
||||
"playbackType": "playOverlap",
|
||||
"volume": 1,
|
||||
"startTime": 9.576271186440678,
|
||||
"endTime": 12.394067796610187
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
8
stream_deck_plugin/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
packages/
|
||||
*.log
|
||||
*.tmp
|
||||
ClipTrimDotNet/bin/
|
||||
ClipTrimDotNet/obj/
|
||||
ClipTrimDotNet/dist/
|
||||
ClipTrimDotNet/node_modules/
|
||||
.vs/
|
||||
25
stream_deck_plugin/ClipTrimDotNet.sln
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.8.34330.188
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClipTrimDotNet", "ClipTrimDotNet\ClipTrimDotNet.csproj", "{4635D874-69C0-4010-BE46-77EF92EB1553}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4635D874-69C0-4010-BE46-77EF92EB1553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4635D874-69C0-4010-BE46-77EF92EB1553}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4635D874-69C0-4010-BE46-77EF92EB1553}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4635D874-69C0-4010-BE46-77EF92EB1553}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {926C6896-F36A-4F3B-A9DD-CA3AA48AD99F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
14
stream_deck_plugin/ClipTrimDotNet/!!README!!.txt
Normal file
@ -0,0 +1,14 @@
|
||||
To use:
|
||||
1. Right click the project and choose "Manage Nuget Packages"
|
||||
2. Choose the restore option in the Nuget screen (or just install the latest StreamDeck-Tools from Nuget)
|
||||
3. Update the manifest.json file with the correct details about your plugin
|
||||
4. Modify PluginAction.cs as needed (it holds the logic for your plugin)
|
||||
5. Modify the PropertyInspector\PluginActionPI.html and PropertyInspector\PluginActionPI.js as needed to show field in the Property Inspector
|
||||
6. Before releasing, change the Assembly Information (Right click the project -> Properties -> Application -> Assembly Information...)
|
||||
|
||||
For help with StreamDeck-Tools:
|
||||
Discord Server: http://discord.barraider.com
|
||||
Resources:
|
||||
* StreamDeck-Tools samples and tutorial: https://github.com/BarRaider/streamdeck-tools
|
||||
* EasyPI library (for working with Property Inspector): https://github.com/BarRaider/streamdeck-easypi
|
||||
|
||||
42
stream_deck_plugin/ClipTrimDotNet/App.config
Normal file
@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="CommandLine" publicKeyToken="5a870481e358d379" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.6.0.0" newVersion="2.6.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Drawing.Common" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-8.0.0.0" newVersion="8.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Extensions.DependencyInjection.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.2" newVersion="10.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Json" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.2" newVersion="10.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Extensions.Logging.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.2" newVersion="10.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Extensions.Logging" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.2" newVersion="10.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Extensions.DependencyInjection" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.2" newVersion="10.0.0.2" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
1
stream_deck_plugin/ClipTrimDotNet/BaseTest.cs
Normal file
@ -0,0 +1 @@
|
||||
|
||||
53
stream_deck_plugin/ClipTrimDotNet/Client/ClipMetadata.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClipTrimDotNet.Client
|
||||
{
|
||||
public enum PlaybackType
|
||||
{
|
||||
playStop,
|
||||
playOverlap
|
||||
}
|
||||
public class ClipMetadata
|
||||
{
|
||||
[JsonProperty(PropertyName = "filename")]
|
||||
[JsonPropertyName("filename")]
|
||||
|
||||
public string Filename { get; set; }
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "name")]
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "volume")]
|
||||
[JsonPropertyName("volume")]
|
||||
public double Volume { get; set; } = 1.0;
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "startTime")]
|
||||
[JsonPropertyName("startTime")]
|
||||
public double StartTime { get; set; } = 0.0;
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "endTime")]
|
||||
[JsonPropertyName("endTime")]
|
||||
public double EndTime { get; set; } = 0.0;
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "playbackType")]
|
||||
[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))]
|
||||
[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
[JsonPropertyName("playbackType")]
|
||||
public PlaybackType PlaybackType { get; set; } = PlaybackType.playStop;
|
||||
}
|
||||
}
|
||||
287
stream_deck_plugin/ClipTrimDotNet/Client/ClipTrimClient.cs
Normal file
@ -0,0 +1,287 @@
|
||||
using BarRaider.SdTools;
|
||||
using ClipTrimDotNet.Keys;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Newtonsoft.Json;
|
||||
using SocketIOClient;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace ClipTrimDotNet.Client
|
||||
{
|
||||
public class ClipTrimClient
|
||||
{
|
||||
private static ClipTrimClient? instance;
|
||||
public static ClipTrimClient Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new ClipTrimClient();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
//private HttpClient httpClient;
|
||||
private SocketIO? socket;
|
||||
|
||||
public string HostName
|
||||
{
|
||||
get
|
||||
{
|
||||
//return $"http://localhost:5010/";
|
||||
return $"http://localhost:{GlobalSettings.Instance.PortNumber}/";
|
||||
}
|
||||
}
|
||||
|
||||
private string? currentHostname = null;
|
||||
|
||||
void CreateSocket()
|
||||
{
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"Starting ClipTrimClient on port {HostName}");
|
||||
socket = new SocketIO(new Uri(HostName));
|
||||
currentHostname = HostName;
|
||||
socket.Options.AutoUpgrade = false;
|
||||
//socket.Options.Path = "/socket.io";
|
||||
socket.Options.ConnectionTimeout = TimeSpan.FromSeconds(10);
|
||||
socket.Options.Reconnection = true;
|
||||
socket.On("full_data", ctx =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = ctx.GetValue<List<CollectionMetaData>>(0);
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"full_data event {JsonConvert.SerializeObject(response)}");
|
||||
Collections = response!;
|
||||
Player.TickAll();
|
||||
PageNavigator.TickAll();
|
||||
//Logger.Instance.LogMessage(TracingLevel.INFO, $"Collections {JsonConvert.SerializeObject(Collections)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"full_data error {ex.ToString()}");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
socket.On("collection_updated", ctx =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = ctx.GetValue<CollectionMetaData>(0)!;
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"collection_updated event {JsonConvert.SerializeObject(response)}");
|
||||
int index = Collections.FindIndex(x => x.Id == response.Id);
|
||||
if (index != -1)
|
||||
{
|
||||
Collections[index] = response;
|
||||
Player.TickAll();
|
||||
PageNavigator.TickAll();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
socket.OnConnected += (sender, e) =>
|
||||
{
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"Socket connected: {e}");
|
||||
};
|
||||
|
||||
socket.OnDisconnected += (sender, e) =>
|
||||
{
|
||||
Logger.Instance.LogMessage(TracingLevel.INFO, $"Socket disconnected: {e}");
|
||||
Task.Run(async () => await Connect());
|
||||
};
|
||||
Task.Run(async () => await Connect());
|
||||
}
|
||||
|
||||
public ClipTrimClient()
|
||||
{
|
||||
//httpClient = new HttpClient()
|
||||
//{
|
||||
// BaseAddress = new Uri("http://localhost:5010/"),
|
||||
// Timeout = TimeSpan.FromSeconds(10)
|
||||
//};
|
||||
CreateSocket();
|
||||
}
|
||||
|
||||
public async Task Connect()
|
||||
{
|
||||
if (socket is null) return;
|
||||
while (!socket.Connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<CollectionMetaData> Collections { get; private set; } = new List<CollectionMetaData>();
|
||||
public int SelectedCollection { get; private set; } = -1;
|
||||
|
||||
public Dictionary<int, int> CollectionIndexes { get; private set; } = new();
|
||||
public int PageIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SelectedCollection == -1) return 0;
|
||||
if (!CollectionIndexes.ContainsKey(SelectedCollection))
|
||||
{
|
||||
CollectionIndexes[SelectedCollection] = 0;
|
||||
}
|
||||
return CollectionIndexes[SelectedCollection];
|
||||
}
|
||||
set
|
||||
{
|
||||
if (SelectedCollection == -1) return;
|
||||
CollectionIndexes[SelectedCollection] = value;
|
||||
}
|
||||
}
|
||||
public bool PageMode { get; set; } = false;
|
||||
|
||||
public int PageCount
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SelectedCollection == -1) return 0;
|
||||
var collection = Collections[SelectedCollection];
|
||||
return (collection.Clips.Count - 1) / 10 + 1;
|
||||
}
|
||||
}
|
||||
public bool CanPageUp
|
||||
{
|
||||
get
|
||||
{
|
||||
if(PageMode) return false;
|
||||
if (SelectedCollection == -1) return false;
|
||||
return PageCount - PageIndex > 1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanPageDown
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PageMode) return false;
|
||||
return PageIndex > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void PageDown()
|
||||
{
|
||||
if (CanPageDown)
|
||||
{
|
||||
PageIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
public void PageUp()
|
||||
{
|
||||
if (CanPageUp)
|
||||
{
|
||||
PageIndex++;
|
||||
}
|
||||
}
|
||||
public List<string> GetCollectionNames()
|
||||
{
|
||||
//await GetMetadata();
|
||||
return Collections.Where(x => x.Name != "Uncategorized").Select(x => x.Name).ToList();
|
||||
}
|
||||
|
||||
public string GetCurrentCollectionName()
|
||||
{
|
||||
if (SelectedCollection == -1) return "";
|
||||
return Collections[SelectedCollection].Name;
|
||||
}
|
||||
|
||||
public string GetPlayerStringByCoordinateIndex(int index)
|
||||
{
|
||||
if (PageMode)
|
||||
{
|
||||
int pageNumber = index + 1;
|
||||
if(pageNumber <= PageCount)
|
||||
{
|
||||
return pageNumber.ToString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
else
|
||||
{
|
||||
var collection = GetClipByPagedIndex(index);
|
||||
return collection?.Name ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public ClipMetadata? GetClipByPagedIndex(int index)
|
||||
{
|
||||
SelectedCollection = Collections.FindIndex(x => x.Name == GlobalSettings.Instance.ProfileName);
|
||||
if (SelectedCollection == -1) return null;
|
||||
int clipIndex = PageIndex * 10 + index;
|
||||
var collection = Collections[SelectedCollection];
|
||||
if (clipIndex >= 0 && clipIndex < collection.Clips.Count)
|
||||
{
|
||||
return collection.Clips[clipIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async void PlayClip(int index)
|
||||
{
|
||||
if (PageMode)
|
||||
{
|
||||
if(index < 0 || index >= PageCount) return;
|
||||
PageIndex = index;
|
||||
PageMode = false;
|
||||
Player.TickAll();
|
||||
PageNavigator.TickAll();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (socket is null) return;
|
||||
var metadata = GetClipByPagedIndex(index);
|
||||
if (metadata == null) return;
|
||||
//Logger.Instance.LogMessage(TracingLevel.INFO, $"playing clip:");
|
||||
await socket.EmitAsync("play_clip", new List<object>() { metadata });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async void SaveClip()
|
||||
{
|
||||
if (socket is null) return;
|
||||
await socket.EmitAsync("record_clip", new List<object>() { });
|
||||
}
|
||||
|
||||
public async void CheckPort()
|
||||
{
|
||||
if (socket is null) return;
|
||||
//Logger.Instance.LogMessage(TracingLevel.INFO, $"Checking port {socket}");
|
||||
if (currentHostname != HostName)
|
||||
{
|
||||
//Logger.Instance.LogMessage(TracingLevel.INFO, $"port {socket}");
|
||||
if (socket.Connected)
|
||||
{
|
||||
await socket.DisconnectAsync();
|
||||
}
|
||||
socket.Dispose();
|
||||
CreateSocket();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ClipTrimDotNet.Client
|
||||
{
|
||||
public class CollectionMetaData
|
||||
{
|
||||
[JsonProperty(PropertyName = "name")]
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "clips")]
|
||||
[JsonPropertyName("clips")]
|
||||
public List<ClipMetadata> Clips { get; set; } = new List<ClipMetadata>();
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
}
|
||||
118
stream_deck_plugin/ClipTrimDotNet/ClipTrimDotNet.csproj
Normal file
@ -0,0 +1,118 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<OutputType>Exe</OutputType>
|
||||
<LangVersion>10</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PreBuildEvent>npm run stop</PreBuildEvent>
|
||||
<PostBuildEvent>npm run start</PostBuildEvent>
|
||||
<AssemblyTitle>ClipTrimDotNet</AssemblyTitle>
|
||||
<Product>ClipTrimDotNet</Product>
|
||||
<Copyright>Copyright © 2020</Copyright>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<OutputPath>bin\Debug\com.michal-courson.cliptrim.sdPlugin\</OutputPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<OutputPath>bin\Release\ClipTrimDotNet.sdPlugin\</OutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Images\app_icon.png" />
|
||||
<None Remove="Images\back.png" />
|
||||
<None Remove="Images\category_icon.png" />
|
||||
<None Remove="Images\collection.png" />
|
||||
<None Remove="Images\collection_icon.png" />
|
||||
<None Remove="Images\page_nav.png" />
|
||||
<None Remove="Images\page_nav_icon.png" />
|
||||
<None Remove="Images\player.png" />
|
||||
<None Remove="Images\player_icon.png" />
|
||||
<None Remove="Images\record.png" />
|
||||
<None Remove="Images\record_icon.png" />
|
||||
<None Remove="manifest.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Primitives" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="NLog" Version="6.0.5" />
|
||||
<PackageReference Include="SocketIOClient" Version="4.0.0.2" />
|
||||
<PackageReference Include="SocketIOClient.Common" Version="4.0.0" />
|
||||
<PackageReference Include="SocketIOClient.Serializer" Version="4.0.0.1" />
|
||||
<PackageReference Include="SocketIOClient.Serializer.NewtonsoftJson" Version="4.0.0.1" />
|
||||
<PackageReference Include="StreamDeck-Tools" Version="6.3.2" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="10.0.2" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.10" />
|
||||
<PackageReference Include="System.IO.Pipelines" Version="10.0.2" />
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
|
||||
<PackageReference Include="System.Security.AccessControl" Version="6.0.1" />
|
||||
<PackageReference Include="System.Text.Encodings.Web" Version="10.0.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.3" />
|
||||
<PackageReference Include="CoreWCF.Primitives" Version="1.8.0" />
|
||||
<PackageReference Include="CoreWCF.ConfigurationManager" Version="1.8.0" />
|
||||
<PackageReference Include="CoreWCF.Http" Version="1.8.0" />
|
||||
<PackageReference Include="CoreWCF.WebHttp" Version="1.8.0" />
|
||||
<PackageReference Include="CoreWCF.NetTcp" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="DialLayout.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="!!README!!.txt" />
|
||||
<Content Include="Images\app_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\back.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\category_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\collection.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\collection_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\page_nav.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\page_nav_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\player_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\record.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\player.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Images\record_icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="manifest.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="package.json" />
|
||||
<Content Include="PropertyInspector\profile_swticher.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="PropertyInspector\file_player.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|AnyCPU'">
|
||||
<StartArguments>--port 23654 --pluginUUID com.michal-courson.cliptrim --registerEvent restart --info {}</StartArguments>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
40
stream_deck_plugin/ClipTrimDotNet/DialLayout.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "sampleDial",
|
||||
"items": [
|
||||
{
|
||||
"key": "title",
|
||||
"type": "text",
|
||||
"rect": [ 16, 10, 136, 24 ],
|
||||
"font": {
|
||||
"size": 16,
|
||||
"weight": 600
|
||||
},
|
||||
"alignment": "left"
|
||||
},
|
||||
{
|
||||
"key": "icon",
|
||||
"type": "pixmap",
|
||||
"rect": [ 16, 40, 48, 48 ]
|
||||
},
|
||||
{
|
||||
"key": "value",
|
||||
"type": "text",
|
||||
"rect": [ 76, 40, 108, 32 ],
|
||||
"font": {
|
||||
"size": 24,
|
||||
"weight": 600
|
||||
},
|
||||
"alignment": "right"
|
||||
},
|
||||
{
|
||||
"key": "indicator",
|
||||
"type": "gbar",
|
||||
"rect": [ 76, 74, 108, 20 ],
|
||||
"value": 0,
|
||||
"subtype": 4,
|
||||
"bar_h": 12,
|
||||
"border_w": 0,
|
||||
"bar_bg_c": "0:#427018,0.75:#705B1C,0.90:#702735,1:#702735"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
stream_deck_plugin/ClipTrimDotNet/GlobalSettings.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using BarRaider.SdTools;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BarRaider.SdTools.Wrappers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ClipTrimDotNet
|
||||
{
|
||||
public class GlobalSettings
|
||||
{
|
||||
public static GlobalSettings? _inst;
|
||||
public static GlobalSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
_inst ??= CreateDefaultSettings();
|
||||
return _inst;
|
||||
}
|
||||
set
|
||||
{
|
||||
_inst = value;
|
||||
}
|
||||
}
|
||||
public static GlobalSettings CreateDefaultSettings()
|
||||
{
|
||||
GlobalSettings instance = new GlobalSettings();
|
||||
instance.ProfileName = null;
|
||||
instance.PortNumber = 5010;
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
[JsonProperty(PropertyName = "profileName")]
|
||||
public string? ProfileName { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "portNumber")]
|
||||
public int? PortNumber { get; set; }
|
||||
|
||||
|
||||
public void SetCurrentProfile(string profile)
|
||||
{
|
||||
ProfileName = profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
stream_deck_plugin/ClipTrimDotNet/Images/app_icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
stream_deck_plugin/ClipTrimDotNet/Images/app_icon.psd
Normal file
BIN
stream_deck_plugin/ClipTrimDotNet/Images/back.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
stream_deck_plugin/ClipTrimDotNet/Images/back.psd
Normal file
BIN
stream_deck_plugin/ClipTrimDotNet/Images/category_icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
stream_deck_plugin/ClipTrimDotNet/Images/collection.png
Normal file
|
After Width: | Height: | Size: 464 B |