python service managment on client, port configuration

This commit is contained in:
michalcourson
2026-02-24 18:08:58 -05:00
parent d49ac95fa2
commit 47cdaa76b6
26 changed files with 244 additions and 67 deletions

View File

@ -1,16 +1,17 @@
{ {
"input_device": { "input_device": {
"index": 49, "channels": 2,
"name": "Microphone (Logi C615 HD WebCam)", "default_samplerate": 48000,
"max_input_channels": 1, "index": 55,
"default_samplerate": 48000.0 "name": "VM Mic mix (VB-Audio Voicemeeter VAIO)"
}, },
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings", "save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\audio-service\\recordings",
"recording_length": 30, "recording_length": 30,
"output_device": { "output_device": {
"default_samplerate": 48000,
"index": 40, "index": 40,
"name": "Speakers (Realtek(R) Audio)",
"max_output_channels": 2, "max_output_channels": 2,
"default_samplerate": 48000.0 "name": "Speakers (Realtek(R) Audio)"
} },
"http_port": 5010
} }

View File

@ -10,7 +10,7 @@ class AudioRecorder:
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls._instance is None: if cls._instance is None:
print("Creating new AudioRecorder instance") # print("Creating new AudioRecorder instance")
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance.init() cls._instance.init()
return cls._instance return cls._instance
@ -22,7 +22,7 @@ class AudioRecorder:
:param sample_rate: Audio sample rate (if None, use default device sample rate) :param sample_rate: Audio sample rate (if None, use default device sample rate)
:param channels: Number of audio channels :param channels: Number of audio channels
""" """
print(f"Initializing AudioRecorder") # print(f"Initializing AudioRecorder")
self.duration = 30 self.duration = 30
self.sample_rate = 44100 self.sample_rate = 44100
self.channels = 2 self.channels = 2
@ -42,7 +42,7 @@ class AudioRecorder:
self.stream.stop() self.stream.stop()
self.buffer = np.zeros((int(self.duration * self.sample_rate), self.channels), dtype=np.float32) self.buffer = np.zeros((int(self.duration * self.sample_rate), self.channels), dtype=np.float32)
print(f"AudioRecorder initialized with duration={self.duration}s, sample_rate={self.sample_rate}Hz, channels={self.channels}") # print(f"AudioRecorder initialized with duration={self.duration}s, sample_rate={self.sample_rate}Hz, channels={self.channels}")
self.stream = sd.InputStream( self.stream = sd.InputStream(
callback=self.record_callback callback=self.record_callback
) )
@ -63,7 +63,8 @@ class AudioRecorder:
:param status: Recording status :param status: Recording status
""" """
if status: if status:
print(f"Recording status: {status}") # print(f"Recording status: {status}")
pass
# Circular buffer implementation # Circular buffer implementation
self.buffer = np.roll(self.buffer, -frames, axis=0) self.buffer = np.roll(self.buffer, -frames, axis=0)
@ -128,9 +129,9 @@ class AudioRecorder:
Start continuous audio recording with circular buffer. Start continuous audio recording with circular buffer.
""" """
if(self.stream.active): if(self.stream.active):
print("Already recording") # print("Already recording")
return return
print('number of channels', self.channels) # print('number of channels', self.channels)
self.stream.start() self.stream.start()
@ -139,7 +140,7 @@ class AudioRecorder:
Stop continuous audio recording with circular buffer. Stop continuous audio recording with circular buffer.
""" """
if(not self.stream.active): if(not self.stream.active):
print("Already stopped") # print("Already stopped")
return return
self.stream.stop() self.stream.stop()

View File

