Skip to content
4 changes: 4 additions & 0 deletions bridge-browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ <h2>
<span id="showLogLabel">Show Floating Log</span>
<input type="checkbox" id="showLog" />
</label>
<label class="checkbox-row">
<span id="soundLabel">Notification Sound</span>
<input type="checkbox" id="logSound" />
</label>
<button
type="button"
id="sessionPresetToggle"
Expand Down
7 changes: 6 additions & 1 deletion bridge-browser/src/background/attention_sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { getErrorMessage } from './errors';
const OFFSCREEN_DOCUMENT_PATH = "offscreen.html";
const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND";

export type LogSoundType = "info" | "success" | "warn" | "error" | "action";

let creatingOffscreenDocument: Promise<void> | null = null;

export async function playAttentionSound(): Promise<{ success: boolean; error?: string }> {
export async function playAttentionSound(
logType?: LogSoundType
): Promise<{ success: boolean; error?: string }> {
try {
await ensureOffscreenDocument();
const response: unknown = await chrome.runtime.sendMessage({
type: PLAY_ATTENTION_SOUND,
logType,
});

if (isRecord(response) && response.success === false) {
Expand Down
53 changes: 53 additions & 0 deletions bridge-browser/src/background/messages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isMessageRequest, type MessageRequest } from '../types';
import { playAttentionSound, type LogSoundType } from './attention_sound';
import { bindSession, handleHandshake } from './connection';
import { isMessageRequest, type MessageRequest, type SessionDisconnectReason } from '../types';
import { playAttentionSound } from './attention_sound';
import { handleHandshake } from './connection';
Expand Down Expand Up @@ -46,6 +49,15 @@ function dispatchRuntimeMessage(
case "GET_STATUS":
handleGetStatus(request, sender, sendResponse);
return true;
case "SET_LOG_VISIBLE":
handleSetLogVisible(request, currentTabId, sendResponse);
return true;
case "SET_LOG_SOUND_ENABLED":
handleSetLogSoundEnabled(request, currentTabId, sendResponse);
return true;
case "PLAY_LOG_SOUND":
respondAsync(playLogSound(request), sendResponse);
return true;
case "REQUEST_USER_ATTENTION":
respondAsync(requestUserAttention(request, sender), sendResponse);
return true;
Expand Down Expand Up @@ -100,6 +112,19 @@ function handleGetStatus(
return;
}

respondAsync(
Promise.all([
getSession(targetTabId),
chrome.storage.sync.get(["logSoundEnabled"]) as Promise<Record<string, unknown>>,
]).then(([session, syncItems]) => ({
connected: Boolean(session),
port: session?.port,
showLog: session?.showLog ?? false,
soundEnabled: syncItems.logSoundEnabled === true,
workspaceId: session?.workspaceId ?? 'global',
})),
sendResponse
);
respondAsync(getStatusResponse(targetTabId), sendResponse);
}

Expand Down Expand Up @@ -189,12 +214,23 @@ function handleSetLogVisible(
);
}

function handleSetLogSoundEnabled(
function handleSetAutoSend(
request: MessageRequest,
currentTabId: number | null | undefined,
sendResponse: SendResponse
): void {
const targetTabId = request.tabId ?? currentTabId;
const soundEnabled = request.soundEnabled === true;

respondAsync(
chrome.storage.sync.set({ logSoundEnabled: soundEnabled }).then(() => {
if (targetTabId) {
void chrome.tabs.sendMessage(targetTabId, {
type: "SET_LOG_SOUND_ENABLED",
soundEnabled,
}).catch(ignoreRuntimeError);
}
if (!targetTabId) {
sendResponse({ success: false, error: "Missing Tab ID" });
return;
Expand All @@ -218,6 +254,23 @@ function handleSetAutoSend(
);
}

async function playLogSound(request: MessageRequest): Promise<{ success: boolean; error?: string }> {
if (!isLogSoundType(request.logType)) {
return { success: false, error: "Unsupported log sound type." };
}

return playAttentionSound(request.logType);
}

function isLogSoundType(value: unknown): value is LogSoundType {
return value === "info" ||
value === "success" ||
value === "warn" ||
value === "error" ||
value === "action";
}
Comment thread
JairHan marked this conversation as resolved.

function handleConnectExisting(
function handleSetAutoApproveTools(
request: MessageRequest,
currentTabId: number | null | undefined,
Expand Down
3 changes: 3 additions & 0 deletions bridge-browser/src/content/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ function handleRuntimeMessage(request: unknown, sendResponse: RuntimeSendRespons
Logger.toggle(show);
Logger.log("Logger Visible: " + show, "info");
}
if (request.type === "SET_LOG_SOUND_ENABLED") {
Logger.setSoundEnabled(request.soundEnabled === true);
if (request.type === "SET_AUTO_SEND") {
CONFIG.autoSend = request.autoSend !== false;
if (!CONFIG.autoSend) {
Expand Down Expand Up @@ -523,6 +525,7 @@ function startObserver() {
// 2. Check initial status
chrome.runtime.sendMessage({ type: "GET_STATUS" }, async (response: unknown) => {
if (isStatusResponse(response) && response.connected) {
Logger.setSoundEnabled(response.soundEnabled === true);
isClientConnected = true;
if (typeof response.workspaceId === "string") {
currentWorkspaceId = response.workspaceId;
Comment thread
JairHan marked this conversation as resolved.
Expand Down
22 changes: 22 additions & 0 deletions bridge-browser/src/modules/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const Logger = {
filterButtons: new Map<LoggerFilterType, HTMLButtonElement>(),
activeTypes: new Set<LoggerLogType>(["summary"]),
isMinimized: false,
soundEnabled: false,

init() {
if (this.el) {return;}
Expand Down Expand Up @@ -291,7 +292,24 @@ export const Logger = {
}
},

setSoundEnabled(enabled: boolean) {
this.soundEnabled = enabled;
},

playLogSound(type: LoggerLogType) {
if (!this.soundEnabled || !isStandardLogType(type)) {return;}

try {
chrome.runtime.sendMessage({ type: "PLAY_LOG_SOUND", logType: type }, () => {
void chrome.runtime.lastError;
});
} catch {
// Logger may be reused outside an extension context during local testing.
}
},

log(msg: string, type: LoggerLogType = "info") {
this.playLogSound(type);
if (!this.el || this.el.style.display === "none") {return;}
Comment thread
JairHan marked this conversation as resolved.
const line = document.createElement("div");
line.className = "line";
Expand Down Expand Up @@ -319,3 +337,7 @@ export const Logger = {
}
},
};

function isStandardLogType(type: LoggerLogType): type is typeof STANDARD_LOG_TYPES[number] {
return STANDARD_LOG_TYPES.includes(type as typeof STANDARD_LOG_TYPES[number]);
}
78 changes: 64 additions & 14 deletions bridge-browser/src/offscreen/audio.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND";

type LogSoundType = "info" | "success" | "warn" | "error" | "action";

type ToneStep = {
frequency: number;
duration: number;
gap?: number;
type?: OscillatorType;
};

const SOUND_PATTERNS: Record<LogSoundType, ToneStep[]> = {
info: [
{ frequency: 660, duration: 0.12 },
{ frequency: 880, duration: 0.16 },
],
success: [
{ frequency: 784, duration: 0.12 },
{ frequency: 1046.5, duration: 0.22 },
],
warn: [
{ frequency: 523.25, duration: 0.14, type: "triangle" },
{ frequency: 392, duration: 0.24, type: "triangle" },
],
error: [
{ frequency: 220, duration: 0.16, type: "square" },
{ frequency: 196, duration: 0.26, type: "square" },
],
action: [
{ frequency: 880, duration: 0.08 },
{ frequency: 987.77, duration: 0.08 },
{ frequency: 1174.66, duration: 0.16 },
],
};

let audioContext: AudioContext | null = null;

chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (!isRecord(request) || request.type !== PLAY_ATTENTION_SOUND) {
return false;
}

void playAttentionSound()
const logType = getLogSoundType(request.logType);
void playAttentionSound(logType)
.then(() => sendResponse({ success: true }))
.catch((error) => {
sendResponse({
Expand All @@ -19,25 +53,30 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
return true;
});

async function playAttentionSound(): Promise<void> {
async function playAttentionSound(logType: LogSoundType): Promise<void> {
const context = getAudioContext();
if (context.state === "suspended") {
await context.resume();
}

const pattern = SOUND_PATTERNS[logType];
const start = context.currentTime + 0.01;
const output = context.createGain();
const totalDuration = pattern.reduce((sum, tone) => sum + tone.duration + (tone.gap ?? 0.03), 0);
output.gain.setValueAtTime(0.0001, start);
output.gain.linearRampToValueAtTime(0.12, start + 0.02);
output.gain.exponentialRampToValueAtTime(0.0001, start + 0.48);
output.gain.linearRampToValueAtTime(0.1, start + 0.02);
output.gain.exponentialRampToValueAtTime(0.0001, start + totalDuration + 0.06);
output.connect(context.destination);

playTone(context, output, 880, start, 0.16);
playTone(context, output, 1174.66, start + 0.16, 0.24);
let cursor = start;
for (const tone of pattern) {
playTone(context, output, tone, cursor);
cursor += tone.duration + (tone.gap ?? 0.03);
}

window.setTimeout(() => {
output.disconnect();
}, 650);
}, Math.ceil((totalDuration + 0.2) * 1000));
}

function getAudioContext(): AudioContext {
Expand All @@ -61,24 +100,23 @@ function getWebkitAudioContext(): typeof AudioContext | undefined {
function playTone(
context: AudioContext,
output: AudioNode,
frequency: number,
start: number,
duration: number
tone: ToneStep,
start: number
): void {
const oscillator = context.createOscillator();
const gain = context.createGain();

oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.type = tone.type ?? "sine";
oscillator.frequency.setValueAtTime(tone.frequency, start);

gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(1, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
gain.gain.exponentialRampToValueAtTime(0.0001, start + tone.duration);

oscillator.connect(gain);
gain.connect(output);
oscillator.start(start);
oscillator.stop(start + duration + 0.03);
oscillator.stop(start + tone.duration + 0.03);
oscillator.addEventListener(
"ended",
() => {
Expand All @@ -89,6 +127,18 @@ function playTone(
);
}

function getLogSoundType(value: unknown): LogSoundType {
return isLogSoundType(value) ? value : "action";
}

function isLogSoundType(value: unknown): value is LogSoundType {
return value === "info" ||
value === "success" ||
value === "warn" ||
value === "error" ||
value === "action";
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Comment thread
JairHan marked this conversation as resolved.
Loading