initial
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"idf.pythonInstallPath": "C:\\Users\\mickl\\.espressif\\tools\\idf-python\\3.11.2\\python.exe"
|
||||||
|
}
|
||||||
6
audio-service/requirements.txt
Normal file
6
audio-service/requirements.txt
Normal file
@ -0,0 +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
|
||||||
BIN
audio-service/src/__pycache__/audio_recorder.cpython-310.pyc
Normal file
BIN
audio-service/src/__pycache__/audio_recorder.cpython-310.pyc
Normal file
Binary file not shown.
BIN
audio-service/src/__pycache__/osc_server.cpython-310.pyc
Normal file
BIN
audio-service/src/__pycache__/osc_server.cpython-310.pyc
Normal file
Binary file not shown.
BIN
audio-service/src/__pycache__/windows_audio.cpython-310.pyc
Normal file
BIN
audio-service/src/__pycache__/windows_audio.cpython-310.pyc
Normal file
Binary file not shown.
75
audio-service/src/audio_recorder.py
Normal file
75
audio-service/src/audio_recorder.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
12
audio-service/src/client_test.py
Normal file
12
audio-service/src/client_test.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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)
|
||||||
92
audio-service/src/main.py
Normal file
92
audio-service/src/main.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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__":
|
||||||
|
main()
|
||||||
130
audio-service/src/osc_server.py
Normal file
130
audio-service/src/osc_server.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
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()
|
||||||
4
audio-service/src/recordings/desktop.ini
Normal file
4
audio-service/src/recordings/desktop.ini
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[ViewState]
|
||||||
|
Mode=
|
||||||
|
Vid=
|
||||||
|
FolderType=Generic
|
||||||
97
audio-service/src/windows_audio.py
Normal file
97
audio-service/src/windows_audio.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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)
|
||||||
4
electron-ui/.gitignore
vendored
Normal file
4
electron-ui/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
17
electron-ui/.vscode/launch.json
vendored
Normal file
17
electron-ui/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Main Process",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"args" : ["."],
|
||||||
|
"outputCapture": "std"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
electron-ui/build/icon.ico
Normal file
BIN
electron-ui/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
electron-ui/build/icon.png
Normal file
BIN
electron-ui/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
5558
electron-ui/package-lock.json
generated
Normal file
5558
electron-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
electron-ui/package.json
Normal file
51
electron-ui/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "audio-clipper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "src/main.js",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.5.3",
|
||||||
|
|
||||||
|
"electron-reload": "^2.0.0-alpha.1",
|
||||||
|
"python-shell": "^5.0.0",
|
||||||
|
"wavefile": "^11.0.0",
|
||||||
|
"wavesurfer.js": "^6.6.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron . --enable-logging",
|
||||||
|
"build": "electron-builder",
|
||||||
|
"build:win": "electron-builder --win",
|
||||||
|
"build:mac": "electron-builder --mac",
|
||||||
|
"build:linux": "electron-builder --linux"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.michalcourson.cliptrimserivce",
|
||||||
|
"productName": "ClipTrim",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "../audio-service",
|
||||||
|
"to": "audio-service",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": ["nsis"],
|
||||||
|
"icon": "build/icon.ico"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": ["dmg"],
|
||||||
|
"icon": "build/icon.icns"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": ["AppImage"],
|
||||||
|
"icon": "build/icon.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"electron": "^13.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
electron-ui/src/assets/icon.png
Normal file
BIN
electron-ui/src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
electron-ui/src/assets/tray-icon.png
Normal file
BIN
electron-ui/src/assets/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
68
electron-ui/src/index.html
Normal file
68
electron-ui/src/index.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Audio Clip Trimmer</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="titlebar"></div>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Collections</h3>
|
||||||
|
<div id="collections-list"></div>
|
||||||
|
<button id="add-collection-btn" class="add-collection-btn">+ New Collection</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div id="nav-buttons">
|
||||||
|
<button id="settings-btn" class="nav-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="restart-btn" class="nav-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="audio-trimmers-section">
|
||||||
|
<div id="audio-trimmers-list" class="audio-trimmers-list">
|
||||||
|
<!-- Audio trimmers will be dynamically added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settings-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-modal">×</span>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div class="settings-group">
|
||||||
|
<label for="recording-length">Recording Length (seconds):</label>
|
||||||
|
<input type="number" id="recording-length" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
<div class="settings-group">
|
||||||
|
<label for="osc-port">OSC port:</label>
|
||||||
|
<input type="number" id="osc-port" min="5000" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="settings-group">
|
||||||
|
<label for="output-folder">Output Folder:</label>
|
||||||
|
<input type="text" id="output-folder" readonly>
|
||||||
|
<button id="select-output-folder">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-group">
|
||||||
|
<label for="input-device">Input Device:</label>
|
||||||
|
<select id="input-device"></select>
|
||||||
|
</div>
|
||||||
|
<button id="save-settings">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="node_modules/wavesurfer.js/dist/wavesurfer.min.js"></script>
|
||||||
|
<script src="node_modules/wavesurfer.js/dist/plugin/wavesurfer.regions.js"></script>
|
||||||
|
<script src="renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
480
electron-ui/src/main.js
Normal file
480
electron-ui/src/main.js
Normal file
@ -0,0 +1,480 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
234
electron-ui/src/metatadata.js
Normal file
234
electron-ui/src/metatadata.js
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
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;
|
||||||
823
electron-ui/src/renderer.js
Normal file
823
electron-ui/src/renderer.js
Normal file
@ -0,0 +1,823 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
355
electron-ui/src/styles.css
Normal file
355
electron-ui/src/styles.css
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
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%;
|
||||||
|
}
|
||||||
374
electron-ui/temp.json
Normal file
374
electron-ui/temp.json
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"name": "Microsoft Sound Mapper - Input",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"name": "Voicemeeter Out B1 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"name": "Headset Microphone (3- Arctis 7",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"name": "Voicemeeter Out B3 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"name": "Voicemeeter Out A5 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 5,
|
||||||
|
"name": "Voicemeeter Out A3 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 6,
|
||||||
|
"name": "Voicemeeter Out B2 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 7,
|
||||||
|
"name": "Voicemeeter Out A1 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 8,
|
||||||
|
"name": "Analogue 1 + 2 (Focusrite USB A",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 9,
|
||||||
|
"name": "Voicemeeter Out A4 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 10,
|
||||||
|
"name": "Headset Microphone (Oculus Virt",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 11,
|
||||||
|
"name": "Voicemeeter Out A2 (VB-Audio Vo",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 28,
|
||||||
|
"name": "Primary Sound Capture Driver",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 29,
|
||||||
|
"name": "Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 30,
|
||||||
|
"name": "Headset Microphone (3- Arctis 7 Chat)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 31,
|
||||||
|
"name": "Voicemeeter Out B3 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 32,
|
||||||
|
"name": "Voicemeeter Out A5 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 33,
|
||||||
|
"name": "Voicemeeter Out A3 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 34,
|
||||||
|
"name": "Voicemeeter Out B2 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 35,
|
||||||
|
"name": "Voicemeeter Out A1 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 36,
|
||||||
|
"name": "Analogue 1 + 2 (Focusrite USB Audio)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 37,
|
||||||
|
"name": "Voicemeeter Out A4 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 38,
|
||||||
|
"name": "Headset Microphone (Oculus Virtual Audio Device)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 39,
|
||||||
|
"name": "Voicemeeter Out A2 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 71,
|
||||||
|
"name": "Headset Microphone (3- Arctis 7 Chat)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 72,
|
||||||
|
"name": "Voicemeeter Out B3 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 73,
|
||||||
|
"name": "Voicemeeter Out A5 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 74,
|
||||||
|
"name": "Voicemeeter Out A3 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 75,
|
||||||
|
"name": "Voicemeeter Out B2 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 76,
|
||||||
|
"name": "Voicemeeter Out A1 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 77,
|
||||||
|
"name": "Analogue 1 + 2 (Focusrite USB Audio)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 78,
|
||||||
|
"name": "Voicemeeter Out A4 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 79,
|
||||||
|
"name": "Headset Microphone (Oculus Virtual Audio Device)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 80,
|
||||||
|
"name": "Voicemeeter Out A2 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 81,
|
||||||
|
"name": "Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 82,
|
||||||
|
"name": "Microphone (Voicemod VAD Wave)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 85,
|
||||||
|
"name": "Input (Voicemeeter Point 2)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 87,
|
||||||
|
"name": "Input (Voicemeeter Point 5)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 89,
|
||||||
|
"name": "Input (Voicemeeter Point 8)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 90,
|
||||||
|
"name": "Voicemeeter Out 2 (Voicemeeter Point 2)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 91,
|
||||||
|
"name": "Voicemeeter Out 5 (Voicemeeter Point 5)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 92,
|
||||||
|
"name": "Voicemeeter Out 8 (Voicemeeter Point 8)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 94,
|
||||||
|
"name": "Input (Voicemeeter Point 3)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 96,
|
||||||
|
"name": "Input (Voicemeeter Point 6)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 97,
|
||||||
|
"name": "Voicemeeter Out 3 (Voicemeeter Point 3)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 98,
|
||||||
|
"name": "Voicemeeter Out 6 (Voicemeeter Point 6)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 100,
|
||||||
|
"name": "Input (Voicemeeter Point 1)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 102,
|
||||||
|
"name": "Input (Voicemeeter Point 4)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 104,
|
||||||
|
"name": "Input (Voicemeeter Point 7)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 105,
|
||||||
|
"name": "Voicemeeter Out 1 (Voicemeeter Point 1)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 106,
|
||||||
|
"name": "Voicemeeter Out 4 (Voicemeeter Point 4)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 107,
|
||||||
|
"name": "Voicemeeter Out 7 (Voicemeeter Point 7)",
|
||||||
|
"max_input_channels": 8,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 108,
|
||||||
|
"name": "Stereo Mix (Realtek HD Audio Stereo input)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 109,
|
||||||
|
"name": "Line In (Realtek HD Audio Line input)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 110,
|
||||||
|
"name": "Microphone (Realtek HD Audio Mic input)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 114,
|
||||||
|
"name": "SteelSeries Sonar - Microphone (SteelSeries_Sonar_VAD Chat Capture Wave)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 120,
|
||||||
|
"name": "SteelSeries Sonar - Stream (SteelSeries_Sonar_VAD Stream Wave)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 123,
|
||||||
|
"name": "Input (OCULUSVAD Wave Speaker Headphone)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 124,
|
||||||
|
"name": "Headset Microphone (OCULUSVAD Wave Microphone Headphone)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 125,
|
||||||
|
"name": "Headset Microphone (Arctis 7 Chat)",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 44100.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 129,
|
||||||
|
"name": "Analogue 1 + 2 (wc4800_8016)",
|
||||||
|
"max_input_channels": 2,
|
||||||
|
"default_samplerate": 48000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 133,
|
||||||
|
"name": "Headset (@System32\\drivers\\bthhfenum.sys,#2;%1 Hands-Free%0\r\n;(Michal<61>s AirPods Pro - Find My))",
|
||||||
|
"max_input_channels": 1,
|
||||||
|
"default_samplerate": 8000.0
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user