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

View File

@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import fs from 'fs';
import AudioChannels from './channels';
import { LoadAudioBufferArgs, LoadAudioBufferResult } from './types';
import PythonSubprocessManager from '../../main/service';
export default function registerAudioIpcHandlers() {
ipcMain.handle(
@ -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;
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 { resolveHtmlPath } from './util';
import registerFileIpcHandlers from '../ipc/audio/main';
import PythonSubprocessManager from './service';
class AppUpdater {
constructor() {
@ -110,6 +111,10 @@ const createWindow = async () => {
});
registerFileIpcHandlers();
const pythonManager = new PythonSubprocessManager('src/main.py');
pythonManager.start();
// Remove this if your app does not use auto updates
// eslint-disable-next-line
new AppUpdater();

View File

@ -1,8 +1,7 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import FileChannels from '../ipc/audio/channels';
import { LoadAudioBufferArgs, ReadTextArgs } from '../ipc/audio/types';
import { LoadAudioBufferArgs } from '../ipc/audio/types';
import AudioChannels from '../ipc/audio/channels';
// import '../ipc/file/preload'; // Import file API preload to ensure it runs and exposes the API
@ -41,10 +40,8 @@ const audioHandler = {
filePath,
} satisfies LoadAudioBufferArgs),
readText: (filePath: string) =>
ipcRenderer.invoke(AudioChannels.READ_TEXT, {
filePath,
} satisfies ReadTextArgs),
getPort: () => ipcRenderer.invoke(AudioChannels.GET_PORT),
restartService: () => ipcRenderer.invoke(AudioChannels.RESTART_SERVICE),
};
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 { useNavigate } from 'react-router-dom';
import SettingsPage from './Settings';
import { apiFetch } from './api';
import apiFetch from './api';
function MainPage() {
const dispatch = useAppDispatch();

View File

@ -5,7 +5,7 @@ import './App.css';
import TextField from '@mui/material/TextField';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { apiFetch } from './api';
import apiFetch from './api';
type AudioDevice = {
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
// Example: window.api.updateSettings(settings);
console.log('Settings updated:', settings);
await apiFetch('settings/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }),
})
.then((res) => res.json())
.then((data) => {
console.log('Settings update response:', data);
if (data.status === 'success') {
window.audio.restartService();
}
return data;
})
.catch((error) => {
console.error('Error updating settings:', error);
});
};
export default function SettingsPage() {
@ -95,11 +111,10 @@ export default function SettingsPage() {
});
}, []);
useEffect(() => {
sendSettingsToBackend(settings);
}, [settings]);
useEffect(() => {}, [settings]);
const handleChange = () => {
sendSettingsToBackend(settings);
// const { name, value } = e.target;
// setSettings((prev) => ({
// ...prev,
@ -142,7 +157,7 @@ export default function SettingsPage() {
type="text"
name="httpPort"
value={settings.http_port}
onBlur={() => console.log('port blur')}
onBlur={() => handleChange()}
onChange={(e) => {
if (!Number.isNaN(Number(e.target.value))) {
setSettings((prev) => ({
@ -168,8 +183,12 @@ export default function SettingsPage() {
if (newDevice) {
setSettings((prev) => ({
...prev,
inputDevice: newDevice,
input_device: newDevice,
}));
sendSettingsToBackend({
...settings,
input_device: newDevice,
});
}
}}
className="ml-2 w-64"
@ -196,6 +215,10 @@ export default function SettingsPage() {
...prev,
output_device: newDevice,
}));
sendSettingsToBackend({
...settings,
output_device: newDevice,
});
}
}}
className="ml-2 w-64"
@ -222,6 +245,7 @@ export default function SettingsPage() {
}));
}
}}
onBlur={() => handleChange()}
className="ml-2 w-[150px]"
/>
</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
return localStorage.getItem('baseUrl') || 'http://localhost:5010';
return `http://localhost:${port.port}`;
};
export function apiFetch(endpoint: string, options = {}) {
const url = `${getBaseUrl()}/${endpoint}`;
export default async function apiFetch(endpoint: string, options = {}) {
const url = `${await getBaseUrl()}/${endpoint}`;
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 { ClipMetadata } from '../../redux/types';
import { useAppDispatch, useAppSelector } from '../hooks';
import { apiFetch } from '../api';
import apiFetch from '../api';
export interface ClipListProps {
collection: string;

View File

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