@ -46,23 +46,20 @@ def main():
app.register_blueprint(device_bp) app.register_blueprint(device_bp)
app.register_blueprint(metadata_bp) app.register_blueprint(metadata_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.run(host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True) app.run(host='127.0.0.1', port=settings.get_settings('http_port'), debug=False, use_reloader=True)
# socketio.run(app, host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True) # socketio.run(app, host='127.0.0.1', port=args.osc_port, debug=False, use_reloader=True)
# Run the OSC server # Run the OSC server
try: # try:
print(f"Starting OSC Recording Server on port {args.osc_port}") # print(f"Starting OSC Recording Server on port {args.osc_port}")
# # osc_server.run_server()
# except KeyboardInterrupt:
# print("\nServer stopped by user.")
# osc_server.run_server() # except Exception as e:
except KeyboardInterrupt: # print(f"Error starting server: {e}")
print("\nServer stopped by user.") # sys.exit(1)
except Exception as e:
print(f"Error starting server: {e}")
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -91,7 +91,7 @@ def edit_clip_in_collection():
meta_manager = MetaDataManager() meta_manager = MetaDataManager()
collection_name = request.json.get('name') collection_name = request.json.get('name')
clip_metadata = request.json.get('clip') clip_metadata = request.json.get('clip')
print(f"Received request to edit clip in collection '{collection_name}': {clip_metadata}") # print(f"Received request to edit clip in collection '{collection_name}': {clip_metadata}")
try: try:
meta_manager.edit_clip_in_collection(collection_name, clip_metadata) meta_manager.edit_clip_in_collection(collection_name, clip_metadata)
collections = meta_manager.collections collections = meta_manager.collections

View File

@ -15,14 +15,14 @@ def start_recording():
@recording_bp.route('/record/stop', methods=['POST']) @recording_bp.route('/record/stop', methods=['POST'])
def stop_recording(): def stop_recording():
recorder = AudioRecorder() recorder = AudioRecorder()
print('HTTP: Stopping audio recording') # print('HTTP: Stopping audio recording')
recorder.stop_recording() recorder.stop_recording()
return jsonify({'status': 'recording stopped'}) return jsonify({'status': 'recording stopped'})
@recording_bp.route('/record/save', methods=['POST']) @recording_bp.route('/record/save', methods=['POST'])
def save_recording(): def save_recording():
recorder = AudioRecorder() recorder = AudioRecorder()
print('HTTP: Saving audio recording') # print('HTTP: Saving audio recording')
saved_file = recorder.save_last_n_seconds() saved_file = recorder.save_last_n_seconds()
return jsonify({'status': 'recording saved', 'file': saved_file}) return jsonify({'status': 'recording saved', 'file': saved_file})
@ -30,7 +30,7 @@ def save_recording():
@recording_bp.route('/record/status', methods=['GET']) @recording_bp.route('/record/status', methods=['GET'])
def recording_status(): def recording_status():
recorder = AudioRecorder() recorder = AudioRecorder()
print('HTTP: Checking recording status') # print('HTTP: Checking recording status')
status = 'recording' if recorder.is_recording() else 'stopped' status = 'recording' if recorder.is_recording() else 'stopped'
return jsonify({'status': status}) return jsonify({'status': status})
@ -45,7 +45,7 @@ def recording_delete():
@recording_bp.route('/playback/start', methods=['POST']) @recording_bp.route('/playback/start', methods=['POST'])
def playback_start(): def playback_start():
print('HTTP: Starting audio playback') # print('HTTP: Starting audio playback')
try: try:
# os.remove(filename) # os.remove(filename)
return jsonify({'status': 'success'}) return jsonify({'status': 'success'})

View File

@ -16,10 +16,16 @@ def get_setting(name):
else: else:
return jsonify({'status': 'error', 'message': f'Setting "{name}" not found'}), 404 return jsonify({'status': 'error', 'message': f'Setting "{name}" not found'}), 404
@settings_bp.route('/settings/<name>', methods=['POST']) @settings_bp.route('/settings/update', methods=['POST'])
def set_setting(name): def set_all_settings():
value = request.json.get('value') settings = request.json.get('settings')
if value is None: print (f"Received settings update: {settings}")
return jsonify({'status': 'error', 'message': 'Value is required'}), 400 if settings is None:
return jsonify({'status': 'error', 'message': 'Settings are required'}), 400
try:
for name, value in settings.items():
print(f"Updating setting '{name}' to '{value}'")
SettingsManager().set_settings(name, value) SettingsManager().set_settings(name, value)
return jsonify({'status': 'success', 'name': name, 'value': value}) return jsonify({'status': 'success', 'settings': settings})
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400

View File

@ -13,6 +13,7 @@ class SettingsManager:
return cls._instance return cls._instance
def init(self): def init(self):
# read settings file from executing directory # read settings file from executing directory
print("Initializing SettingsManager", os.getcwd())
self.settings_file = os.path.join(os.getcwd(), "settings.json") self.settings_file = os.path.join(os.getcwd(), "settings.json")
if os.path.exists(self.settings_file): if os.path.exists(self.settings_file):
with open(self.settings_file, "r") as f: with open(self.settings_file, "r") as f:
@ -46,18 +47,23 @@ class SettingsManager:
#see if input device is in "devices", if not set to the first index #see if input device is in "devices", if not set to the first index
if input is not None and any(d['name'] == input["name"] for d in input_devices): if input is not None and any(d['name'] == input["name"] for d in input_devices):
print(f"Using saved input device index: {input}") # print(f"Using saved input device index: {input}")
pass
else: else:
input = input_devices[0] if input_devices else None input = input_devices[0] if input_devices else None
self.settings["input_device"] = input self.settings["input_device"] = input
#see if output device is in "devices", if not set to the first index #see if output device is in "devices", if not set to the first index
if output is not None and any(d['name'] == output["name"] for d in output_devices): if output is not None and any(d['name'] == output["name"] for d in output_devices):
print(f"Using saved output device index: {output}") # print(f"Using saved output device index: {output}")
pass
else: else:
output = output_devices[0] if output_devices else None output = output_devices[0] if output_devices else None
self.settings["output_device"] = output self.settings["output_device"] = output
if not "http_port" in self.settings:
self.settings["http_port"] = 5010
self.save_settings() self.save_settings()
@ -71,6 +77,8 @@ class SettingsManager:
return self.settings return self.settings
def set_settings(self, name, value): def set_settings(self, name, value):
if(name not in self.settings):
raise ValueError(f"Setting '{name}' not found.")
self.settings[name] = value self.settings[name] = value
self.save_settings() self.save_settings()

View File

@ -25,11 +25,11 @@ class WindowsAudioManager:
wasapi_device_indexes = api['devices'] wasapi_device_indexes = api['devices']
break break
# print(f"Host APIs: {host_apis}") # print(f"Host APIs: {host_apis}")
print(f"WASAPI Device Indexes: {wasapi_device_indexes}") # print(f"WASAPI Device Indexes: {wasapi_device_indexes}")
wasapi_device_indexes = set(wasapi_device_indexes) if wasapi_device_indexes is not None else set() wasapi_device_indexes = set(wasapi_device_indexes) if wasapi_device_indexes is not None else set()
self.devices = [dev for dev in sd.query_devices() if dev['index'] in wasapi_device_indexes] self.devices = [dev for dev in sd.query_devices() if dev['index'] in wasapi_device_indexes]
# self.devices = sd.query_devices() # self.devices = sd.query_devices()
print(f"devices: {self.devices}") # print(f"devices: {self.devices}")
self.default_input = sd.default.device[0] self.default_input = sd.default.device[0]
self.default_output = sd.default.device[1] self.default_output = sd.default.device[1]

16
electron-ui/settings.json Normal file
View File

@ -0,0 +1,16 @@
{
"input_device": {
"index": 49,
"name": "Microphone (Logi C615 HD WebCam)",
"channels": 1,
"default_samplerate": 48000.0
},
"output_device": {
"index": 40,
"name": "Speakers (Realtek(R) Audio)",
"channels": 2,
"default_samplerate": 48000.0
},
"save_path": "C:\\Users\\mickl\\Desktop\\cliptrim-ui\\ClipTrimApp\\electron-ui\\recordings",
"recording_length": 15
}

View File

@ -1,5 +1,7 @@
const AudioChannels = { const AudioChannels = {
LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer', LOAD_AUDIO_BUFFER: 'audio:loadAudioBuffer',
GET_PORT: 'audio:getPort',
RESTART_SERVICE: 'audio:restartService',
} as const; } as const;
export default AudioChannels; export default AudioChannels;

View File

@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import fs from 'fs'; import fs from 'fs';
import AudioChannels from './channels'; import AudioChannels from './channels';
import { LoadAudioBufferArgs, LoadAudioBufferResult } from './types'; import { LoadAudioBufferArgs, LoadAudioBufferResult } from './types';
import PythonSubprocessManager from '../../main/service';
export default function registerAudioIpcHandlers() { export default function registerAudioIpcHandlers() {
ipcMain.handle( ipcMain.handle(
@ -15,4 +16,25 @@ export default function registerAudioIpcHandlers() {
} }
}, },
); );
ipcMain.handle(AudioChannels.GET_PORT, async () => {
try {
if (PythonSubprocessManager.instance?.portNumber) {
return { port: PythonSubprocessManager.instance.portNumber };
}
return { error: 'Port number not available yet.' };
} catch (err: any) {
return { error: err.message };
}
});
ipcMain.handle(AudioChannels.RESTART_SERVICE, async () => {
try {
PythonSubprocessManager.instance?.restart();
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
});
} }

