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