Audio Processing
@@ -1533,6 +1688,89 @@ export function SecurityTab() {
* the test is live tears down and re-creates the graph with the
* new constraints.
*/
+/**
+ * Voice input mode — radio group for Voice Activity (default) vs
+ * Push to Talk, plus a keybind display + release-delay slider when
+ * PTT is selected. The keybind itself is rebound from the Keybinds
+ * tab; this is just a convenient inline pointer + shortcut preview.
+ */
+function VoiceInputModeSection({
+ settings,
+ update,
+}: {
+ settings: VoiceSettings;
+ update: (key: K, value: VoiceSettings[K]) => void;
+}) {
+ const keybinds = useKeybinds();
+ const pttCombo = keybinds.getCombo('voice.pushToTalk');
+ return (
+
+
Input Mode
+
+ Voice Activity transmits whenever you speak. Push to Talk only
+ transmits while you hold the bound key.
+
+
+
+
+
+
+ {settings.inputMode === 'push-to-talk' && (
+
+
+ Release Delay
+
+ {settings.pushToTalkReleaseDelayMs}ms
+
+
+
+ update('pushToTalkReleaseDelayMs', Number(e.target.value))
+ }
+ className={styles.voiceSlider}
+ />
+
+ How long to keep transmitting after you release the key —
+ avoids clipping the end of a word.
+
+
+ )}
+
+ );
+}
+
function MicTest({ settings }: { settings: VoiceSettings }) {
const [testing, setTesting] = useState(false);
const [level, setLevel] = useState(0);
diff --git a/packages/shared/src/contexts/KeybindContext.tsx b/packages/shared/src/contexts/KeybindContext.tsx
index c924209..9c75090 100644
--- a/packages/shared/src/contexts/KeybindContext.tsx
+++ b/packages/shared/src/contexts/KeybindContext.tsx
@@ -29,6 +29,13 @@ export interface KeybindAction {
description: string;
category: KeybindCategory;
defaultCombo: string;
+ /** Press-and-hold actions (push-to-talk, walkie-talkie style).
+ * Instead of a single `brycord:keybind:` event on keydown,
+ * the dispatcher fires `brycord:keybind::down` on the first
+ * keydown (no `e.repeat`) and `brycord:keybind::up` on
+ * keyup. These events do NOT preventDefault, so binding PTT to
+ * a letter doesn't break typing in text fields. */
+ pressAndHold?: boolean;
}
const DEFAULT_ACTIONS: KeybindAction[] = [
@@ -53,6 +60,15 @@ const DEFAULT_ACTIONS: KeybindAction[] = [
category: 'voice',
defaultCombo: '',
},
+ {
+ id: 'voice.pushToTalk',
+ label: 'Push to Talk',
+ description:
+ 'Hold to transmit your mic while the voice input mode is set to Push to Talk.',
+ category: 'voice',
+ defaultCombo: '',
+ pressAndHold: true,
+ },
{
id: 'navigation.goToDMs',
label: 'Go to Direct Messages',
@@ -242,43 +258,109 @@ export function KeybindProvider({ children }: { children: ReactNode }) {
// wins against components that use keydown for their own shortcuts
// — rebinding in settings disables the default behaviour cleanly.
useEffect(() => {
+ // Track currently-held press-and-hold actions so keydown repeats
+ // (browser auto-repeat while the key stays pressed) only fire
+ // a single `:down` event per physical press, and so we can emit
+ // a matching `:up` when the key is released.
+ const heldPressAndHold = new Set();
+
+ const isInEditableField = (target: EventTarget | null): boolean => {
+ const el = target as HTMLElement | null;
+ if (!el) return false;
+ const tag = el.tagName;
+ return (
+ tag === 'INPUT' ||
+ tag === 'TEXTAREA' ||
+ el.isContentEditable
+ );
+ };
+
const onKeyDown = (e: KeyboardEvent) => {
- // Never intercept keys typed into inputs / contenteditable —
- // a bare `Escape` would otherwise cancel active composition.
- const target = e.target as HTMLElement | null;
- if (target) {
- const tag = target.tagName;
- if (
- tag === 'INPUT' ||
- tag === 'TEXTAREA' ||
- target.isContentEditable
- ) {
- // Allow Ctrl- / Ctrl+Shift- combinations through —
- // those are deliberate shortcuts, never accidental
- // typing. Plain keys still wait for focus to leave.
- if (!(e.ctrlKey || e.metaKey)) {
- return;
- }
- }
- }
const combo = eventToCombo(e);
if (!combo) return;
+ const editable = isInEditableField(e.target);
+
for (const action of DEFAULT_ACTIONS) {
- if ((combos[action.id] ?? '') === combo) {
- e.preventDefault();
- e.stopPropagation();
+ if ((combos[action.id] ?? '') !== combo) continue;
+
+ if (action.pressAndHold) {
+ // Fire `:down` once per physical press. Deliberately
+ // DO NOT preventDefault — press-and-hold bindings
+ // coexist with typing so binding PTT to a letter
+ // doesn't swallow that letter in an input.
+ if (e.repeat) return;
+ if (heldPressAndHold.has(action.id)) return;
+ heldPressAndHold.add(action.id);
window.dispatchEvent(
- new CustomEvent(`brycord:keybind:${action.id}`),
+ new CustomEvent(`brycord:keybind:${action.id}:down`),
);
return;
}
+
+ // Non-hold (one-shot) actions: original behaviour —
+ // swallow the key and fire the action, but only when
+ // the target is not an editable field (unless the user
+ // used a Ctrl/Meta shortcut, which is always deliberate).
+ if (editable && !(e.ctrlKey || e.metaKey)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ window.dispatchEvent(
+ new CustomEvent(`brycord:keybind:${action.id}`),
+ );
+ return;
}
};
+
+ const onKeyUp = (e: KeyboardEvent) => {
+ // Fire `:up` for any currently-held press-and-hold action
+ // whose combo key was just released. We match on the single
+ // key (`e.key`) rather than a full combo because the combo
+ // includes modifiers that may be released in any order.
+ if (heldPressAndHold.size === 0) return;
+ const released = e.key.length === 1 ? e.key.toUpperCase() : e.key;
+ for (const action of DEFAULT_ACTIONS) {
+ if (!action.pressAndHold) continue;
+ if (!heldPressAndHold.has(action.id)) continue;
+ const combo = combos[action.id] ?? '';
+ if (!combo) continue;
+ // `combo` is like "Ctrl+Shift+V" — the final segment is
+ // the main key. A release of any of the component keys
+ // counts as "stop holding".
+ const parts = combo.split('+');
+ if (parts.includes(released) || released === 'Control' ||
+ released === 'Shift' || released === 'Alt' ||
+ released === 'Meta') {
+ heldPressAndHold.delete(action.id);
+ window.dispatchEvent(
+ new CustomEvent(`brycord:keybind:${action.id}:up`),
+ );
+ }
+ }
+ };
+
+ // Safety net — if focus leaves the window while a PTT key is
+ // held, browsers usually don't fire keyup. Release everything
+ // so the mic doesn't stay hot forever.
+ const onBlur = () => {
+ for (const id of heldPressAndHold) {
+ window.dispatchEvent(
+ new CustomEvent(`brycord:keybind:${id}:up`),
+ );
+ }
+ heldPressAndHold.clear();
+ };
+
window.addEventListener('keydown', onKeyDown, { capture: true });
+ window.addEventListener('keyup', onKeyUp, { capture: true });
+ window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown, {
capture: true,
} as EventListenerOptions);
+ window.removeEventListener('keyup', onKeyUp, {
+ capture: true,
+ } as EventListenerOptions);
+ window.removeEventListener('blur', onBlur);
};
}, [combos]);
diff --git a/packages/shared/src/contexts/VoiceContext.jsx b/packages/shared/src/contexts/VoiceContext.jsx
index 52f1fd0..73eb751 100644
--- a/packages/shared/src/contexts/VoiceContext.jsx
+++ b/packages/shared/src/contexts/VoiceContext.jsx
@@ -92,6 +92,84 @@ export const VoiceProvider = ({ children }) => {
const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({});
+ // Voice-input mode state. "voice-activity" is the default and the
+ // LiveKit track stays enabled whenever the user isn't muted. In
+ // "push-to-talk" we flip the mic off until the bound key is held.
+ // Settings live in localStorage (see UserSettingsModal) and are
+ // broadcast via `brycord:voice-settings-changed` on change.
+ const [inputMode, setInputMode] = useState('voice-activity');
+ const [pttReleaseDelayMs, setPttReleaseDelayMs] = useState(200);
+ const [isPttActive, setIsPttActive] = useState(false);
+ const pttReleaseTimerRef = useRef(null);
+
+ useEffect(() => {
+ const readSettings = () => {
+ try {
+ const raw = localStorage.getItem('voiceSettings');
+ if (!raw) return;
+ const parsed = JSON.parse(raw);
+ if (parsed?.inputMode === 'push-to-talk' || parsed?.inputMode === 'voice-activity') {
+ setInputMode(parsed.inputMode);
+ }
+ if (typeof parsed?.pushToTalkReleaseDelayMs === 'number') {
+ setPttReleaseDelayMs(parsed.pushToTalkReleaseDelayMs);
+ }
+ } catch {
+ /* ignore malformed blob */
+ }
+ };
+ readSettings();
+ const onChange = () => readSettings();
+ window.addEventListener('brycord:voice-settings-changed', onChange);
+ return () => window.removeEventListener('brycord:voice-settings-changed', onChange);
+ }, []);
+
+ // Subscribe to the `voice.pushToTalk` keybind's down/up events while
+ // PTT mode is selected. On release, honor the configured delay
+ // before flipping the mic off so the last syllable isn't clipped.
+ useEffect(() => {
+ if (inputMode !== 'push-to-talk') {
+ // Flipping back to voice activity resets any pending hold
+ // so the next keydown starts fresh.
+ setIsPttActive(false);
+ if (pttReleaseTimerRef.current) {
+ clearTimeout(pttReleaseTimerRef.current);
+ pttReleaseTimerRef.current = null;
+ }
+ return;
+ }
+ const onDown = () => {
+ if (pttReleaseTimerRef.current) {
+ clearTimeout(pttReleaseTimerRef.current);
+ pttReleaseTimerRef.current = null;
+ }
+ setIsPttActive(true);
+ };
+ const onUp = () => {
+ if (pttReleaseDelayMs <= 0) {
+ setIsPttActive(false);
+ return;
+ }
+ if (pttReleaseTimerRef.current) {
+ clearTimeout(pttReleaseTimerRef.current);
+ }
+ pttReleaseTimerRef.current = setTimeout(() => {
+ setIsPttActive(false);
+ pttReleaseTimerRef.current = null;
+ }, pttReleaseDelayMs);
+ };
+ window.addEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
+ window.addEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
+ return () => {
+ window.removeEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
+ window.removeEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
+ if (pttReleaseTimerRef.current) {
+ clearTimeout(pttReleaseTimerRef.current);
+ pttReleaseTimerRef.current = null;
+ }
+ };
+ }, [inputMode, pttReleaseDelayMs]);
+
// Voice recording — crash-safe per-participant audio capture.
// Only available on Electron (`platform.features.hasRecording`).
// `recordingError` is surfaced once per failure so UI can toast
@@ -759,15 +837,18 @@ export const VoiceProvider = ({ children }) => {
}
}, [voiceStates, activeChannelId, room, convex, myUserId]);
- // Enforce server mute: force-disable mic when server muted, restore when lifted
+ // Reconcile the mic track against every source of "mic should be
+ // off": user mute, deafen, server mute, and — when the input mode
+ // is push-to-talk — the PTT not being currently held. Runs on any
+ // change so the UI stays in sync without each feature owning its
+ // own enable/disable path.
useEffect(() => {
if (!myUserId || !room) return;
- if (isServerMuted(myUserId)) {
- room.localParticipant.setMicrophoneEnabled(false);
- } else if (!isMuted && !isDeafened) {
- room.localParticipant.setMicrophoneEnabled(true);
- }
- }, [voiceStates, room, myUserId]);
+ const serverMuted = isServerMuted(myUserId);
+ const pttBlocks = inputMode === 'push-to-talk' && !isPttActive;
+ const shouldEnable = !isMuted && !isDeafened && !serverMuted && !pttBlocks;
+ room.localParticipant.setMicrophoneEnabled(shouldEnable);
+ }, [voiceStates, room, myUserId, isMuted, isDeafened, inputMode, isPttActive]);
// Re-apply personal mutes/volumes when room or participants change
useEffect(() => {
diff --git a/packages/shared/src/global.css b/packages/shared/src/global.css
index c3b2ee5..d2ff311 100644
--- a/packages/shared/src/global.css
+++ b/packages/shared/src/global.css
@@ -893,6 +893,12 @@ img[alt] {
100% { background-color: transparent; }
}
+@keyframes brycord-record-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0.55); }
+ 70% { box-shadow: 0 0 0 10px rgba(218, 55, 60, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0); }
+}
+
.searchHighlight {
animation: searchFlash 2s ease-out;
}
diff --git a/packages/shared/src/platform/types.js b/packages/shared/src/platform/types.js
index 605d10e..a6c9096 100644
--- a/packages/shared/src/platform/types.js
+++ b/packages/shared/src/platform/types.js
@@ -52,6 +52,14 @@
* @property {() => void} close
*/
+/**
+ * @typedef {Object} PlatformNotifications
+ * @property {(opts: {title: string, body?: string, silent?: boolean}) => void|Promise} show - Show a desktop/system notification
+ * @property {(count: number) => void} setBadge - Set unread badge/overlay count (0 clears)
+ * @property {(on: boolean) => void} flashFrame - Flash the window/taskbar to draw attention
+ * @property {() => Promise<'granted'|'denied'|'default'|'unavailable'>} ensurePermission - Request permission if needed; resolves the current state
+ */
+
/**
* @typedef {Object} PlatformRecording
* @property {() => Promise} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
@@ -109,6 +117,7 @@
* @property {boolean} hasVoiceService
* @property {boolean} hasSystemBars
* @property {boolean} [hasBackButton]
+ * @property {boolean} [hasNotifications]
*/
/**
@@ -120,6 +129,7 @@
* @property {PlatformLinks} links
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
+ * @property {PlatformNotifications|null} notifications
* @property {PlatformRecording|null} recording
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB