43 Commits

Author SHA1 Message Date
d3d5270889 closes #6 2026-03-03 17:47:45 -05:00
017a2ae5a4 multiline clip name 2026-03-01 17:47:59 -05:00
791abef1ef better auto focus for delete 2026-03-01 17:35:24 -05:00
31cc3079a8 kinda bad, but functional delete on enter 2026-03-01 17:31:14 -05:00
5e50b29625 trimmer jsx cleanup 2026-03-01 17:27:12 -05:00
d37cd773f8 proper closing, metadata file locations moved 2026-03-01 13:48:31 -05:00
801966e8d8 fix duplicate sockets, fix dll build error 2026-03-01 10:23:54 -05:00
39395fd846 system tray and package config 2026-02-28 19:09:45 -05:00
510b92f669 wavesurfer lazy loading 2026-02-28 17:28:49 -05:00
aefb3f2648 Merge branch 'socket' into react_migration 2026-02-28 17:04:44 -05:00
a613b26ba8 remove pycache 2026-02-28 17:04:11 -05:00
7a471041e7 port selection on plugin 2026-02-28 17:01:59 -05:00
ab57d8ef22 socket in client, new set of icons 2026-02-28 11:17:37 -05:00
69c9d80a82 plugin work, page navigation, reticks 2026-02-26 20:02:22 -05:00
8c83819a17 faster socket, clean up on plugin 2026-02-26 17:38:50 -05:00
ad07bf7fe6 remove client test app 2026-02-26 15:52:04 -05:00
bc40f9abe3 socket set up properly 2026-02-26 15:48:41 -05:00
e7f649ae0b * 'Line 1: Added 'using Newtonsoft.Json.Serialization;' to ensure that JsonPropertyAttribute is available, as it is defined in this namespace in Newtonsoft.Json.' in file 'stream_deck_plugin\ClipTrimDotNet\Client\ClipMetadata.cs' 2026-02-24 21:16:25 -05:00
e34903b20f * 'Line 3: Removed the duplicate using directive for Newtonsoft.Json.Converters.' in file 'stream_deck_plugin\ClipTrimDotNet\Client\ClipMetadata.cs' 2026-02-24 21:16:18 -05:00
192c959d39 * 'Line 1: Ensure both 'Newtonsoft.Json' and 'Newtonsoft.Json.Converters' namespaces are included, as JsonPropertyAttribute and StringEnumConverter are defined in these namespaces. This resolves the CS0246 error for missing JsonPropertyAttribute.' in file 'stream_deck_plugin\ClipTrimDotNet\Client\ClipMetadata.cs' 2026-02-24 21:16:12 -05:00
60123d7450 * 'Line 37: Replaced [JsonProperty(PropertyName = path)] with [JsonPropertyName(path)] to use the System.Text.Json.Serialization.JsonPropertyNameAttribute, which is available in .NET 8 and the current project.
Line 40: Replaced [JsonProperty(PropertyName = index)] with [JsonPropertyName(index)] to use the correct attribute from System.Text.Json.Serialization.
Line 42: Replaced [JsonProperty(PropertyName = playtype)] with [JsonPropertyName(playtype)] to use the correct attribute from System.Text.Json.Serialization.
Line 45: Replaced [JsonProperty(PropertyName = volume)] with [JsonPropertyName(volume)] to use the correct attribute from System.Text.Json.Serialization.
Line 5: Replaced the using directive for Newtonsoft.Json with System.Text.Json.Serialization, as the project does not reference Newtonsoft.Json and should use System.Text.Json.Serialization.JsonPropertyNameAttribute instead.' in file 'stream_deck_plugin\ClipTrimDotNet\Player.cs'
2026-02-24 21:16:05 -05:00
8265951bd4 Move assembly metadata to .csproj and clean AssemblyInfo.cs
Assembly metadata such as title, product, copyright, and version information was moved from Properties/AssemblyInfo.cs to the ClipTrimDotNet.csproj file. The corresponding attributes and comments were removed from AssemblyInfo.cs, leaving only the trademark, culture, and GUID attributes. This streamlines project configuration and centralizes assembly information in the project file.
2026-02-24 21:15:31 -05:00
757d5ef1a7 Update package versions in ClipTrimDotNet.csproj
Upgraded several NuGet package dependencies in ClipTrimDotNet.csproj, including Microsoft.Extensions.* packages, System.* packages, and System.Drawing.Common, from version 8.x to 10.x or 9.x where applicable. This ensures compatibility with newer frameworks and libraries, and may provide bug fixes and performance improvements. No other files were modified.
2026-02-24 21:15:29 -05:00
d78c49d0ad Update ClipTrimDotNet.csproj dependencies to latest versions
Modernized the ClipTrimDotNet.csproj by upgrading several Microsoft.Extensions and System.* package references to their latest 8.x versions, replacing or removing older references. Added new CoreWCF packages and System.Configuration.ConfigurationManager for enhanced WCF and configuration support. Cleaned up unused or redundant references to streamline the project dependencies.
2026-02-24 21:14:40 -05:00
089023e7cf Refactor ClipTrimDotNet.csproj to SDK style and .NET 8
Converted the project file to the modern SDK-style format, targeting .NET 8.0 instead of .NET Framework 4.8. Replaced explicit assembly references and manual NuGet package management with <PackageReference> elements for all dependencies. Removed legacy MSBuild properties, imports, and targets, simplifying the project structure. Updated build events and ensured content files are copied as needed. This modernization improves maintainability and compatibility with current .NET tooling.
2026-02-24 21:14:28 -05:00
db97747f2e Commit upgrade plan 2026-02-24 21:13:55 -05:00
1e7141c43f some tests 2026-02-24 21:11:26 -05:00
8fda2a03af server playback 2026-02-24 19:08:27 -05:00
47cdaa76b6 python service managment on client, port configuration 2026-02-24 18:08:58 -05:00
d49ac95fa2 settings work 2026-02-22 14:57:04 -05:00
f2718282c7 Merge branch 'plugin_migration' into react_migration 2026-02-22 13:11:40 -05:00
86e30e6ec3 settings page created 2026-02-22 13:10:22 -05:00
b8f26496a0 skeleton of drag and drop 2026-02-21 21:15:06 -05:00
a761b81dd1 fully functional runtime stuff. Need settings then new features 2026-02-21 20:42:11 -05:00
c1948182ec remove .vs files 2026-02-21 11:21:11 -05:00
8f367c9264 start of migration 2026-02-21 11:15:02 -05:00
9af8626dab include stream deck plugin 2026-02-21 09:02:16 -05:00
60355d176c collection list, move, delete 2026-02-20 20:21:08 -05:00
d6f4d4166b metadata fixes, redux start. Lifecycle fixes for regions, etc 2026-02-17 18:10:38 -05:00
f9fdfb629b fix python instance init, remove and edit clip meta, add meta on record 2026-02-14 11:57:12 -05:00
f3b883602e python service http refactor start 2026-02-14 11:24:09 -05:00
5516ce9212 draggables, ipc stuff 2026-02-11 19:42:52 -05:00
0346efd504 styling and building out trimmer component 2026-02-07 10:25:53 -05:00
129 changed files with 7106 additions and 3045 deletions

