Enhance AudioPlayer component for native playback and debugging

- Improved the useNativePlayback logic to include detailed console logging for better debugging.
- Updated auto-play functionality to fetch runtime profile channels and channels, ensuring accurate playback decisions.
- Refactored audio playback handling to support native audio routing with enhanced error handling and logging.
- Introduced a new MultiSelect component for improved channel selection in VoicesTab.
- Updated FloatingGenerateBox to include selectedProfileId in audio setting.
- Added new dependencies for audio processing in Cargo.toml and Cargo.lock.
This commit is contained in:
Jamie Pine
2026-01-27 16:15:16 -08:00
parent d9c7121c5b
commit 7f18c09628
8 changed files with 725 additions and 73 deletions
+110 -7
View File
@@ -48,17 +48,32 @@ export function AudioPlayer() {
// Determine if we should use native playback
const useNativePlayback = useMemo(() => {
if (!isTauri() || !profileChannels || !channels) return false;
console.log('useNativePlayback memo:', {
isTauri: isTauri(),
profileId,
profileChannels,
channels,
});
if (!isTauri() || !profileChannels || !channels) {
console.log('useNativePlayback: false - missing requirements');
return false;
}
const assignedChannels = channels.filter((ch) =>
profileChannels.channel_ids.includes(ch.id),
);
console.log('Assigned channels:', assignedChannels);
// Use native playback if any assigned channel has non-default devices
return assignedChannels.some(
const shouldUseNative = assignedChannels.some(
(ch) => ch.device_ids.length > 0 && !ch.is_default,
);
}, [profileChannels, channels, isTauri()]);
console.log('useNativePlayback result:', shouldUseNative);
return shouldUseNative;
}, [profileChannels, channels, profileId]);
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
@@ -157,7 +172,7 @@ export function AudioPlayer() {
});
// Update store when duration is loaded
wavesurfer.on('ready', () => {
wavesurfer.on('ready', async () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
@@ -178,7 +193,95 @@ export function AudioPlayer() {
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready
// Auto-play when ready - check if we should use native playback
// Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl;
const currentProfileId = usePlayerStore.getState().profileId;
console.log('Auto-play check - capturing runtime values...');
// Fetch profile channels at runtime (not using captured value)
let runtimeProfileChannels = null;
let runtimeChannels = null;
if (isTauri() && currentProfileId) {
try {
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
console.log('Runtime profileChannels:', runtimeProfileChannels);
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
runtimeChannels = await apiClient.listChannels();
console.log('Runtime channels:', runtimeChannels);
}
} catch (error) {
console.error('Failed to fetch runtime channel data:', error);
}
}
console.log('Auto-play check:', {
isTauri: isTauri(),
currentAudioUrl,
currentProfileId,
hasProfileChannels: !!runtimeProfileChannels,
hasChannels: !!runtimeChannels,
});
if (isTauri() && currentAudioUrl && currentProfileId && runtimeProfileChannels && runtimeChannels) {
console.log('Attempting native audio playback...');
try {
// Collect all device IDs from assigned channels
const assignedChannels = runtimeChannels.filter((ch: any) =>
runtimeProfileChannels.channel_ids.includes(ch.id),
);
console.log('Assigned channels for playback:', assignedChannels);
// Check if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some(
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
);
console.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) {
console.log('No custom devices assigned, falling back to WaveSurfer');
} else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
console.log('Device IDs to play to:', deviceIds);
if (deviceIds.length > 0) {
console.log('Fetching audio data from:', currentAudioUrl);
// Fetch audio data
const response = await fetch(currentAudioUrl);
const audioData = new Uint8Array(await response.arrayBuffer());
console.log('Audio data size:', audioData.length);
// Play via native audio
console.log('Invoking play_audio_to_devices...');
try {
const result = await invoke('play_audio_to_devices', {
audioData: Array.from(audioData),
deviceIds: deviceIds,
});
console.log('play_audio_to_devices completed successfully, result:', result);
setIsPlaying(true);
console.log('Auto-playing via native audio routing - SUCCESS');
return;
} catch (invokeError) {
console.error('play_audio_to_devices invoke failed:', invokeError);
throw invokeError;
}
} else {
console.log('No device IDs found, falling back to WaveSurfer');
}
}
} catch (error) {
console.error('Native playback failed during auto-play, falling back to WaveSurfer:', error);
// Fall through to WaveSurfer playback
}
} else {
console.log('Not using native playback, using WaveSurfer');
}
// Standard WaveSurfer auto-play
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
@@ -454,8 +557,8 @@ export function AudioPlayer() {
// Play via native audio
await invoke('play_audio_to_devices', {
audio_data: Array.from(audioData),
device_ids: deviceIds,
audioData: Array.from(audioData),
deviceIds: deviceIds,
});
setIsPlaying(true);
@@ -134,7 +134,7 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, data.text.substring(0, 50));
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
setIsExpanded(false);
+12 -21
View File
@@ -8,6 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MultiSelect } from '@/components/ui/multi-select';
import {
Table,
TableBody,
@@ -17,6 +18,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
@@ -136,12 +138,7 @@ export function VoicesTab() {
}
interface VoiceRowProps {
profile: {
id: string;
name: string;
description: string | null;
language: string;
};
profile: VoiceProfileResponse;
generationCount: number;
channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>;
@@ -175,22 +172,16 @@ function VoiceRow({
<TableCell>{generationCount}</TableCell>
<TableCell>{samples?.length || 0}</TableCell>
<TableCell>
<select
multiple
<MultiSelect
options={channels.map((ch) => ({
value: ch.id,
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
}))}
value={channelIds}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, (opt) => opt.value);
onChannelChange(selected);
}}
className="w-full min-w-[200px] border rounded px-2 py-1 text-sm"
size={Math.min(channels.length + 1, 5)}
>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>
{ch.name} {ch.is_default && '(Default)'}
</option>
))}
</select>
onChange={onChannelChange}
placeholder="Select channels..."
className="min-w-[200px]"
/>
</TableCell>
<TableCell>
<DropdownMenu>
+102
View File
@@ -0,0 +1,102 @@
import * as React from 'react';
import { ChevronDown, Check } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
export interface MultiSelectOption {
value: string;
label: string;
}
export interface MultiSelectProps {
options: MultiSelectOption[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
}
const MultiSelectCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
MultiSelectCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
export function MultiSelect({
options,
value,
onChange,
placeholder = 'Select...',
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (optionValue: string) => {
const newValue = value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue];
onChange(newValue);
};
const displayText =
value.length === 0
? placeholder
: value.length === 1
? options.find((opt) => opt.value === value[0])?.label || placeholder
: `${value.length} selected`;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex h-8 w-full items-center justify-between rounded-full border border-border bg-card px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-background/50 transition-all',
className,
)}
>
<span className="line-clamp-1">{displayText}</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="max-h-96 overflow-auto"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{options.map((option) => (
<MultiSelectCheckboxItem
key={option.value}
checked={value.includes(option.value)}
onSelect={() => handleSelect(option.value)}
onCheckedChange={() => handleSelect(option.value)}
>
{option.label}
</MultiSelectCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}