View File

@ -6,3 +6,22 @@ export interface LoadAudioBufferResult {
buffer?: Buffer; buffer?: Buffer;
error?: string; error?: string;
} }
export interface GetPortResult {
port?: number;
error?: string;
}
export interface SetPortArgs {
port: number;
}
export interface SetPortResult {
success: boolean;
error?: string;
}
export interface RestartServiceResult {
success: boolean;
error?: string;
}

View File

@ -16,6 +16,7 @@ import log from 'electron-log';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { resolveHtmlPath } from './util'; import { resolveHtmlPath } from './util';
import registerFileIpcHandlers from '../ipc/audio/main'; import registerFileIpcHandlers from '../ipc/audio/main';
import PythonSubprocessManager from './service';
class AppUpdater { class AppUpdater {
constructor() { constructor() {
@ -110,6 +111,10 @@ const createWindow = async () => {
}); });
registerFileIpcHandlers(); registerFileIpcHandlers();
const pythonManager = new PythonSubprocessManager('src/main.py');
pythonManager.start();
// Remove this if your app does not use auto updates // Remove this if your app does not use auto updates
// eslint-disable-next-line // eslint-disable-next-line
new AppUpdater(); new AppUpdater();

View File

@ -1,8 +1,7 @@
// Disable no-unused-vars, broken for spread args // Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */ /* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import FileChannels from '../ipc/audio/channels'; import { LoadAudioBufferArgs } from '../ipc/audio/types';
import { LoadAudioBufferArgs, ReadTextArgs } from '../ipc/audio/types';
import AudioChannels from '../ipc/audio/channels'; import AudioChannels from '../ipc/audio/channels';
// import '../ipc/file/preload'; // Import file API preload to ensure it runs and exposes the API // import '../ipc/file/preload'; // Import file API preload to ensure it runs and exposes the API
@ -41,10 +40,8 @@ const audioHandler = {
filePath, filePath,
} satisfies LoadAudioBufferArgs), } satisfies LoadAudioBufferArgs),
readText: (filePath: string) => getPort: () => ipcRenderer.invoke(AudioChannels.GET_PORT),
ipcRenderer.invoke(AudioChannels.READ_TEXT, { restartService: () => ipcRenderer.invoke(AudioChannels.RESTART_SERVICE),
filePath,
} satisfies ReadTextArgs),
}; };
contextBridge.exposeInMainWorld('audio', audioHandler); contextBridge.exposeInMainWorld('audio', audioHandler);

