Compare commits
2 Commits
f9a4b4a92d
...
a5a794c590
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5a794c590 | ||
|
|
0e056732bc |
149
docs/superpowers/plans/2026-05-13-one-port-websocket.md
Normal file
149
docs/superpowers/plans/2026-05-13-one-port-websocket.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# One-Port WebSocket Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Serve Express HTTP endpoints, static frontend, and WebSocket traffic on one `WEBSERVER_PORT` using WebSocket path `/ws`.
|
||||||
|
|
||||||
|
**Architecture:** `src/webserver.ts` should create one `http.Server` from the Express app, attach `WebSocketServer` to that same server with `path: "/ws"`, and remove `port + 1`. `public/index.html` should connect to `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws` so dev, production, and reverse proxy setups use the same host and port.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Express, Node HTTP server, `ws`, Bun scripts, Biome, TypeScript compiler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/webserver.ts`: change WebSocket server construction and logs from separate port to shared HTTP server path `/ws`.
|
||||||
|
- Modify `public/index.html`: change browser WebSocket URL from hardcoded `:3001` to same-origin `/ws`.
|
||||||
|
- No new files required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Attach WebSocket to Existing HTTP Server
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/webserver.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update WebSocket server creation**
|
||||||
|
|
||||||
|
Replace this code in `src/webserver.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const wsPort = port + 1;
|
||||||
|
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
|
||||||
|
wsLogger.info({ wsPort }, "WebSocket server listening");
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const wsPath = "/ws";
|
||||||
|
const wss = new WebSocketServer({ server, path: wsPath });
|
||||||
|
wsLogger.info({ port, wsPath }, "WebSocket server listening");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update connection log**
|
||||||
|
|
||||||
|
Replace this code in `src/webserver.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
wsLogger.info({ wsPort }, "New WebSocket connection");
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
wsLogger.info({ port, wsPath }, "New WebSocket connection");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: command exits `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update Browser WebSocket URL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace hardcoded WebSocket port**
|
||||||
|
|
||||||
|
Replace this code in `public/index.html`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
socket = new WebSocket(`ws://${window.location.hostname}:3001`);
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
socket = new WebSocket(`${wsProtocol}//${window.location.host}/ws`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint and build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint && bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both commands exit `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Verify One-Port Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `src/webserver.ts`
|
||||||
|
- Verify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start dev server**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected logs include Express web interface on configured port and WebSocket server listening with `{ port: 3000, wsPath: "/ws" }`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Browser smoke test**
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: page loads and browser WebSocket connects to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://localhost:3000/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Endpoint smoke test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
curl http://localhost:3000/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `/health` returns JSON and `/metrics` returns Prometheus text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: Covers one-port HTTP/WebSocket server, `/ws` path, frontend URL update, and verification.
|
||||||
|
- Placeholder scan: No TBD/TODO placeholders.
|
||||||
|
- Type consistency: Uses `wsPath` in both server creation and logs; frontend connects to `/ws`.
|
||||||
163
docs/superpowers/plans/2026-05-13-web-mic-noise-suppression.md
Normal file
163
docs/superpowers/plans/2026-05-13-web-mic-noise-suppression.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Web Mic Noise Suppression Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Reduce background noise from the browser microphone before audio is sent to Discord.
|
||||||
|
|
||||||
|
**Architecture:** Use native browser audio constraints for echo cancellation, noise suppression, and auto gain control at `getUserMedia` capture time. Add a lightweight RMS noise gate inside the existing `onaudioprocess` transmit loop so quiet background noise becomes silence before PCM is sent over WebSocket.
|
||||||
|
|
||||||
|
**Tech Stack:** Browser MediaDevices API, Web Audio API, plain JavaScript in `public/index.html`, existing Bun/TypeScript verification scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `public/index.html`: update mic capture constraints and add local RMS noise gate constants/helpers inside the existing script.
|
||||||
|
- No new dependencies.
|
||||||
|
- No server changes required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Enable Browser-Level Audio Processing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update microphone constraints**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exits `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add Lightweight RMS Noise Gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add threshold constants near audio constants**
|
||||||
|
|
||||||
|
Add after:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const CHANNELS = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
This code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const NOISE_GATE_THRESHOLD = 0.01;
|
||||||
|
const NOISE_GATE_HOLD_FRAMES = 3;
|
||||||
|
let noiseGateHold = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add RMS helper function before `startStreaming()`**
|
||||||
|
|
||||||
|
Add before:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function startStreaming() {
|
||||||
|
```
|
||||||
|
|
||||||
|
This function:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function calculateRms(samples) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
sum += samples[i] * samples[i];
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum / samples.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply gate before PCM conversion**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const inputData = e.inputBuffer.getChannelData(0);
|
||||||
|
const pcmData = new Int16Array(inputData.length);
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||||
|
}
|
||||||
|
socket.send(pcmData.buffer);
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const inputData = e.inputBuffer.getChannelData(0);
|
||||||
|
const rms = calculateRms(inputData);
|
||||||
|
if (rms >= NOISE_GATE_THRESHOLD) {
|
||||||
|
noiseGateHold = NOISE_GATE_HOLD_FRAMES;
|
||||||
|
} else if (noiseGateHold > 0) {
|
||||||
|
noiseGateHold--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pcmData = new Int16Array(inputData.length);
|
||||||
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
|
const sample = noiseGateHold > 0 ? inputData[i] : 0;
|
||||||
|
pcmData[i] = Math.max(-1, Math.min(1, sample)) * 32767;
|
||||||
|
}
|
||||||
|
socket.send(pcmData.buffer);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Reset gate on stop**
|
||||||
|
|
||||||
|
Add inside `stopStreaming()` after:
|
||||||
|
|
||||||
|
```js
|
||||||
|
isStreaming = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
This line:
|
||||||
|
|
||||||
|
```js
|
||||||
|
noiseGateHold = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test && bun run typecheck && bun run lint && bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands exit `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: Browser native noise suppression and JS noise gate are both covered.
|
||||||
|
- Placeholder scan: No placeholders or TODOs.
|
||||||
|
- Type consistency: Uses existing `SAMPLE_RATE`, `CHANNELS`, and `onaudioprocess` pipeline.
|
||||||
@@ -175,10 +175,14 @@
|
|||||||
// Optimized settings (1/4 bandwidth compared to 48k Stereo)
|
// Optimized settings (1/4 bandwidth compared to 48k Stereo)
|
||||||
const SAMPLE_RATE = 24000;
|
const SAMPLE_RATE = 24000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
|
const NOISE_GATE_THRESHOLD = 0.01;
|
||||||
|
const NOISE_GATE_HOLD_FRAMES = 3;
|
||||||
|
let noiseGateHold = 0;
|
||||||
|
|
||||||
function initWebSocket() {
|
function initWebSocket() {
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
|
||||||
socket = new WebSocket(`ws://${window.location.hostname}:3001`);
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
socket = new WebSocket(`${wsProtocol}//${window.location.host}/ws`);
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
@@ -257,9 +261,25 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function calculateRms(samples) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
sum += samples[i] * samples[i];
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum / samples.length);
|
||||||
|
}
|
||||||
|
|
||||||
async function startStreaming() {
|
async function startStreaming() {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
},
|
||||||
|
});
|
||||||
isStreaming = true;
|
isStreaming = true;
|
||||||
toggleBtn.innerText = 'Stop Transmitting';
|
toggleBtn.innerText = 'Stop Transmitting';
|
||||||
toggleBtn.className = 'btn btn-danger';
|
toggleBtn.className = 'btn btn-danger';
|
||||||
@@ -280,9 +300,17 @@
|
|||||||
if (!isStreaming || socket.readyState !== WebSocket.OPEN) return;
|
if (!isStreaming || socket.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
const inputData = e.inputBuffer.getChannelData(0);
|
const inputData = e.inputBuffer.getChannelData(0);
|
||||||
|
const rms = calculateRms(inputData);
|
||||||
|
if (rms >= NOISE_GATE_THRESHOLD) {
|
||||||
|
noiseGateHold = NOISE_GATE_HOLD_FRAMES;
|
||||||
|
} else if (noiseGateHold > 0) {
|
||||||
|
noiseGateHold--;
|
||||||
|
}
|
||||||
|
|
||||||
const pcmData = new Int16Array(inputData.length);
|
const pcmData = new Int16Array(inputData.length);
|
||||||
for (let i = 0; i < inputData.length; i++) {
|
for (let i = 0; i < inputData.length; i++) {
|
||||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
const sample = noiseGateHold > 0 ? inputData[i] : 0;
|
||||||
|
pcmData[i] = Math.max(-1, Math.min(1, sample)) * 32767;
|
||||||
}
|
}
|
||||||
socket.send(pcmData.buffer);
|
socket.send(pcmData.buffer);
|
||||||
|
|
||||||
@@ -299,6 +327,7 @@
|
|||||||
|
|
||||||
function stopStreaming() {
|
function stopStreaming() {
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
|
noiseGateHold = 0;
|
||||||
if (processor) processor.disconnect();
|
if (processor) processor.disconnect();
|
||||||
if (audioContextTransmit) audioContextTransmit.close();
|
if (audioContextTransmit) audioContextTransmit.close();
|
||||||
toggleBtn.innerText = 'Start Transmitting';
|
toggleBtn.innerText = 'Start Transmitting';
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ const configSchema = z.object({
|
|||||||
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
|
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
|
||||||
AVATAR_SIZE: z.coerce.number().positive().default(64),
|
AVATAR_SIZE: z.coerce.number().positive().default(64),
|
||||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||||
NODE_ENV: z.enum(["development", "production"]).default("development"),
|
NODE_ENV: z
|
||||||
|
.enum(["development", "production", "test"])
|
||||||
|
.default("development"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ export function startWebserver(port: number = 3000) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
const wsPort = port + 1;
|
const wsPath = "/ws";
|
||||||
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
|
const wss = new WebSocketServer({ server, path: wsPath });
|
||||||
wsLogger.info({ wsPort }, "WebSocket server listening");
|
wsLogger.info({ port, wsPath }, "WebSocket server listening");
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
@@ -199,7 +199,7 @@ export function startWebserver(port: number = 3000) {
|
|||||||
}, 20);
|
}, 20);
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws) => {
|
||||||
wsLogger.info({ wsPort }, "New WebSocket connection");
|
wsLogger.info({ port, wsPath }, "New WebSocket connection");
|
||||||
wsClients.add(ws);
|
wsClients.add(ws);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import process from "node:process";
|
||||||
import { parseBoolean, parsePositiveNumber } from "../src/config";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("config parsers", () => {
|
const originalEnv = process.env;
|
||||||
it("parses boolean values", () => {
|
|
||||||
expect(parseBoolean("true", false)).toBe(true);
|
afterEach(() => {
|
||||||
expect(parseBoolean("false", true)).toBe(false);
|
process.env = originalEnv;
|
||||||
expect(parseBoolean(undefined, true)).toBe(true);
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses positive numbers", () => {
|
describe("loadConfig", () => {
|
||||||
expect(parsePositiveNumber("5000", 0)).toBe(5000);
|
it("loads required values and coerces optional values", async () => {
|
||||||
expect(parsePositiveNumber("0", 123)).toBe(123);
|
process.env = {
|
||||||
expect(parsePositiveNumber("bad", 123)).toBe(123);
|
...originalEnv,
|
||||||
expect(parsePositiveNumber(undefined, 123)).toBe(123);
|
DISCORD_TOKEN: "token",
|
||||||
|
VOICE_CHANNEL_ID: "voice-channel",
|
||||||
|
GUILD_ID: "guild",
|
||||||
|
VERBOSE: "true",
|
||||||
|
WEBSERVER_PORT: "4000",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../src/config");
|
||||||
|
const config = loadConfig(process.env);
|
||||||
|
|
||||||
|
expect(config.DISCORD_TOKEN).toBe("token");
|
||||||
|
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||||
|
expect(config.GUILD_ID).toBe("guild");
|
||||||
|
expect(config.VERBOSE).toBe(true);
|
||||||
|
expect(config.WEBSERVER_PORT).toBe(4000);
|
||||||
|
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
||||||
|
expect(config.NODE_ENV).toBe("test");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user