Skip to content
Open
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
25 changes: 20 additions & 5 deletions src/offscreenAudioGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,34 @@ export interface OffscreenAudioGraph {
recorderDestination: MediaStreamAudioDestinationNode;
analyser: AnalyserNode;
tabSource: MediaStreamAudioSourceNode;
downmixNode: GainNode;
}

/**
* Connects a media stream to the shared recorder and analyser nodes.
*
* The recorder path is routed through a stereo-to-mono downmix node so the
* MediaRecorder produces mono audio (sufficient for STT, saves ~50%
* bandwidth/storage). The analyser and playback destinations always receive
* the raw (stereo) signal so VAD/waveform are unaffected.
*
* Only the tab stream receives a playback destination. The microphone must
* never be routed to AudioContext.destination because that would create local
* monitoring and potentially audible feedback.
*/
function connectCaptureSource(
context: AudioContext,
stream: MediaStream,
recorderDestination: MediaStreamAudioDestinationNode,
downmixNode: GainNode,
analyser: AnalyserNode,
playbackDestination?: AudioDestinationNode,
): MediaStreamAudioSourceNode {
const source = context.createMediaStreamSource(stream);

source.connect(recorderDestination);
// Downmix path → recorder (stereo → mono)
source.connect(downmixNode);

// Ungated path → analyser (VAD & waveform need raw signal)
source.connect(analyser);

if (playbackDestination) {
Expand All @@ -47,13 +56,18 @@ export function createOffscreenAudioGraph(
): OffscreenAudioGraph {
const recorderDestination = context.createMediaStreamDestination();
const analyser = context.createAnalyser();
const downmixNode = context.createGain();

analyser.fftSize = OFFSCREEN_ANALYSER_FFT_SIZE;
downmixNode.channelCount = 1;
downmixNode.channelCountMode = "explicit";
downmixNode.channelInterpretation = "speakers";
downmixNode.connect(recorderDestination);

const tabSource = connectCaptureSource(
context,
tabStream,
recorderDestination,
downmixNode,
analyser,
context.destination,
);
Expand All @@ -62,6 +76,7 @@ export function createOffscreenAudioGraph(
recorderDestination,
analyser,
tabSource,
downmixNode,
};
}

Expand All @@ -74,7 +89,7 @@ export function createOffscreenAudioGraph(
export function connectMicrophoneToOffscreenAudioGraph(
context: AudioContext,
microphoneStream: MediaStream,
graph: Pick<OffscreenAudioGraph, "recorderDestination" | "analyser">,
graph: Pick<OffscreenAudioGraph, "analyser" | "downmixNode">,
): MediaStreamAudioSourceNode {
return connectCaptureSource(context, microphoneStream, graph.recorderDestination, graph.analyser);
return connectCaptureSource(context, microphoneStream, graph.downmixNode, graph.analyser);
}
105 changes: 105 additions & 0 deletions src/utils/domHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import test from "node:test";
import { escapeHtml, formatDuration, sanitizeTopicStatus } from "./domHelpers";

test("escapeHtml prevents XSS", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");

assert.equal(escapeHtml(""), "");
assert.equal(escapeHtml("Alice"), "Alice");
assert.equal(escapeHtml("O'Brien"), "O&#039;Brien");
assert.equal(escapeHtml('Say "hello"'), "Say &quot;hello&quot;");
assert.equal(escapeHtml("a < b"), "a &lt; b");
assert.equal(escapeHtml("a > b"), "a &gt; b");
assert.equal(escapeHtml("M&Ms"), "M&amp;Ms");

const xss = '<script>alert("xss")</script>';
assert.equal(escapeHtml(xss), "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");

const imgXss = '<img src=x onerror="alert(1)">';
assert.equal(escapeHtml(imgXss), "&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");

const safe = escapeHtml(xss);
// nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag
assert.ok(!safe.includes("<script>"));
assert.ok(!safe.includes("</script>"));
assert.ok(!safe.includes('"'));
});

test("escapeHtml handles edge cases", () => {
assert.equal(escapeHtml(" "), " ");
assert.equal(escapeHtml("a".repeat(1000)), "a".repeat(1000));

assert.equal(escapeHtml("Jack &amp; Jill"), "Jack &amp;amp; Jill");

assert.equal(escapeHtml("\n\t"), "\n\t");
});

test("escapeHtml in attribute context prevents injection", () => {
const name = 'John "Drop Table" Doe';
const escaped = escapeHtml(name);
const attr = `data-name="${escaped}"`;
assert.equal(attr, 'data-name="John &quot;Drop Table&quot; Doe"');

const singleQuote = "O'Brien";
const escaped2 = escapeHtml(singleQuote);
const attr2 = `data-author="${escaped2}"`;
assert.equal(attr2, 'data-author="O&#039;Brien"');
});

test("regression: malicious speaker name in transcript entry pattern", () => {
const malicious = '<script>alert("xss")</script>';
const speaker = escapeHtml(malicious);

const initials = speaker
.split(" ")
.filter(Boolean)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);

assert.equal(initials, "&");
assert.ok(!initials.includes("<"));
assert.ok(!initials.includes(">"));
assert.ok(!initials.includes('"'));

const html = `<div class="transcript-speaker">${speaker}</div>`;
assert.ok(!html.includes("<script>"));
assert.ok(html.includes("&lt;script&gt;"));
});

test("escapeHtml output is safe when embedded in innerHTML", () => {
const payloads = [
'<script>alert("xss")</script>',
'<img src=x onerror="alert(1)">',
'"><script>alert(1)</script>',
"javascript:alert(1)",
"<<script>script>alert(1)</script>",
];

for (const payload of payloads) {
const escaped = escapeHtml(payload);
// nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag
assert.ok(!escaped.includes("<script>"), `payload still contains <script>: ${payload}`);
assert.ok(!escaped.includes("</script>"), `payload still contains </script>: ${payload}`);
assert.ok(!escaped.includes('"'), `payload still contains double quote: ${payload}`);
}
});

test("formatDuration", () => {
assert.equal(formatDuration(0), "00:00:00");
assert.equal(formatDuration(90), "00:01:30");
assert.equal(formatDuration(3661), "01:01:01");
assert.equal(formatDuration(86399), "23:59:59");
assert.equal(formatDuration(360000), "100:00:00");
});

test("sanitizeTopicStatus", () => {
assert.equal(sanitizeTopicStatus("completed"), "completed");
assert.equal(sanitizeTopicStatus("unresolved"), "unresolved");
assert.equal(sanitizeTopicStatus("active"), "active");
assert.equal(sanitizeTopicStatus("pending"), "active");
assert.equal(sanitizeTopicStatus(""), "active");
});
43 changes: 32 additions & 11 deletions tests/offscreenAudioGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class MockAnalyserNode extends MockAudioNode {
fftSize = 2048;
}

class MockGainNode extends MockAudioNode {
gain = { value: 1 };
channelCount = 2;
channelCountMode: ChannelCountMode = "max";
channelInterpretation: ChannelInterpretation = "speakers";
}

class MockMediaStreamDestinationNode extends MockAudioNode {
readonly stream = createMockStream("recorder-output");
}
Expand All @@ -36,6 +43,7 @@ class MockAudioContext {
readonly analysers: MockAnalyserNode[] = [];
readonly recorderDestinations: MockMediaStreamDestinationNode[] = [];
readonly sources: MockSourceNode[] = [];
readonly gainNodes: MockGainNode[] = [];

createMediaStreamDestination(): MediaStreamAudioDestinationNode {
const destination = new MockMediaStreamDestinationNode();
Expand All @@ -51,6 +59,12 @@ class MockAudioContext {
return analyser as unknown as AnalyserNode;
}

createGain(): GainNode {
const gain = new MockGainNode();
this.gainNodes.push(gain);
return gain as unknown as GainNode;
}

createMediaStreamSource(stream: MediaStream): MediaStreamAudioSourceNode {
const source = new MockSourceNode(stream);
this.sources.push(source);
Expand Down Expand Up @@ -93,19 +107,26 @@ test("configures the analyser with the offscreen FFT size", () => {
assert.equal(context.analysers[0].fftSize, 1024);
});

test("routes tab audio to recorder, analyser, and playback output", () => {
test("routes tab audio to downmix node, analyser, and playback output", () => {
const context = new MockAudioContext();

createOffscreenAudioGraph(asAudioContext(context), createMockStream("tab"));

assert.deepEqual(context.sources[0].connections, [
context.recorderDestinations[0],
context.analysers[0],
context.destination,
]);
// Tab source → downmixNode (mono recorder path), analyser (raw VAD), and destination (playback)
assert.equal(context.sources[0].connections.length, 3);
assert.equal(context.sources[0].connections[0], context.gainNodes[0]);
assert.equal(context.sources[0].connections[1], context.analysers[0]);
assert.equal(context.sources[0].connections[2], context.destination);

// Downmix node → recorder destination with mono configuration
assert.equal(context.gainNodes[0].connections.length, 1);
assert.equal(context.gainNodes[0].connections[0], context.recorderDestinations[0]);
assert.equal(context.gainNodes[0].channelCount, 1);
assert.equal(context.gainNodes[0].channelCountMode, "explicit");
assert.equal(context.gainNodes[0].channelInterpretation, "speakers");
});

test("routes microphone audio to recorder and analyser", () => {
test("routes microphone audio through downmix node and to analyser", () => {
const context = new MockAudioContext();

const graph = createOffscreenAudioGraph(asAudioContext(context), createMockStream("tab"));
Expand All @@ -118,10 +139,10 @@ test("routes microphone audio to recorder and analyser", () => {

assert.equal(microphoneSource, context.sources[1]);

assert.deepEqual(context.sources[1].connections, [
context.recorderDestinations[0],
context.analysers[0],
]);
// Mic source → downmixNode (mono recorder path) and analyser (raw VAD)
assert.equal(context.sources[1].connections.length, 2);
assert.equal(context.sources[1].connections[0], context.gainNodes[0]);
assert.equal(context.sources[1].connections[1], context.analysers[0]);
});

test("does not route microphone audio to local playback", () => {
Expand Down