View File

@ -0,0 +1,79 @@
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import path from 'path';
export default class PythonSubprocessManager {
// eslint-disable-next-line no-use-before-define
public static instance: PythonSubprocessManager | null = null;
private process: ChildProcessWithoutNullStreams | null = null;
private scriptPath: string;
private working_dir: string = path.join(
__dirname,
'..',
'..',
'..',
'audio-service',
);
public portNumber: number | null = null;
constructor(scriptPath: string) {
this.scriptPath = scriptPath;
PythonSubprocessManager.instance = this;
}
start(args: string[] = []): void {
if (this.process) {
throw new Error('Process already running.');
}
console.log(`Using Python working directory at: ${this.working_dir}`);
console.log(`Starting Python subprocess with script: ${this.scriptPath}`);
this.process = spawn(
'venv/Scripts/python.exe',
[this.scriptPath, ...args],
{
cwd: this.working_dir,
detached: false,
stdio: 'pipe',
},
);
this.process.stdout.on('data', (data: Buffer) => {
console.log(`Python stdout: ${data.toString()}`);
});
this.process.stderr.on('data', (data: Buffer) => {
console.error(`Python stderr: ${data.toString()}`);
const lines = data.toString().split('\n');
// eslint-disable-next-line no-restricted-syntax
for (const line of lines) {
const match = line.match(/Running on .*:(\d+)/);
if (match) {
const port = parseInt(match[1], 10);
console.log(`Detected port: ${port}`);
this.portNumber = port;
}
}
});
this.process.on('exit', () => {
console.log('Python subprocess exited.');
this.process = null;
});
}
stop(): void {
if (this.process) {
this.process.kill();
this.process = null;
}
}
restart(args: string[] = []): void {
this.stop();
this.start(args);
}
isHealthy(): boolean {
return !!this.process && !this.process.killed;
}
}

View File

