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
5 changes: 4 additions & 1 deletion apps/whispering/src-tauri/src/recorder/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ impl RecorderState {
/// List available recording devices by name
pub fn enumerate_devices(&self) -> Result<Vec<String>> {
let host = cpal::default_host();
let devices = host
let mut devices: Vec<String> = host
.input_devices()
.map_err(|e| format!("Failed to get input devices: {}", e))?
.filter_map(|device| device.name().ok())
.collect();

// Prepend "default" as the first option (uses OS system default)
devices.insert(0, "default".to_string());

Ok(devices)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { Spinner } from '@epicenter/ui/spinner';
import { Badge } from '@epicenter/ui/badge';
import { createDeviceChangeListener } from '$lib/services/isomorphic/device-change.svelte';

const combobox = useCombobox();

Expand Down Expand Up @@ -51,6 +52,19 @@
enabled: combobox.open,
}));

const deviceChangeListener = createDeviceChangeListener();

// Auto-refresh device list when devices change (web only - desktop uses polling)
// Note: TanStack Query already fetches when enabled becomes true, so no manual refetch needed
$effect(() => {
if (combobox.open) {
deviceChangeListener.subscribe();
}
return () => {
deviceChangeListener.unsubscribe();
};
});

$effect(() => {
if (getDevicesQuery.isError) {
rpc.notify.warning(getDevicesQuery.error);
Expand Down Expand Up @@ -159,7 +173,12 @@
: 'opacity-0',
)}
/>
<span class="flex-1 text-sm">{device.label}</span>
<span class="flex-1 text-sm">
{device.label}
{#if device.id === 'default'}
<Badge variant="secondary" class="ml-2 text-xs">Auto</Badge>
{/if}
</span>
</Command.Item>
{/each}
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import MicIcon from '@lucide/svelte/icons/mic';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { Spinner } from '@epicenter/ui/spinner';
import { Badge } from '@epicenter/ui/badge';
import { createDeviceChangeListener } from '$lib/services/isomorphic/device-change.svelte';

const combobox = useCombobox();

Expand All @@ -27,6 +29,19 @@
enabled: combobox.open,
}));

const deviceChangeListener = createDeviceChangeListener();

// Auto-refresh device list when devices change (web only - desktop uses polling)
// Note: TanStack Query already fetches when enabled becomes true, so no manual refetch needed
$effect(() => {
if (combobox.open) {
deviceChangeListener.subscribe();
}
return () => {
deviceChangeListener.unsubscribe();
};
});

$effect(() => {
if (getDevicesQuery.isError) {
rpc.notify.warning(getDevicesQuery.error);
Expand Down Expand Up @@ -91,7 +106,12 @@
selectedDeviceId === device.id ? 'opacity-100' : 'opacity-0',
)}
/>
{device.label}
<span class="flex-1">
{device.label}
{#if device.id === 'default'}
<Badge variant="secondary" class="ml-2 text-xs">Auto</Badge>
{/if}
</span>
</Command.Item>
{/each}
{/if}
Expand Down
15 changes: 14 additions & 1 deletion apps/whispering/src/lib/query/isomorphic/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,21 @@ export const recorder = {
serviceError: error,
});
}
return Ok(data);
// Transform "default" device ID to user-friendly "System Default" label
const devicesWithLabels = data.map((device) => {
if (device.id === 'default') {
return {
...device,
label: 'System Default',
};
}
return device;
});
return Ok(devicesWithLabels);
},
// Poll for device changes on desktop (no native devicechange event in Tauri)
// On web, we use the devicechange event listener in components
refetchInterval: window.__TAURI_INTERNALS__ ? 3000 : false,
}),

// Query that returns the recorder state (IDLE or RECORDING)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Creates a device change listener for monitoring audio device changes
* Works in both web (MediaDevices) and desktop (Tauri) environments
*/
export function createDeviceChangeListener() {
let isSubscribed = false;
let handler: (() => void) | null = null;

function subscribe() {
if (isSubscribed || !navigator.mediaDevices) {
return;
}

// Subscribe to device changes (web only - desktop uses polling)
if ('ondevicechange' in navigator.mediaDevices) {
handler = () => {
// Device change detected - queries will auto-refresh via enabled flag
console.log('[DeviceChangeListener] Device change detected');
};
navigator.mediaDevices.addEventListener('devicechange', handler);
isSubscribed = true;
}
}

function unsubscribe() {
if (handler && navigator.mediaDevices) {
navigator.mediaDevices.removeEventListener('devicechange', handler);
}
isSubscribed = false;
handler = null;
}

return {
subscribe,
unsubscribe,
};
}
24 changes: 19 additions & 5 deletions apps/whispering/src/lib/services/isomorphic/device-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,18 @@ export async function enumerateDevices(): Promise<
(device) => device.kind === 'audioinput',
);
// On Web: Return Device objects with both ID and label
return audioInputDevices.map((device) => ({
const deviceList = audioInputDevices.map((device) => ({
id: asDeviceIdentifier(device.deviceId),
label: device.label,
}));

// Prepend "System Default" as the first option
deviceList.unshift({
id: asDeviceIdentifier('default'),
label: 'System Default',
});

return deviceList;
},
catch: (error) =>
DeviceStreamServiceErr({
Expand All @@ -89,11 +97,17 @@ async function getStreamForDeviceIdentifier(
return tryAsync({
try: async () => {
// On Web: deviceIdentifier IS the deviceId, use it directly
// Special case: if deviceIdentifier is "default", omit deviceId to use system default
const audioConstraints =
deviceIdentifier === 'default'
? WHISPER_RECOMMENDED_MEDIA_TRACK_CONSTRAINTS
: {
...WHISPER_RECOMMENDED_MEDIA_TRACK_CONSTRAINTS,
deviceId: { exact: deviceIdentifier },
};

const stream = await navigator.mediaDevices.getUserMedia({
audio: {
...WHISPER_RECOMMENDED_MEDIA_TRACK_CONSTRAINTS,
deviceId: { exact: deviceIdentifier },
},
audio: audioConstraints,
});
return stream;
},
Expand Down
6 changes: 3 additions & 3 deletions apps/whispering/src/lib/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,13 @@ export const Settings = type({
*/
'recording.cpal.deviceId': type('string | null')
.pipe(deviceIdTransform)
.default(null),
.default('default'),
'recording.navigator.deviceId': type('string | null')
.pipe(deviceIdTransform)
.default(null),
.default('default'),
'recording.ffmpeg.deviceId': type('string | null')
.pipe(deviceIdTransform)
.default(null),
.default('default'),

// Browser recording settings (used when browser method is selected)
'recording.navigator.bitrateKbps': type
Expand Down
Loading