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() }} +
+
+ +
+

Running: {{ running() }}

Started In Probe: {{ started() }}

diff --git a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts index df5a9c3..4b67568 100644 --- a/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts +++ b/apps/renderer/src/app/features/python-sidecar-lab/python-sidecar-lab-page.ts @@ -1,4 +1,12 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button'; @@ -13,6 +21,11 @@ import { getDesktopApi } from '@electron-foundation/desktop-api'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PythonSidecarLabPage { + private readonly destroyRef = inject(DestroyRef); + + readonly signalCanvas = + viewChild.required>('signalCanvas'); + readonly desktopAvailable = signal(!!getDesktopApi()); readonly status = signal('Idle.'); readonly running = signal(false); @@ -31,6 +44,14 @@ export class PythonSidecarLabPage { readonly inspectedFileSize = signal('N/A'); readonly inspectedHeaderHex = signal('N/A'); readonly inspectedAccepted = signal('N/A'); + readonly streaming = signal(false); + readonly streamStatus = signal('Stream idle.'); + + private streamTimer: ReturnType | null = null; + + constructor() { + this.destroyRef.onDestroy(() => this.stopStreaming()); + } async probeSidecar() { const desktop = getDesktopApi(); @@ -147,4 +168,111 @@ export class PythonSidecarLabPage { this.pid.set('N/A'); this.status.set(result.data.message ?? 'Python sidecar stopped.'); } + + toggleStreaming() { + if (this.streaming()) { + this.stopStreaming(); + return; + } + + const ctx = this.signalCanvas().nativeElement.getContext('2d'); + if (!ctx) { + this.streamStatus.set('Canvas unavailable.'); + return; + } + + this.streaming.set(true); + this.streamStatus.set('Streaming waveform from Python sidecar…'); + void this.pollWaveform(); + this.streamTimer = setInterval(() => void this.pollWaveform(), 150); + } + + stopStreaming() { + if (this.streamTimer !== null) { + clearInterval(this.streamTimer); + this.streamTimer = null; + } + if (this.streaming()) { + this.streaming.set(false); + this.streamStatus.set('Stream stopped.'); + } + } + + private async pollWaveform() { + const desktop = getDesktopApi(); + if (!desktop) { + this.stopStreaming(); + return; + } + + const startedAt = performance.now(); + const result = await desktop.python.waveform(256); + if (!result.ok) { + this.streamStatus.set(`Stream error: ${result.error.message}`); + this.stopStreaming(); + return; + } + + const latencyMs = Math.round(performance.now() - startedAt); + this.drawSignal(result.data.samples, result.data.spectrum); + this.streamStatus.set( + `${result.data.samples.length} samples · ${result.data.spectrum.length} bins · ${latencyMs} ms round-trip`, + ); + } + + private drawSignal(samples: number[], spectrum: number[]) { + const canvas = this.signalCanvas().nativeElement; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.floor(canvas.clientWidth * dpr)); + const height = Math.max(1, Math.floor(canvas.clientHeight * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.fillStyle = '#080b12'; + ctx.fillRect(0, 0, width, height); + + const traceHeight = height * 0.58; + const baseline = traceHeight / 2; + + ctx.strokeStyle = '#1d2635'; + ctx.beginPath(); + ctx.moveTo(0, baseline); + ctx.lineTo(width, baseline); + ctx.stroke(); + + ctx.strokeStyle = '#38bdf8'; + ctx.lineWidth = Math.max(1.25, 1.5 * dpr); + ctx.beginPath(); + samples.forEach((sample, index) => { + const x = (index / (samples.length - 1 || 1)) * width; + const y = baseline - sample * baseline * 0.9; + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); + + const spectrumTop = traceHeight + height * 0.06; + const spectrumHeight = height - spectrumTop - 4; + const binWidth = width / spectrum.length; + ctx.fillStyle = '#2dd4bf'; + spectrum.forEach((magnitude, index) => { + const barHeight = Math.min(1, magnitude * 8) * spectrumHeight; + ctx.fillRect( + index * binWidth + binWidth * 0.15, + height - 4 - barHeight, + binWidth * 0.7, + barHeight, + ); + }); + } } diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index b73dd0e..6c9476d 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -122,6 +122,15 @@ export interface DesktopPythonApi { message?: string; }> >; + waveform: (points: number) => Promise< + DesktopResult<{ + samples: number[]; + spectrum: number[]; + sampleRate: number; + generatedAt: number; + message?: string; + }> + >; stop: () => Promise< DesktopResult<{ stopped: boolean; diff --git a/libs/shared/contracts/src/lib/channels.ts b/libs/shared/contracts/src/lib/channels.ts index 2969151..686c837 100644 --- a/libs/shared/contracts/src/lib/channels.ts +++ b/libs/shared/contracts/src/lib/channels.ts @@ -19,6 +19,7 @@ export const IPC_CHANNELS = { pythonProbe: 'python:probe', pythonInspectPdf: 'python:inspect-pdf', pythonStop: 'python:stop', + pythonWaveform: 'python:waveform', settingsGetRuntimeConfig: 'settings:get-runtime-config', settingsSaveFeatureConfig: 'settings:save-feature-config', settingsResetFeatureConfig: 'settings:reset-feature-config', diff --git a/libs/shared/contracts/src/lib/python.contract.ts b/libs/shared/contracts/src/lib/python.contract.ts index 66a8014..ca4a1f2 100644 --- a/libs/shared/contracts/src/lib/python.contract.ts +++ b/libs/shared/contracts/src/lib/python.contract.ts @@ -10,6 +10,13 @@ export const pythonInspectPdfRequestSchema = requestEnvelope( .strict(), ); export const pythonStopRequestSchema = requestEnvelope(emptyPayloadSchema); +export const pythonWaveformRequestSchema = requestEnvelope( + z + .object({ + points: z.number().int().min(64).max(1024), + }) + .strict(), +); export const pythonProbeHealthSchema = z.object({ status: z.string(), @@ -50,6 +57,19 @@ export const pythonInspectPdfResponseSchema = z.object({ message: z.string().optional(), }); +export const pythonWaveformResponseSchema = z.object({ + samples: z.array(z.number()), + spectrum: z.array(z.number().nonnegative()), + sampleRate: z.number().positive(), + generatedAt: z.number(), + message: z.string().optional(), +}); + +export type PythonWaveformRequest = z.infer; +export type PythonWaveformResponse = z.infer< + typeof pythonWaveformResponseSchema +>; + export type PythonProbeRequest = z.infer; export type PythonProbeResponse = z.infer; export type PythonInspectPdfRequest = z.infer< From 417635ecc00fce5ea768301fa6d4ab1c103c4aed Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Mon, 24 Aug 2026 09:46:59 +0100 Subject: [PATCH 2/2] chore(renderer): drop unused rAF handle in starfield worker --- apps/renderer/src/app/features/canvas-lab/render.worker.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/renderer/src/app/features/canvas-lab/render.worker.ts b/apps/renderer/src/app/features/canvas-lab/render.worker.ts index 6ede98c..28c2e8f 100644 --- a/apps/renderer/src/app/features/canvas-lab/render.worker.ts +++ b/apps/renderer/src/app/features/canvas-lab/render.worker.ts @@ -41,7 +41,6 @@ let speed = 10; let accent = '#38bdf8'; let accentRgb: [number, number, number] = [0.22, 0.74, 0.97]; let running = true; -let rafHandle = 0; let lastTimestamp = 0; function hexToRgb(hex: string): [number, number, number] { @@ -67,7 +66,7 @@ function seedStars(width: number, height: number): void { function drawStarfield(timestamp: number): void { if (!ctx || !canvas || !running) { - rafHandle = requestAnimationFrame(drawStarfield); + requestAnimationFrame(drawStarfield); return; } @@ -112,7 +111,7 @@ function drawStarfield(timestamp: number): void { ctx.stroke(); } - rafHandle = requestAnimationFrame(drawStarfield); + requestAnimationFrame(drawStarfield); } self.onmessage = (event: MessageEvent) => { @@ -133,7 +132,7 @@ self.onmessage = (event: MessageEvent) => { running = message.running; seedStars(canvas.width, canvas.height); lastTimestamp = performance.now(); - rafHandle = requestAnimationFrame(drawStarfield); + requestAnimationFrame(drawStarfield); return; }