@ -14,7 +14,7 @@ import { useAppDispatch, useAppSelector } from './hooks';
import { store } from '../redux/main'; import { store } from '../redux/main';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SettingsPage from './Settings'; import SettingsPage from './Settings';
import { apiFetch } from './api'; import apiFetch from './api';
function MainPage() { function MainPage() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@ -5,7 +5,7 @@ import './App.css';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { apiFetch } from './api'; import apiFetch from './api';
type AudioDevice = { type AudioDevice = {
index: number; index: number;
@ -57,10 +57,26 @@ async function fetchSettings(): Promise<Settings> {
}); });
} }
const sendSettingsToBackend = (settings: Settings) => { const sendSettingsToBackend = async (settings: Settings) => {
// Replace with actual backend call // Replace with actual backend call
// Example: window.api.updateSettings(settings); // Example: window.api.updateSettings(settings);
console.log('Settings updated:', settings); console.log('Settings updated:', settings);
await apiFetch('settings/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }),
})
.then((res) => res.json())
.then((data) => {
console.log('Settings update response:', data);
if (data.status === 'success') {
window.audio.restartService();
}
return data;
})
.catch((error) => {
console.error('Error updating settings:', error);
});
}; };
export default function SettingsPage() { export default function SettingsPage() {
@ -95,11 +111,10 @@ export default function SettingsPage() {
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {}, [settings]);
sendSettingsToBackend(settings);
}, [settings]);
const handleChange = () => { const handleChange = () => {
sendSettingsToBackend(settings);
// const { name, value } = e.target; // const { name, value } = e.target;
// setSettings((prev) => ({ // setSettings((prev) => ({
// ...prev, // ...prev,
@ -142,7 +157,7 @@ export default function SettingsPage() {
type="text" type="text"
name="httpPort" name="httpPort"
value={settings.http_port} value={settings.http_port}
onBlur={() => console.log('port blur')} onBlur={() => handleChange()}
onChange={(e) => { onChange={(e) => {
if (!Number.isNaN(Number(e.target.value))) { if (!Number.isNaN(Number(e.target.value))) {
setSettings((prev) => ({ setSettings((prev) => ({
@ -168,8 +183,12 @@ export default function SettingsPage() {
if (newDevice) { if (newDevice) {
setSettings((prev) => ({ setSettings((prev) => ({
...prev, ...prev,
inputDevice: newDevice, input_device: newDevice,
})); }));
sendSettingsToBackend({
...settings,
input_device: newDevice,
});
} }
}} }}
className="ml-2 w-64" className="ml-2 w-64"
@ -196,6 +215,10 @@ export default function SettingsPage() {
...prev, ...prev,
output_device: newDevice, output_device: newDevice,
})); }));
sendSettingsToBackend({
...settings,
output_device: newDevice,
});
} }
}} }}
className="ml-2 w-64" className="ml-2 w-64"
@ -222,6 +245,7 @@ export default function SettingsPage() {
})); }));
} }
}} }}
onBlur={() => handleChange()}
className="ml-2 w-[150px]" className="ml-2 w-[150px]"
/> />
</div> </div>

View File

@ -1,13 +1,13 @@
const getBaseUrl = () => { const getBaseUrl = async () => {
const port = await window.audio.getPort();
if (port.error || !port.port) {
return `http://localhost:5010`;
}
// You can store the base URL in localStorage, a config file, or state // You can store the base URL in localStorage, a config file, or state
return localStorage.getItem('baseUrl') || 'http://localhost:5010'; return `http://localhost:${port.port}`;
}; };
export function apiFetch(endpoint: string, options = {}) { export default async function apiFetch(endpoint: string, options = {}) {
const url = `${getBaseUrl()}/${endpoint}`; const url = `${await getBaseUrl()}/${endpoint}`;
return fetch(url, options); return fetch(url, options);
} }
export function setBaseUrl(baseUrl: string) {
localStorage.setItem('baseUrl', baseUrl);
}

View File

@ -15,7 +15,7 @@ import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import AudioTrimmer from './AudioTrimer'; import AudioTrimmer from './AudioTrimer';
import { ClipMetadata } from '../../redux/types'; import { ClipMetadata } from '../../redux/types';
import { useAppDispatch, useAppSelector } from '../hooks'; import { useAppDispatch, useAppSelector } from '../hooks';
import { apiFetch } from '../api'; import apiFetch from '../api';
export interface ClipListProps { export interface ClipListProps {
collection: string; collection: string;

View File

@ -1,10 +1,10 @@
import { ElectronHandler, FileHandler } from '../main/preload'; import { ElectronHandler, AudioHandler } from '../main/preload';
declare global { declare global {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
interface Window { interface Window {
electron: ElectronHandler; electron: ElectronHandler;
audio: FileHandler; audio: AudioHandler;
} }
} }