Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/desktop-main/python-sidecar/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 55 additions & 2 deletions apps/desktop-main/src/assets/python_sidecar/service.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -26,14 +28,65 @@ def _build_health_payload():
return payload


def _parse_points_from_path(path):
# Path shape: /waveform or /waveform?points=<int>
_, _, 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")
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop-main/src/ipc/python-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop-main/src/python-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -207,6 +215,21 @@ export class PythonSidecar {
return (await response.json()) as PythonInspectPdfResult;
}

async waveform(points: number): Promise<PythonWaveformResult> {
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();
}
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop-preload/src/api/python-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
pythonProbeResponseSchema,
pythonStopRequestSchema,
pythonStopResponseSchema,
pythonWaveformRequestSchema,
pythonWaveformResponseSchema,
} from '@electron-foundation/contracts';
import { createCorrelationId, invokeIpc } from '../invoke-client';

Expand Down Expand Up @@ -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({
Expand Down
7 changes: 3 additions & 4 deletions apps/renderer/src/app/features/canvas-lab/render.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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;
}

Expand Down Expand Up @@ -112,7 +111,7 @@ function drawStarfield(timestamp: number): void {
ctx.stroke();
}

rafHandle = requestAnimationFrame(drawStarfield);
requestAnimationFrame(drawStarfield);
}

self.onmessage = (event: MessageEvent<WorkerMessage>) => {
Expand All @@ -133,7 +132,7 @@ self.onmessage = (event: MessageEvent<WorkerMessage>) => {
running = message.running;
seedStars(canvas.width, canvas.height);
lastTimestamp = performance.now();
rafHandle = requestAnimationFrame(drawStarfield);
requestAnimationFrame(drawStarfield);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ <h3>PDF Pipeline Demo</h3>
</p>
<p class="status">{{ inspectStatus() }}</p>

<h3>Signal Pipeline (sidecar → IPC → canvas)</h3>
<div class="actions">
<button
mat-flat-button
type="button"
[color]="streaming() ? 'warn' : 'primary'"
[disabled]="!desktopAvailable()"
(click)="toggleStreaming()"
>
<mat-icon aria-hidden="true"
>{{ streaming() ? "stop_circle" : "play_circle" }}</mat-icon
>
{{ streaming() ? "Stop Stream" : "Start Stream" }}
</button>
<span class="status">{{ streamStatus() }}</span>
</div>
<div class="signal-frame">
<canvas
#signalCanvas
class="signal-canvas"
aria-label="Waveform and spectrum streamed from the Python sidecar"
></canvas>
</div>

<div class="meta-grid">
<p><strong>Running:</strong> {{ running() }}</p>
<p><strong>Started In Probe:</strong> {{ started() }}</p>
Expand Down
Loading
Loading