70
.github/upgrades/dotnet-upgrade-plan.md vendored Normal file
View 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

View File

@ -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
View File

@ -0,0 +1,2 @@
recordings/
__pycache__/

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

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

View 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": []
}
]

View File

@ -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

View 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
}

View 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

View 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))

View File

@ -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

View File

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

View File

@ -1,92 +1,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()

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
[ViewState]
Mode=
Vid=
FolderType=Generic
[ViewState]
Mode=
Vid=
FolderType=Generic

View 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

View 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'})

View 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

View 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

View File

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

View File

@ -0,0 +1,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()

View File

@ -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']

View File

@ -1,4 +1,4 @@
const tailwindcss = require('@tailwindcss/postcss');
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
module.exports = {

View File

@ -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,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
electron-ui/dll_err.txt Normal file

Binary file not shown.

View 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;

View File

@ -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;

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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"
}

View File

@ -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
View 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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

View 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;

View 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 };
}
});
}

View 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;
}

View File

@ -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

View File

@ -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;

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -1,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 {

View File

@ -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>
);
}

View 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>
);
}

View 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);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

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

View File

@ -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>

View File

@ -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
View 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
View File

@ -0,0 +1,8 @@
packages/
*.log
*.tmp
ClipTrimDotNet/bin/
ClipTrimDotNet/obj/
ClipTrimDotNet/dist/
ClipTrimDotNet/node_modules/
.vs/

View 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

View 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

View 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>

View File

@ -0,0 +1 @@


View 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;
}
}

View 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();
}
}
}
}

View File

@ -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; }
}
}

View 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>

View File

@ -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>

View 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"
}
]
}

View 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;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

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