From 4808e4d30fdb8f00695ca944b7acdf7c47dd100d Mon Sep 17 00:00:00 2001
From: Simon Hagger
Date: Mon, 24 Aug 2026 09:46:44 +0100
Subject: [PATCH 1/2] feat: stream Python-sidecar waveform through IPC into
renderer canvas
- Sidecar /waveform endpoint synthesizes multi-harmonic signal and
computes magnitude spectrum in pure Python (no new runtime deps)
- pythonWaveform validated IPC channel with envelope + zod schemas
- PythonSidecar.waveform() client, preload waveform() API, desktop-api
surface extension
- Sidecar Lab 'Signal Pipeline' pane polls sidecar at 150ms and renders
oscilloscope trace plus spectrum bars; interval cleared on destroy
---
.../python-sidecar/tests/test_service.py | 32 +++++
.../src/assets/python_sidecar/service.py | 57 +++++++-
apps/desktop-main/src/ipc/python-handlers.ts | 35 +++++
apps/desktop-main/src/python-sidecar.ts | 23 ++++
apps/desktop-preload/src/api/python-api.ts | 19 +++
.../python-sidecar-lab-page.css | 13 ++
.../python-sidecar-lab-page.html | 24 ++++
.../python-sidecar-lab-page.ts | 130 +++++++++++++++++-
.../desktop-api/src/lib/desktop-api.ts | 9 ++
libs/shared/contracts/src/lib/channels.ts | 1 +
.../contracts/src/lib/python.contract.ts | 20 +++
11 files changed, 360 insertions(+), 3 deletions(-)
diff --git a/apps/desktop-main/python-sidecar/tests/test_service.py b/apps/desktop-main/python-sidecar/tests/test_service.py
index d6f2663..43a9a32 100644
--- a/apps/desktop-main/python-sidecar/tests/test_service.py
+++ b/apps/desktop-main/python-sidecar/tests/test_service.py
@@ -122,6 +122,38 @@ def test_inspect_pdf_endpoint_rejects_missing_filepath():
thread.join(timeout=1)
+def test_waveform_endpoint_returns_samples_and_spectrum():
+ service = _load_service_module()
+ server = TCPServer(("127.0.0.1", 0), service._Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ status, payload = _request(
+ server.server_address[1], "GET", "/waveform?points=64"
+ )
+ assert status == 200
+ assert isinstance(payload, dict)
+ assert len(payload["samples"]) == 64
+ assert len(payload["spectrum"]) == 32
+ assert all(m >= 0 for m in payload["spectrum"])
+ assert payload["sampleRate"] > 0
+ assert "pythonVersion" not in payload or isinstance(
+ payload["pythonVersion"], str
+ )
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=1)
+
+
+def test_waveform_endpoint_defaults_points_when_invalid():
+ service = _load_service_module()
+
+ assert service._parse_points_from_path("/waveform") == 256
+ assert service._parse_points_from_path("/waveform?points=999999") == 1024
+ assert service._parse_points_from_path("/waveform?points=abc") == 256
+
+
def test_unknown_paths_return_404():
service = _load_service_module()
server = TCPServer(("127.0.0.1", 0), service._Handler)
diff --git a/apps/desktop-main/src/assets/python_sidecar/service.py b/apps/desktop-main/src/assets/python_sidecar/service.py
index 3dae77b..c3165c7 100644
--- a/apps/desktop-main/src/assets/python_sidecar/service.py
+++ b/apps/desktop-main/src/assets/python_sidecar/service.py
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
import argparse
import json
+import math
import os
import platform
import sys
+import time
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -26,14 +28,65 @@ def _build_health_payload():
return payload
+def _parse_points_from_path(path):
+ # Path shape: /waveform or /waveform?points=
+ _, _, query = path.partition("?")
+ for fragment in query.split("&"):
+ key, _, value = fragment.partition("=")
+ if key == "points":
+ try:
+ return max(64, min(1024, int(value)))
+ except ValueError:
+ return 256
+ return 256
+
+
+def _build_waveform_payload(points, phase_base):
+ samples = []
+ for index in range(points):
+ t = index / points
+ phase = phase_base + t * math.tau
+ value = (
+ math.sin(phase) * 0.55
+ + math.sin(phase * 2.7 + 1.3) * 0.28
+ + math.sin(phase * 5.1 + 0.4) * 0.17
+ )
+ samples.append(round(value, 6))
+
+ spectrum = []
+ for harmonic in range(1, 33):
+ real = sum(
+ sample * math.cos(math.tau * harmonic * i / points)
+ for i, sample in enumerate(samples)
+ )
+ imag = sum(
+ sample * math.sin(math.tau * harmonic * i / points)
+ for i, sample in enumerate(samples)
+ )
+ magnitude = math.sqrt(real * real + imag * imag) / points
+ spectrum.append(round(magnitude, 6))
+
+ return {
+ "samples": samples,
+ "spectrum": spectrum,
+ "sampleRate": 256,
+ "generatedAt": time.time(),
+ "message": "Synthetic waveform generated by python sidecar.",
+ }
+
+
class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
- if self.path != "/health":
+ if self.path == "/health":
+ payload = _build_health_payload()
+ elif self.path.split("?")[0] == "/waveform":
+ points = _parse_points_from_path(self.path)
+ payload = _build_waveform_payload(points, time.time() % math.tau)
+ else:
self.send_response(404)
self.end_headers()
return
- payload = _build_health_payload()
encoded = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
diff --git a/apps/desktop-main/src/ipc/python-handlers.ts b/apps/desktop-main/src/ipc/python-handlers.ts
index 8bce3d4..90b1262 100644
--- a/apps/desktop-main/src/ipc/python-handlers.ts
+++ b/apps/desktop-main/src/ipc/python-handlers.ts
@@ -6,6 +6,7 @@ import {
pythonInspectPdfRequestSchema,
pythonProbeRequestSchema,
pythonStopRequestSchema,
+ pythonWaveformRequestSchema,
} from '@electron-foundation/contracts';
import type { MainIpcContext } from './handler-context';
import { consumeSelectedFileToken } from './consume-selected-file-token';
@@ -133,6 +134,40 @@ export const registerPythonIpcHandlers = (
},
});
+ registerValidatedHandler({
+ ipcMain,
+ channel: IPC_CHANNELS.pythonWaveform,
+ schema: pythonWaveformRequestSchema,
+ context,
+ handler: async (_event, request) => {
+ const sidecar = context.getPythonSidecar();
+ if (!sidecar) {
+ return asFailure(
+ 'PYTHON/UNAVAILABLE',
+ 'Python sidecar is not configured.',
+ undefined,
+ false,
+ request.correlationId,
+ );
+ }
+
+ try {
+ const waveform = await sidecar.waveform(request.payload.points);
+ return asSuccess(waveform);
+ } catch (error) {
+ return asFailure(
+ 'PYTHON/WAVEFORM_FAILED',
+ 'Python sidecar failed to generate waveform.',
+ {
+ message: error instanceof Error ? error.message : String(error),
+ },
+ false,
+ request.correlationId,
+ );
+ }
+ },
+ });
+
registerValidatedHandler({
ipcMain,
channel: IPC_CHANNELS.pythonStop,
diff --git a/apps/desktop-main/src/python-sidecar.ts b/apps/desktop-main/src/python-sidecar.ts
index f547ca6..073122a 100644
--- a/apps/desktop-main/src/python-sidecar.ts
+++ b/apps/desktop-main/src/python-sidecar.ts
@@ -41,6 +41,14 @@ type PythonInspectPdfResult = {
message?: string;
};
+type PythonWaveformResult = {
+ samples: number[];
+ spectrum: number[];
+ sampleRate: number;
+ generatedAt: number;
+ message?: string;
+};
+
type CommandCandidate = {
command: string;
args: string[];
@@ -207,6 +215,21 @@ export class PythonSidecar {
return (await response.json()) as PythonInspectPdfResult;
}
+ async waveform(points: number): Promise {
+ const response = await fetch(
+ `${this.endpoint.replace('/health', '/waveform')}?points=${points}`,
+ { signal: AbortSignal.timeout(3_000) },
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `Python waveform endpoint returned ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return (await response.json()) as PythonWaveformResult;
+ }
+
dispose() {
void this.stop();
}
diff --git a/apps/desktop-preload/src/api/python-api.ts b/apps/desktop-preload/src/api/python-api.ts
index 94853c8..5a3b68f 100644
--- a/apps/desktop-preload/src/api/python-api.ts
+++ b/apps/desktop-preload/src/api/python-api.ts
@@ -8,6 +8,8 @@ import {
pythonProbeResponseSchema,
pythonStopRequestSchema,
pythonStopResponseSchema,
+ pythonWaveformRequestSchema,
+ pythonWaveformResponseSchema,
} from '@electron-foundation/contracts';
import { createCorrelationId, invokeIpc } from '../invoke-client';
@@ -46,6 +48,23 @@ export const createPythonApi = (): DesktopPythonApi => ({
);
},
+ async waveform(points: number) {
+ const correlationId = createCorrelationId();
+ const request = pythonWaveformRequestSchema.parse({
+ contractVersion: CONTRACT_VERSION,
+ correlationId,
+ payload: { points },
+ });
+
+ return invokeIpc(
+ IPC_CHANNELS.pythonWaveform,
+ request,
+ correlationId,
+ pythonWaveformResponseSchema,
+ 5_000,
+ );
+ },
+
async stop() {
const correlationId = createCorrelationId();
const request = pythonStopRequestSchema.parse({
diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css
index 701b864..2f814f8 100644
--- a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css
+++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.css
@@ -44,3 +44,16 @@ pre {
overflow: auto;
background: rgb(0 0 0 / 4%);
}
+
+.signal-frame {
+ height: 220px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #080b12;
+}
+
+.signal-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html
index 256b752..71160d4 100644
--- a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html
+++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.html
@@ -50,6 +50,30 @@ PDF Pipeline Demo
{{ inspectStatus() }}
+ Signal Pipeline (sidecar → IPC → canvas)
+
+
+ {{ streamStatus() }}
+
+
+
+
+