From 30ea627ae852df19a40cc0d72e455713116da7b0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 26 Jan 2026 19:20:20 -0800 Subject: [PATCH 1/8] Implement audio channel management features - Added new components for managing audio channels, including creation, updating, and deletion of channels. - Introduced a new AudioTab for channel management and integrated it into the main application layout. - Updated the API client to support audio channel operations and added corresponding backend endpoints. - Enhanced the player store to handle audio playback routing through assigned channels. - Refactored existing components to accommodate the new audio channel functionality, including updates to the HistoryTable and GenerationForm for profile-channel associations. - Improved sidebar navigation to include new tabs for Voices and Audio management. --- app/src/App.tsx | 36 +- .../components/AudioPlayer/AudioPlayer.tsx | 76 ++- app/src/components/AudioTab/AudioTab.tsx | 537 ++++++++++++++++++ .../components/Generation/GenerationForm.tsx | 2 +- app/src/components/History/HistoryTable.tsx | 8 +- app/src/components/ServerTab/ServerTab.tsx | 29 + app/src/components/Sidebar.tsx | 8 +- app/src/components/VoicesTab/VoicesTab.tsx | 216 +++++++ app/src/lib/api/client.ts | 82 +++ app/src/stores/audioChannelStore.ts | 44 ++ app/src/stores/playerStore.ts | 8 +- backend/channels.py | 263 +++++++++ backend/database.py | 54 +- backend/main.py | 121 +++- backend/models.py | 34 ++ tauri/src-tauri/Cargo.toml | 2 + tauri/src-tauri/src/audio_output.rs | 357 ++++++++++++ tauri/src-tauri/src/main.rs | 22 +- 18 files changed, 1857 insertions(+), 42 deletions(-) create mode 100644 app/src/components/AudioTab/AudioTab.tsx create mode 100644 app/src/components/ServerTab/ServerTab.tsx create mode 100644 app/src/components/VoicesTab/VoicesTab.tsx create mode 100644 app/src/stores/audioChannelStore.ts create mode 100644 backend/channels.py create mode 100644 tauri/src-tauri/src/audio_output.rs diff --git a/app/src/App.tsx b/app/src/App.tsx index d76f7655..202c8ac4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,15 +3,14 @@ import voiceboxLogo from '@/assets/voicebox-logo.png'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; import { GenerationForm } from '@/components/Generation/GenerationForm'; import { HistoryTable } from '@/components/History/HistoryTable'; -import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; -import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; -import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; -import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import ShinyText from '@/components/ShinyText'; import { Sidebar } from '@/components/Sidebar'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { Toaster } from '@/components/ui/toaster'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; +import { VoicesTab } from '@/components/VoicesTab/VoicesTab'; +import { AudioTab } from '@/components/AudioTab/AudioTab'; +import { ServerTab } from '@/components/ServerTab/ServerTab'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks'; import { @@ -174,27 +173,7 @@ function App() {
- {activeTab === 'settings' ? ( -
-
- - -
- {isTauri() && } - -
- Created by{' '} - - Jamie Pine - -
-
- ) : ( + {activeTab === 'main' && ( // Main view: Profiles top left, Generator bottom left, History right
{/* Left Column */} @@ -216,12 +195,15 @@ function App() {
)} + {activeTab === 'voices' && } + {activeTab === 'audio' && } + {activeTab === 'server' && }
- {/* Audio Player - always visible except on settings */} - {activeTab !== 'settings' && } + {/* Audio Player - always visible except on server tab */} + {activeTab !== 'server' && } {/* Show download toasts for any active downloads (from anywhere) */} {activeDownloads.map((download) => { diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 1e125e13..b8af49d6 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,15 +1,20 @@ import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { formatAudioDuration } from '@/lib/utils/audio'; import { usePlayerStore } from '@/stores/playerStore'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; +import { isTauri } from '@/lib/tauri'; +import { invoke } from '@tauri-apps/api/core'; export function AudioPlayer() { const { audioUrl, audioId, + profileId, title, isPlaying, currentTime, @@ -25,6 +30,36 @@ export function AudioPlayer() { clearRestartFlag, } = usePlayerStore(); + // Check if profile has assigned channels (for native audio routing) + const { data: profileChannels } = useQuery({ + queryKey: ['profile-channels', profileId], + queryFn: () => { + if (!profileId) return { channel_ids: [] }; + return apiClient.getProfileChannels(profileId); + }, + enabled: !!profileId && isTauri(), + }); + + const { data: channels } = useQuery({ + queryKey: ['channels'], + queryFn: () => apiClient.listChannels(), + enabled: !!profileChannels && profileChannels.channel_ids.length > 0, + }); + + // Determine if we should use native playback + const useNativePlayback = useMemo(() => { + if (!isTauri() || !profileChannels || !channels) return false; + + const assignedChannels = channels.filter((ch) => + profileChannels.channel_ids.includes(ch.id), + ); + + // Use native playback if any assigned channel has non-default devices + return assignedChannels.some( + (ch) => ch.device_ids.length > 0 && !ch.is_default, + ); + }, [profileChannels, channels, isTauri()]); + const waveformRef = useRef(null); const wavesurferRef = useRef(null); const loadingRef = useRef(false); @@ -395,7 +430,44 @@ export function AudioPlayer() { // Handle loop - WaveSurfer handles this via the 'finish' event - const handlePlayPause = () => { + const handlePlayPause = async () => { + // If using native playback, handle differently + if (useNativePlayback && audioUrl && profileChannels && channels) { + if (isPlaying) { + // For native playback, we'd need to track and stop streams + // For now, just toggle the state + setIsPlaying(false); + return; + } + + try { + // Collect all device IDs from assigned channels + const assignedChannels = channels.filter((ch) => + profileChannels.channel_ids.includes(ch.id), + ); + const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids); + + if (deviceIds.length > 0) { + // Fetch audio data + const response = await fetch(audioUrl); + const audioData = new Uint8Array(await response.arrayBuffer()); + + // Play via native audio + await invoke('play_audio_to_devices', { + audio_data: Array.from(audioData), + device_ids: deviceIds, + }); + + setIsPlaying(true); + return; + } + } catch (error) { + console.error('Native playback failed, falling back to WaveSurfer:', error); + // Fall through to WaveSurfer playback + } + } + + // Standard WaveSurfer playback if (!wavesurferRef.current) { console.error('WaveSurfer not initialized'); return; diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx new file mode 100644 index 00000000..04e07978 --- /dev/null +++ b/app/src/components/AudioTab/AudioTab.tsx @@ -0,0 +1,537 @@ +import { Edit, Plus, Trash2, Speaker } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; +import { isTauri } from '@/lib/tauri'; +import { invoke } from '@tauri-apps/api/core'; + +interface AudioDevice { + id: string; + name: string; + is_default: boolean; +} + +export function AudioTab() { + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + const queryClient = useQueryClient(); + + const { data: channels, isLoading: channelsLoading } = useQuery({ + queryKey: ['channels'], + queryFn: () => apiClient.listChannels(), + }); + + const { data: devices, isLoading: devicesLoading } = useQuery({ + queryKey: ['audio-devices'], + queryFn: async () => { + if (!isTauri()) { + return []; + } + try { + const result = await invoke('list_audio_output_devices'); + return result; + } catch (error) { + console.error('Failed to list audio devices:', error); + return []; + } + }, + enabled: isTauri(), + }); + + const { data: profiles } = useQuery({ + queryKey: ['profiles'], + queryFn: () => apiClient.listProfiles(), + }); + + const createChannel = useMutation({ + mutationFn: (data: { name: string; device_ids: string[] }) => + apiClient.createChannel(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['channels'] }); + setCreateDialogOpen(false); + }, + }); + + const updateChannel = useMutation({ + mutationFn: ({ + channelId, + data, + }: { + channelId: string; + data: { name?: string; device_ids?: string[] }; + }) => apiClient.updateChannel(channelId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['channels'] }); + queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); + setEditingChannel(null); + }, + }); + + const deleteChannel = useMutation({ + mutationFn: (channelId: string) => apiClient.deleteChannel(channelId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['channels'] }); + queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); + }, + }); + + const { data: channelVoices } = useQuery({ + queryKey: ['channel-voices', editingChannel], + queryFn: async () => { + if (!editingChannel) return { profile_ids: [] }; + return apiClient.getChannelVoices(editingChannel); + }, + enabled: !!editingChannel, + }); + + const setChannelVoices = useMutation({ + mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) => + apiClient.setChannelVoices(channelId, profileIds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['channel-voices'] }); + queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); + }, + }); + + if (channelsLoading || devicesLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

Audio Channels

+ +
+ +
+ {channels?.map((channel) => ( + + +
+ + + {channel.name} + {channel.is_default && ( + (Default) + )} + + {!channel.is_default && ( +
+ + +
+ )} +
+
+ +
+
+ +
+ {channel.device_ids.length > 0 ? ( +
    + {channel.device_ids.map((deviceId) => { + const device = devices?.find((d) => d.id === deviceId); + return ( +
  • {device?.name || deviceId || 'Default Speakers'}
  • + ); + })} +
+ ) : ( + Default Speakers + )} +
+
+
+ + +
+
+
+
+ ))} +
+ +
+

Available Devices

+
+ {devices && devices.length > 0 ? ( + devices.map((device) => ( +
+ {device.name} + {device.is_default && ( + (default) + )} +
+ )) + ) : ( +
+ {isTauri() + ? 'No audio devices found' + : 'Audio device selection requires Tauri'} +
+ )} +
+
+ + {/* Create Channel Dialog */} + { + createChannel.mutate({ name, device_ids: deviceIds }); + }} + /> + + {/* Edit Channel Dialog */} + {editingChannel && ( + !open && setEditingChannel(null)} + channel={channels?.find((c) => c.id === editingChannel)!} + devices={devices || []} + profiles={profiles || []} + channelVoices={channelVoices?.profile_ids || []} + onUpdate={(name, deviceIds) => { + updateChannel.mutate({ + channelId: editingChannel, + data: { name, device_ids: deviceIds }, + }); + }} + onSetVoices={(profileIds) => { + setChannelVoices.mutate({ + channelId: editingChannel, + profileIds, + }); + }} + /> + )} +
+ ); +} + +function ChannelVoicesList({ channelId }: { channelId: string }) { + const { data: voices } = useQuery({ + queryKey: ['channel-voices', channelId], + queryFn: () => apiClient.getChannelVoices(channelId), + }); + + const { data: profiles } = useQuery({ + queryKey: ['profiles'], + queryFn: () => apiClient.listProfiles(), + }); + + const voiceNames = + voices?.profile_ids + .map((id) => profiles?.find((p) => p.id === id)?.name) + .filter(Boolean) || []; + + return ( +
+ {voiceNames.length > 0 ? ( + {voiceNames.join(', ')} + ) : ( + No voices assigned + )} +
+ ); +} + +interface CreateChannelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + devices: AudioDevice[]; + onCreate: (name: string, deviceIds: string[]) => void; +} + +function CreateChannelDialog({ + open, + onOpenChange, + devices, + onCreate, +}: CreateChannelDialogProps) { + const [name, setName] = useState(''); + const [selectedDevices, setSelectedDevices] = useState([]); + + const handleSubmit = () => { + if (name.trim()) { + onCreate(name.trim(), selectedDevices); + setName(''); + setSelectedDevices([]); + } + }; + + return ( + + + + Create Audio Channel + + Create a new audio channel (bus) to route voices to specific output devices. + + +
+
+ + setName(e.target.value)} + placeholder="e.g., Virtual Cable, Broadcast" + /> +
+
+ + + {selectedDevices.length > 0 && ( +
+ {selectedDevices.map((deviceId) => { + const device = devices.find((d) => d.id === deviceId); + return ( +
+ {device?.name || deviceId} + +
+ ); + })} +
+ )} +
+
+ + + + +
+
+ ); +} + +interface EditChannelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + channel: { + id: string; + name: string; + device_ids: string[]; + }; + devices: AudioDevice[]; + profiles: Array<{ id: string; name: string }>; + channelVoices: string[]; + onUpdate: (name: string, deviceIds: string[]) => void; + onSetVoices: (profileIds: string[]) => void; +} + +function EditChannelDialog({ + open, + onOpenChange, + channel, + devices, + profiles, + channelVoices, + onUpdate, + onSetVoices, +}: EditChannelDialogProps) { + const [name, setName] = useState(channel.name); + const [selectedDevices, setSelectedDevices] = useState(channel.device_ids); + const [selectedVoices, setSelectedVoices] = useState(channelVoices); + + const handleSubmit = () => { + if (name.trim()) { + onUpdate(name.trim(), selectedDevices); + onSetVoices(selectedVoices); + } + }; + + return ( + + + + Edit Channel + Update channel settings and voice assignments. + +
+
+ + setName(e.target.value)} + /> +
+
+ + + {selectedDevices.length > 0 && ( +
+ {selectedDevices.map((deviceId) => { + const device = devices.find((d) => d.id === deviceId); + return ( +
+ {device?.name || deviceId} + +
+ ); + })} +
+ )} +
+
+ + + {selectedVoices.length > 0 && ( +
+ {selectedVoices.map((profileId) => { + const profile = profiles.find((p) => p.id === profileId); + return ( +
+ {profile?.name || profileId} + +
+ ); + })} +
+ )} +
+
+ + + + +
+
+ ); +} diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index d11d36d0..2d13bf9e 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -120,7 +120,7 @@ export function GenerationForm() { // Autoplay the generated audio 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(); } catch (error) { diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index ed2ae6b4..79faa64d 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -69,14 +69,14 @@ export function HistoryTable() { return () => scrollEl.removeEventListener('scroll', handleScroll); }, []); - const handlePlay = (audioId: string, text: string) => { + const handlePlay = (audioId: string, text: string, profileId: string) => { // If clicking the same audio, restart it from the beginning if (currentAudioId === audioId) { restartCurrentAudio(); } else { // Otherwise, load the new audio const audioUrl = apiClient.getAudioUrl(audioId); - setAudio(audioUrl, audioId, text.substring(0, 50)); + setAudio(audioUrl, audioId, profileId, text.substring(0, 50)); } }; @@ -195,7 +195,7 @@ export function HistoryTable() { if (target.closest('textarea') || window.getSelection()?.toString()) { return; } - handlePlay(gen.id, gen.text); + handlePlay(gen.id, gen.text, gen.profile_id); }} > {/* Waveform icon */} @@ -242,7 +242,7 @@ export function HistoryTable() { - handlePlay(gen.id, gen.text)}> + handlePlay(gen.id, gen.text, gen.profile_id)}> Play diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx new file mode 100644 index 00000000..78e556ae --- /dev/null +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -0,0 +1,29 @@ +import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; +import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; +import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; +import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; +import { isTauri } from '@/lib/tauri'; + +export function ServerTab() { + return ( +
+
+ + +
+ {isTauri() && } + +
+ Created by{' '} + + Jamie Pine + +
+
+ ); +} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 3b75e65c..2bb4496b 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Loader2, Settings, Volume2 } from 'lucide-react'; +import { Loader2, Settings, Volume2, Mic, Speaker, Server } from 'lucide-react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; @@ -11,8 +11,10 @@ interface SidebarProps { } const tabs = [ - { id: 'main', icon: Volume2, label: 'Main' }, - { id: 'settings', icon: Settings, label: 'Settings' }, + { id: 'main', icon: Volume2, label: 'Generate' }, + { id: 'voices', icon: Mic, label: 'Voices' }, + { id: 'audio', icon: Speaker, label: 'Audio' }, + { id: 'server', icon: Server, label: 'Server' }, ]; export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) { diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx new file mode 100644 index 00000000..5a585c80 --- /dev/null +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -0,0 +1,216 @@ +import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useProfiles, useProfileSamples, useDeleteProfile } from '@/lib/hooks/useProfiles'; +import { useHistory } from '@/lib/hooks/useHistory'; +import { useUIStore } from '@/stores/uiStore'; +import { apiClient } from '@/lib/api/client'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export function VoicesTab() { + const { data: profiles, isLoading } = useProfiles(); + const { data: historyData } = useHistory({ limit: 1000 }); + const queryClient = useQueryClient(); + const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); + const deleteProfile = useDeleteProfile(); + + // Get generation counts per profile + const generationCounts = useMemo(() => { + const counts: Record = {}; + if (historyData?.items) { + historyData.items.forEach((item) => { + counts[item.profile_id] = (counts[item.profile_id] || 0) + 1; + }); + } + return counts; + }, [historyData]); + + // Get channel assignments for each profile + const { data: channelAssignments } = useQuery({ + queryKey: ['profile-channels'], + queryFn: async () => { + if (!profiles) return {}; + const assignments: Record = {}; + for (const profile of profiles) { + try { + const result = await apiClient.getProfileChannels(profile.id); + assignments[profile.id] = result.channel_ids; + } catch { + assignments[profile.id] = []; + } + } + return assignments; + }, + enabled: !!profiles, + }); + + // Get all channels + const { data: channels } = useQuery({ + queryKey: ['channels'], + queryFn: () => apiClient.listChannels(), + }); + + const handleEdit = (profileId: string) => { + setEditingProfileId(profileId); + setDialogOpen(true); + }; + + const handleDelete = (profileId: string) => { + if (confirm('Are you sure you want to delete this profile?')) { + deleteProfile.mutate(profileId); + } + }; + + const handleChannelChange = async (profileId: string, channelIds: string[]) => { + try { + await apiClient.setProfileChannels(profileId, channelIds); + queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); + } catch (error) { + console.error('Failed to update channels:', error); + } + }; + + if (isLoading) { + return ( +
+
Loading voices...
+
+ ); + } + + return ( +
+
+

Voices

+ +
+ +
+ + + + Name + Language + Generations + Samples + Channels + + + + + {profiles?.map((profile) => ( + handleChannelChange(profile.id, channelIds)} + onEdit={() => handleEdit(profile.id)} + onDelete={() => handleDelete(profile.id)} + /> + ))} + +
+
+
+ ); +} + +interface VoiceRowProps { + profile: { + id: string; + name: string; + description: string | null; + language: string; + }; + generationCount: number; + channelIds: string[]; + channels: Array<{ id: string; name: string; is_default: boolean }>; + onChannelChange: (channelIds: string[]) => void; + onEdit: () => void; + onDelete: () => void; +} + +function VoiceRow({ + profile, + generationCount, + channelIds, + channels, + onChannelChange, + onEdit, + onDelete, +}: VoiceRowProps) { + const { data: samples } = useProfileSamples(profile.id); + + return ( + + +
+
{profile.name}
+ {profile.description && ( +
{profile.description}
+ )} +
+
+ {profile.language} + {generationCount} + {samples?.length || 0} + + + + + + + + + + + + Edit + + + + Delete + + + + +
+ ); +} diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 506f573c..bc654932 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -279,6 +279,88 @@ class ApiClient { async getActiveTasks(): Promise { return this.request('/tasks/active'); } + + // Audio Channels + async listChannels(): Promise< + Array<{ + id: string; + name: string; + is_default: boolean; + device_ids: string[]; + created_at: string; + }> + > { + return this.request('/channels'); + } + + async createChannel(data: { + name: string; + device_ids: string[]; + }): Promise<{ + id: string; + name: string; + is_default: boolean; + device_ids: string[]; + created_at: string; + }> { + return this.request('/channels', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateChannel( + channelId: string, + data: { + name?: string; + device_ids?: string[]; + }, + ): Promise<{ + id: string; + name: string; + is_default: boolean; + device_ids: string[]; + created_at: string; + }> { + return this.request(`/channels/${channelId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteChannel(channelId: string): Promise<{ message: string }> { + return this.request(`/channels/${channelId}`, { + method: 'DELETE', + }); + } + + async getChannelVoices(channelId: string): Promise<{ profile_ids: string[] }> { + return this.request(`/channels/${channelId}/voices`); + } + + async setChannelVoices( + channelId: string, + profileIds: string[], + ): Promise<{ message: string }> { + return this.request(`/channels/${channelId}/voices`, { + method: 'PUT', + body: JSON.stringify({ profile_ids: profileIds }), + }); + } + + async getProfileChannels(profileId: string): Promise<{ channel_ids: string[] }> { + return this.request(`/profiles/${profileId}/channels`); + } + + async setProfileChannels( + profileId: string, + channelIds: string[], + ): Promise<{ message: string }> { + return this.request(`/profiles/${profileId}/channels`, { + method: 'PUT', + body: JSON.stringify({ channel_ids: channelIds }), + }); + } } export const apiClient = new ApiClient(); diff --git a/app/src/stores/audioChannelStore.ts b/app/src/stores/audioChannelStore.ts new file mode 100644 index 00000000..f8e126ed --- /dev/null +++ b/app/src/stores/audioChannelStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface AudioChannel { + id: string; + name: string; + is_default: boolean; + device_ids: string[]; + created_at: string; +} + +interface AudioChannelStore { + channels: AudioChannel[]; + setChannels: (channels: AudioChannel[]) => void; + addChannel: (channel: AudioChannel) => void; + updateChannel: (id: string, channel: Partial) => void; + removeChannel: (id: string) => void; +} + +export const useAudioChannelStore = create()( + persist( + (set) => ({ + channels: [], + setChannels: (channels) => set({ channels }), + addChannel: (channel) => + set((state) => ({ + channels: [...state.channels, channel], + })), + updateChannel: (id, updates) => + set((state) => ({ + channels: state.channels.map((ch) => + ch.id === id ? { ...ch, ...updates } : ch, + ), + })), + removeChannel: (id) => + set((state) => ({ + channels: state.channels.filter((ch) => ch.id !== id), + })), + }), + { + name: 'voicebox-audio-channels', + }, + ), +); diff --git a/app/src/stores/playerStore.ts b/app/src/stores/playerStore.ts index 006e0d85..d6b984a9 100644 --- a/app/src/stores/playerStore.ts +++ b/app/src/stores/playerStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; interface PlayerState { audioUrl: string | null; audioId: string | null; + profileId: string | null; title: string | null; isPlaying: boolean; currentTime: number; @@ -11,7 +12,7 @@ interface PlayerState { isLooping: boolean; shouldRestart: boolean; - setAudio: (url: string, id: string, title?: string) => void; + setAudio: (url: string, id: string, profileId: string | null, title?: string) => void; setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; @@ -25,6 +26,7 @@ interface PlayerState { export const usePlayerStore = create((set) => ({ audioUrl: null, audioId: null, + profileId: null, title: null, isPlaying: false, currentTime: 0, @@ -33,10 +35,11 @@ export const usePlayerStore = create((set) => ({ isLooping: false, shouldRestart: false, - setAudio: (url, id, title) => + setAudio: (url, id, profileId, title) => set({ audioUrl: url, audioId: id, + profileId: profileId || null, title: title || null, currentTime: 0, isPlaying: false, @@ -53,6 +56,7 @@ export const usePlayerStore = create((set) => ({ set({ audioUrl: null, audioId: null, + profileId: null, title: null, isPlaying: false, currentTime: 0, diff --git a/backend/channels.py b/backend/channels.py new file mode 100644 index 00000000..146c003d --- /dev/null +++ b/backend/channels.py @@ -0,0 +1,263 @@ +""" +Audio channel management module. +""" + +from typing import List, Optional +from datetime import datetime +import uuid +from sqlalchemy.orm import Session + +from .models import ( + AudioChannelCreate, + AudioChannelUpdate, + AudioChannelResponse, + ChannelVoiceAssignment, + ProfileChannelAssignment, +) +from .database import ( + AudioChannel as DBAudioChannel, + ChannelDeviceMapping as DBChannelDeviceMapping, + ProfileChannelMapping as DBProfileChannelMapping, + VoiceProfile as DBVoiceProfile, +) + + +async def list_channels(db: Session) -> List[AudioChannelResponse]: + """List all audio channels.""" + channels = db.query(DBAudioChannel).all() + result = [] + + for channel in channels: + # Get device IDs for this channel + device_mappings = db.query(DBChannelDeviceMapping).filter_by( + channel_id=channel.id + ).all() + device_ids = [m.device_id for m in device_mappings] + + result.append(AudioChannelResponse( + id=channel.id, + name=channel.name, + is_default=channel.is_default, + device_ids=device_ids, + created_at=channel.created_at, + )) + + return result + + +async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]: + """Get a channel by ID.""" + channel = db.query(DBAudioChannel).filter_by(id=channel_id).first() + if not channel: + return None + + # Get device IDs + device_mappings = db.query(DBChannelDeviceMapping).filter_by( + channel_id=channel.id + ).all() + device_ids = [m.device_id for m in device_mappings] + + return AudioChannelResponse( + id=channel.id, + name=channel.name, + is_default=channel.is_default, + device_ids=device_ids, + created_at=channel.created_at, + ) + + +async def create_channel( + data: AudioChannelCreate, + db: Session, +) -> AudioChannelResponse: + """Create a new audio channel.""" + # Check if name already exists + existing = db.query(DBAudioChannel).filter_by(name=data.name).first() + if existing: + raise ValueError(f"Channel with name '{data.name}' already exists") + + # Create channel + channel = DBAudioChannel( + id=str(uuid.uuid4()), + name=data.name, + is_default=False, + created_at=datetime.utcnow(), + ) + db.add(channel) + db.flush() + + # Add device mappings + for device_id in data.device_ids: + mapping = DBChannelDeviceMapping( + id=str(uuid.uuid4()), + channel_id=channel.id, + device_id=device_id, + ) + db.add(mapping) + + db.commit() + db.refresh(channel) + + return AudioChannelResponse( + id=channel.id, + name=channel.name, + is_default=channel.is_default, + device_ids=data.device_ids, + created_at=channel.created_at, + ) + + +async def update_channel( + channel_id: str, + data: AudioChannelUpdate, + db: Session, +) -> Optional[AudioChannelResponse]: + """Update an audio channel.""" + channel = db.query(DBAudioChannel).filter_by(id=channel_id).first() + if not channel: + return None + + if channel.is_default: + raise ValueError("Cannot modify the default channel") + + # Update name if provided + if data.name is not None: + # Check if name already exists (excluding current channel) + existing = db.query(DBAudioChannel).filter( + DBAudioChannel.name == data.name, + DBAudioChannel.id != channel_id + ).first() + if existing: + raise ValueError(f"Channel with name '{data.name}' already exists") + channel.name = data.name + + # Update device mappings if provided + if data.device_ids is not None: + # Delete existing mappings + db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete() + + # Add new mappings + for device_id in data.device_ids: + mapping = DBChannelDeviceMapping( + id=str(uuid.uuid4()), + channel_id=channel.id, + device_id=device_id, + ) + db.add(mapping) + + db.commit() + db.refresh(channel) + + # Get updated device IDs + device_mappings = db.query(DBChannelDeviceMapping).filter_by( + channel_id=channel.id + ).all() + device_ids = [m.device_id for m in device_mappings] + + return AudioChannelResponse( + id=channel.id, + name=channel.name, + is_default=channel.is_default, + device_ids=device_ids, + created_at=channel.created_at, + ) + + +async def delete_channel(channel_id: str, db: Session) -> bool: + """Delete an audio channel.""" + channel = db.query(DBAudioChannel).filter_by(id=channel_id).first() + if not channel: + return False + + if channel.is_default: + raise ValueError("Cannot delete the default channel") + + # Delete device mappings + db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete() + + # Delete profile-channel mappings + db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete() + + # Delete channel + db.delete(channel) + db.commit() + + return True + + +async def get_channel_voices(channel_id: str, db: Session) -> List[str]: + """Get list of profile IDs assigned to a channel.""" + mappings = db.query(DBProfileChannelMapping).filter_by( + channel_id=channel_id + ).all() + return [m.profile_id for m in mappings] + + +async def set_channel_voices( + channel_id: str, + data: ChannelVoiceAssignment, + db: Session, +) -> None: + """Set which voices are assigned to a channel.""" + # Verify channel exists + channel = db.query(DBAudioChannel).filter_by(id=channel_id).first() + if not channel: + raise ValueError(f"Channel {channel_id} not found") + + # Verify all profiles exist + for profile_id in data.profile_ids: + profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + # Delete existing mappings for this channel + db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete() + + # Add new mappings + for profile_id in data.profile_ids: + mapping = DBProfileChannelMapping( + profile_id=profile_id, + channel_id=channel_id, + ) + db.add(mapping) + + db.commit() + + +async def get_profile_channels(profile_id: str, db: Session) -> List[str]: + """Get list of channel IDs assigned to a profile.""" + mappings = db.query(DBProfileChannelMapping).filter_by( + profile_id=profile_id + ).all() + return [m.channel_id for m in mappings] + + +async def set_profile_channels( + profile_id: str, + data: ProfileChannelAssignment, + db: Session, +) -> None: + """Set which channels a profile is assigned to.""" + # Verify profile exists + profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + # Verify all channels exist + for channel_id in data.channel_ids: + channel = db.query(DBAudioChannel).filter_by(id=channel_id).first() + if not channel: + raise ValueError(f"Channel {channel_id} not found") + + # Delete existing mappings for this profile + db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete() + + # Add new mappings + for channel_id in data.channel_ids: + mapping = DBProfileChannelMapping( + profile_id=profile_id, + channel_id=channel_id, + ) + db.add(mapping) + + db.commit() diff --git a/backend/database.py b/backend/database.py index 302ac5e4..1e48d86c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2,7 +2,7 @@ SQLite database ORM using SQLAlchemy. """ -from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey +from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session from datetime import datetime @@ -62,6 +62,33 @@ class Project(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class AudioChannel(Base): + """Audio channel (bus) database model.""" + __tablename__ = "audio_channels" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class ChannelDeviceMapping(Base): + """Mapping between channels and OS audio devices.""" + __tablename__ = "channel_device_mappings" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False) + device_id = Column(String, nullable=False) # OS device identifier + + +class ProfileChannelMapping(Base): + """Mapping between voice profiles and audio channels (many-to-many).""" + __tablename__ = "profile_channel_mappings" + + profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True) + channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True) + + # Database setup will be initialized in init_db() engine = None SessionLocal = None @@ -82,6 +109,31 @@ def init_db(): SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base.metadata.create_all(bind=engine) + + # Create default channel if it doesn't exist + db = SessionLocal() + try: + default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first() + if not default_channel: + default_channel = AudioChannel( + id=str(uuid.uuid4()), + name="Default", + is_default=True + ) + db.add(default_channel) + + # Assign all existing profiles to default channel + profiles = db.query(VoiceProfile).all() + for profile in profiles: + mapping = ProfileChannelMapping( + profile_id=profile.id, + channel_id=default_channel.id + ) + db.add(mapping) + + db.commit() + finally: + db.close() def get_db(): diff --git a/backend/main.py b/backend/main.py index cfd36a61..90008edd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,7 @@ import io from pathlib import Path import uuid -from . import database, models, profiles, history, tts, transcribe, config, export_import +from . import database, models, profiles, history, tts, transcribe, config, export_import, channels from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .utils.progress import get_progress_manager from .utils.tasks import get_task_manager @@ -292,6 +292,125 @@ async def export_profile( raise HTTPException(status_code=500, detail=str(e)) +# ============================================ +# AUDIO CHANNEL ENDPOINTS +# ============================================ + +@app.get("/channels", response_model=List[models.AudioChannelResponse]) +async def list_channels(db: Session = Depends(get_db)): + """List all audio channels.""" + return await channels.list_channels(db) + + +@app.post("/channels", response_model=models.AudioChannelResponse) +async def create_channel( + data: models.AudioChannelCreate, + db: Session = Depends(get_db), +): + """Create a new audio channel.""" + try: + return await channels.create_channel(data, db) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/channels/{channel_id}", response_model=models.AudioChannelResponse) +async def get_channel( + channel_id: str, + db: Session = Depends(get_db), +): + """Get an audio channel by ID.""" + channel = await channels.get_channel(channel_id, db) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + return channel + + +@app.put("/channels/{channel_id}", response_model=models.AudioChannelResponse) +async def update_channel( + channel_id: str, + data: models.AudioChannelUpdate, + db: Session = Depends(get_db), +): + """Update an audio channel.""" + try: + channel = await channels.update_channel(channel_id, data, db) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + return channel + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/channels/{channel_id}") +async def delete_channel( + channel_id: str, + db: Session = Depends(get_db), +): + """Delete an audio channel.""" + try: + success = await channels.delete_channel(channel_id, db) + if not success: + raise HTTPException(status_code=404, detail="Channel not found") + return {"message": "Channel deleted successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/channels/{channel_id}/voices") +async def get_channel_voices( + channel_id: str, + db: Session = Depends(get_db), +): + """Get list of profile IDs assigned to a channel.""" + try: + profile_ids = await channels.get_channel_voices(channel_id, db) + return {"profile_ids": profile_ids} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.put("/channels/{channel_id}/voices") +async def set_channel_voices( + channel_id: str, + data: models.ChannelVoiceAssignment, + db: Session = Depends(get_db), +): + """Set which voices are assigned to a channel.""" + try: + await channels.set_channel_voices(channel_id, data, db) + return {"message": "Channel voices updated successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/profiles/{profile_id}/channels") +async def get_profile_channels( + profile_id: str, + db: Session = Depends(get_db), +): + """Get list of channel IDs assigned to a profile.""" + try: + channel_ids = await channels.get_profile_channels(profile_id, db) + return {"channel_ids": channel_ids} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.put("/profiles/{profile_id}/channels") +async def set_profile_channels( + profile_id: str, + data: models.ProfileChannelAssignment, + db: Session = Depends(get_db), +): + """Set which channels a profile is assigned to.""" + try: + await channels.set_profile_channels(profile_id, data, db) + return {"message": "Profile channels updated successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # ============================================ # GENERATION ENDPOINTS # ============================================ diff --git a/backend/models.py b/backend/models.py index caeeebba..3c7af507 100644 --- a/backend/models.py +++ b/backend/models.py @@ -159,3 +159,37 @@ class ActiveTasksResponse(BaseModel): """Response model for active tasks.""" downloads: List[ActiveDownloadTask] generations: List[ActiveGenerationTask] + + +class AudioChannelCreate(BaseModel): + """Request model for creating an audio channel.""" + name: str = Field(..., min_length=1, max_length=100) + device_ids: List[str] = Field(default_factory=list) + + +class AudioChannelUpdate(BaseModel): + """Request model for updating an audio channel.""" + name: Optional[str] = Field(None, min_length=1, max_length=100) + device_ids: Optional[List[str]] = None + + +class AudioChannelResponse(BaseModel): + """Response model for audio channel.""" + id: str + name: str + is_default: bool + device_ids: List[str] + created_at: datetime + + class Config: + from_attributes = True + + +class ChannelVoiceAssignment(BaseModel): + """Request model for assigning voices to a channel.""" + profile_ids: List[str] + + +class ProfileChannelAssignment(BaseModel): + """Request model for assigning channels to a profile.""" + channel_ids: List[str] diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index cff60a87..343473b4 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ serde_json = "1.0" tokio = { version = "1", features = ["full"] } hound = "3.5" base64 = "0.22" +cpal = "0.15" +symphonia = { version = "0.5", features = ["wav", "pcm"] } [target.'cfg(target_os = "macos")'.dependencies] screencapturekit = { version = "1", features = ["async"] } diff --git a/tauri/src-tauri/src/audio_output.rs b/tauri/src-tauri/src/audio_output.rs new file mode 100644 index 00000000..defa294f --- /dev/null +++ b/tauri/src-tauri/src/audio_output.rs @@ -0,0 +1,357 @@ +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Device, Host, SampleFormat, StreamConfig}; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AudioOutputDevice { + pub id: String, + pub name: String, + pub is_default: bool, +} + +pub struct AudioOutputState { + host: Host, +} + +impl AudioOutputState { + pub fn new() -> Self { + Self { + host: cpal::default_host(), + } + } + + pub fn list_output_devices(&self) -> Result, String> { + let devices = self + .host + .output_devices() + .map_err(|e| format!("Failed to enumerate output devices: {}", e))?; + + let default_device = self.host.default_output_device(); + + let mut result = Vec::new(); + for device in devices { + let name = device + .name() + .map_err(|e| format!("Failed to get device name: {}", e))?; + + // Generate a stable ID from the device name (cpal doesn't provide stable IDs) + let id = format!("device_{}", name.replace(' ', "_").to_lowercase()); + + let is_default = default_device + .as_ref() + .map(|d| d.name().unwrap_or_default() == name) + .unwrap_or(false); + + result.push(AudioOutputDevice { + id, + name, + is_default, + }); + } + + Ok(result) + } + + pub async fn play_audio_to_devices( + &self, + audio_data: Vec, + device_ids: Vec, + ) -> Result<(), String> { + // Decode audio file (assuming WAV format) + let (samples, sample_rate, channels) = self.decode_wav(&audio_data)?; + + // Find devices by ID + let devices: Vec = self + .host + .output_devices() + .map_err(|e| format!("Failed to enumerate devices: {}", e))? + .filter_map(|device| { + let name = device.name().ok()?; + let id = format!("device_{}", name.replace(' ', "_").to_lowercase()); + if device_ids.contains(&id) { + Some(device) + } else { + None + } + }) + .collect(); + + if devices.is_empty() { + return Err("No matching devices found".to_string()); + } + + // Play to each device + for device in devices { + self.play_to_device(&device, samples.clone(), sample_rate, channels) + .map_err(|e| format!("Failed to play to device: {}", e))?; + } + + Ok(()) + } + + fn decode_wav(&self, data: &[u8]) -> Result<(Vec, u32, u16), String> { + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Probe; + + let mss = MediaSourceStream::new( + Box::new(std::io::Cursor::new(data)), + Default::default(), + ); + + let mut probe = Probe::default(); + let mut format = probe + .format( + &Default::default(), + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| format!("Failed to probe audio: {}", e))? + .format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or("No audio track found")?; + + let sample_rate = track + .codec_params + .sample_rate + .ok_or("No sample rate found")?; + + let channels = track + .codec_params + .channels + .ok_or("No channels found")? + .count() as u16; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &Default::default()) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + let mut samples = Vec::new(); + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(_) => break, // End of stream + }; + + let decoded = decoder + .decode(&packet) + .map_err(|e| format!("Decode error: {}", e))?; + + // Convert to f32 samples + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + + // Handle multi-channel audio + if spec.channels.count() == 1 { + // Mono + let plane = decoded.plane(0); + for i in 0..duration { + if let Some(&sample) = plane.get(i as usize) { + samples.push(sample as f32 / 32768.0); + } + } + } else { + // Multi-channel - interleave + for i in 0..duration { + for ch in 0..spec.channels.count() { + if let Some(plane) = decoded.plane(ch) { + if let Some(&sample) = plane.get(i as usize) { + samples.push(sample as f32 / 32768.0); + } + } + } + } + } + } + + Ok((samples, sample_rate, channels)) + } + + fn play_to_device( + &self, + device: &Device, + samples: Vec, + sample_rate: u32, + channels: u16, + ) -> Result<(), String> { + let config = device + .default_output_config() + .map_err(|e| format!("Failed to get default config: {}", e))?; + + // Prepare samples for the device's format + let device_sample_rate = config.sample_rate().0; + let device_channels = config.channels(); + + // Resample if needed (simple linear interpolation for now) + let resampled = if device_sample_rate != sample_rate { + self.resample(&samples, sample_rate, device_sample_rate) + } else { + samples + }; + + // Interleave/convert channels if needed + let interleaved = self.interleave_channels(&resampled, channels, device_channels); + + // Create shared buffer for playback + let buffer: Arc>> = Arc::new(Mutex::new(interleaved)); + let position = Arc::new(AtomicUsize::new(0)); + let buffer_clone = buffer.clone(); + let position_clone = position.clone(); + + let err_fn = |err| eprintln!("Playback error: {}", err); + + let stream_config = StreamConfig { + channels: device_channels, + sample_rate: cpal::SampleRate(device_sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let stream = match config.sample_format() { + SampleFormat::F32 => { + let buffer = buffer_clone.clone(); + let pos = position_clone.clone(); + device + .build_output_stream( + &stream_config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut idx = pos.load(Ordering::Relaxed); + let buf = buffer.lock().unwrap(); + for sample in data.iter_mut() { + if idx < buf.len() { + *sample = buf[idx]; + idx += 1; + } else { + *sample = 0.0; + } + } + pos.store(idx, Ordering::Relaxed); + }, + err_fn, + None, + ) + .map_err(|e| format!("Failed to build stream: {}", e))? + } + SampleFormat::I16 => { + let buffer = buffer_clone.clone(); + let pos = position_clone.clone(); + device + .build_output_stream( + &stream_config, + move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + let mut idx = pos.load(Ordering::Relaxed); + let buf = buffer.lock().unwrap(); + for sample in data.iter_mut() { + if idx < buf.len() { + *sample = (buf[idx] * 32767.0) as i16; + idx += 1; + } else { + *sample = 0; + } + } + pos.store(idx, Ordering::Relaxed); + }, + err_fn, + None, + ) + .map_err(|e| format!("Failed to build stream: {}", e))? + } + SampleFormat::U16 => { + let buffer = buffer_clone.clone(); + let pos = position_clone.clone(); + device + .build_output_stream( + &stream_config, + move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + let mut idx = pos.load(Ordering::Relaxed); + let buf = buffer.lock().unwrap(); + for sample in data.iter_mut() { + if idx < buf.len() { + *sample = ((buf[idx] + 1.0) * 32767.5) as u16; + idx += 1; + } else { + *sample = 32768; + } + } + pos.store(idx, Ordering::Relaxed); + }, + err_fn, + None, + ) + .map_err(|e| format!("Failed to build stream: {}", e))? + } + _ => return Err("Unsupported sample format".to_string()), + }; + + stream.play().map_err(|e| format!("Failed to play stream: {}", e))?; + + // Keep stream alive until playback completes + // In a real implementation, we'd track this and clean up when done + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); // Max 30s + }); + + Ok(()) + } + + fn resample(&self, samples: &[f32], from_rate: u32, to_rate: u32) -> Vec { + if from_rate == to_rate { + return samples.to_vec(); + } + + let ratio = to_rate as f64 / from_rate as f64; + let new_len = (samples.len() as f64 * ratio) as usize; + let mut resampled = Vec::with_capacity(new_len); + + for i in 0..new_len { + let src_idx = (i as f64 / ratio) as usize; + if src_idx < samples.len() { + resampled.push(samples[src_idx]); + } else { + resampled.push(0.0); + } + } + + resampled + } + + fn interleave_channels( + &self, + samples: &[f32], + src_channels: u16, + dst_channels: u16, + ) -> Vec { + if src_channels == dst_channels { + return samples.to_vec(); + } + + let mut interleaved = Vec::new(); + let samples_per_channel = samples.len() / src_channels as usize; + + for i in 0..samples_per_channel { + for ch in 0..dst_channels { + let src_ch = if ch < src_channels { ch } else { src_channels - 1 }; + let idx = (i * src_channels as usize) + src_ch as usize; + if idx < samples.len() { + interleaved.push(samples[idx]); + } else { + interleaved.push(0.0); + } + } + } + + interleaved + } +} + +impl Default for AudioOutputState { + fn default() -> Self { + Self::new() + } +} diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index f44300c5..272aea06 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod audio_capture; +mod audio_output; use std::sync::Mutex; use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent}; @@ -368,6 +369,22 @@ fn is_system_audio_supported() -> bool { audio_capture::is_supported() } +#[command] +fn list_audio_output_devices( + state: State<'_, audio_output::AudioOutputState>, +) -> Result, String> { + state.list_output_devices() +} + +#[command] +async fn play_audio_to_devices( + state: State<'_, audio_output::AudioOutputState>, + audio_data: Vec, + device_ids: Vec, +) -> Result<(), String> { + state.play_audio_to_devices(audio_data, device_ids).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -380,6 +397,7 @@ pub fn run() { keep_running_on_close: Mutex::new(false), }) .manage(audio_capture::AudioCaptureState::new()) + .manage(audio_output::AudioOutputState::new()) .setup(|app| { #[cfg(desktop)] app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; @@ -420,7 +438,9 @@ pub fn run() { set_keep_server_running, start_system_audio_capture, stop_system_audio_capture, - is_system_audio_supported + is_system_audio_supported, + list_audio_output_devices, + play_audio_to_devices ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { From 7f18c096284ab1ce24fee9b8507a5d84d7e3d12b Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 16:15:16 -0800 Subject: [PATCH 2/8] 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. --- .../components/AudioPlayer/AudioPlayer.tsx | 117 +++++- .../Generation/FloatingGenerateBox.tsx | 2 +- app/src/components/VoicesTab/VoicesTab.tsx | 33 +- app/src/components/ui/multi-select.tsx | 102 +++++ tauri/src-tauri/Cargo.lock | 388 +++++++++++++++++- tauri/src-tauri/Cargo.toml | 2 +- tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes tauri/src-tauri/src/audio_output.rs | 154 +++++-- 8 files changed, 725 insertions(+), 73 deletions(-) create mode 100644 app/src/components/ui/multi-select.tsx diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index b8af49d6..22aa6230 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -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(null); const wavesurferRef = useRef(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); diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index e0f1daf4..1cf11c83 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -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); diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 60052f25..63a67c45 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -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({ {generationCount} {samples?.length || 0} - + onChange={onChannelChange} + placeholder="Select channels..." + className="min-w-[200px]" + /> diff --git a/app/src/components/ui/multi-select.tsx b/app/src/components/ui/multi-select.tsx new file mode 100644 index 00000000..0f3ffb5e --- /dev/null +++ b/app/src/components/ui/multi-select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +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 ( + + + + + e.preventDefault()} + > + {options.map((option) => ( + handleSelect(option.value)} + onCheckedChange={() => handleSelect(option.value)} + > + {option.label} + + ))} + + + ); +} diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 44ae0a52..5bba547c 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -56,6 +78,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atk" version = "0.18.2" @@ -276,6 +304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -416,6 +446,17 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + [[package]] name = "coreaudio-sys" version = "0.2.17" @@ -425,6 +466,29 @@ dependencies = [ "bindgen", ] +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -540,6 +604,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "deranged" version = "0.5.5" @@ -755,6 +825,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1653,6 +1729,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1814,6 +1900,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1929,6 +2024,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1938,7 +2047,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -1950,6 +2059,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1987,6 +2105,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2260,6 +2389,29 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3448,7 +3600,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3542,6 +3694,201 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -3618,9 +3965,9 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc2", "objc2-app-kit", "objc2-foundation", @@ -4498,12 +4845,14 @@ dependencies = [ "base64 0.22.1", "core-foundation-sys", "coreaudio-sys", + "cpal", "hound", "objc", "scopeguard", "screencapturekit", "serde", "serde_json", + "symphonia", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4816,6 +5165,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4859,6 +5218,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4961,6 +5330,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5316,7 +5694,7 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-core-foundation", diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index ab893880..07cc32fb 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tokio = { version = "1", features = ["full"] } hound = "3.5" base64 = "0.22" cpal = "0.15" -symphonia = { version = "0.5", features = ["wav", "pcm"] } +symphonia = { version = "0.5", features = ["all"] } scopeguard = "1.2.0" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 6265d4db938d981f0d9b35ea8a41ba6cf643fe52..dae2dd8301b6f706e8eb3b293140c97b33f9c44d 100644 GIT binary patch delta 752 zcmZwEyKYo55C&jvLSVUH17r!1aKBCV_%`;?oY=><@(6hYIz&mIVz;HBgFYn^#o5Q; zEm%=dAfdGU(Q2#3(pUN)&+qBaUxO)p`@)02-@o#owbpZPM4C#r=1kUXPLC_BZA^`nxcA}xyHC5Lqq$7xGJrK$hcmDNXW?8wmtomxbBr9KYba+Q z$d+s=Ii1Nw!%2yYY?{?Mp;JmN=4|)VcKCU{WywBiTS!!lWK(A-u`bkc=v=HWI$KwR zBxTFXppV^fbI>|hwc;448gIyIHY98`S#Auoa82Pqn~T>zFN5ZJxV7FYE2hRH~aZ(G=s07&FIIsFXqomSBte)2T!#XmXvaWyqA$&NReYG zA-9sG)U1h5j*cmomWyHDKG=W%ackpn;in5ff)yCU8CZq0aIRnYrmS|XYfiZ)^0^99 zwmOPIJ90uvQ68Ocs$;N|k&nj*4j=TT`82^`C(y zvs(szNX^=)v&!WfjV5bzAXQ=^-!gKGl*x!FjB)o{pS5G154_Vj6Yr+d2%oQDf=5iY@HxB^$<8eE4Pa1(C98r+6EaFVeO z8*mRc;XXWohwuot;4wUbr|=A(!wYx`ui$mRxBKS!xYNldFRCQA$&ji>QgkfInzm>c zgAkDik7`Y6Lq6S$>Cw(xAlQZpFti{*f&$$i?U?>|SA(b_Cr4ImEJzfYVhM(F#!@B~ bO>$*eC~PTuSva5A>3rhA_w&iS?}vW@u%*>v diff --git a/tauri/src-tauri/src/audio_output.rs b/tauri/src-tauri/src/audio_output.rs index defa294f..ef263da9 100644 --- a/tauri/src-tauri/src/audio_output.rs +++ b/tauri/src-tauri/src/audio_output.rs @@ -58,10 +58,16 @@ impl AudioOutputState { audio_data: Vec, device_ids: Vec, ) -> Result<(), String> { + eprintln!("play_audio_to_devices called with {} bytes, {} device IDs", audio_data.len(), device_ids.len()); + eprintln!("Requested device IDs: {:?}", device_ids); + // Decode audio file (assuming WAV format) + eprintln!("Decoding audio data..."); let (samples, sample_rate, channels) = self.decode_wav(&audio_data)?; + eprintln!("Audio decoded: {} samples, {}Hz, {} channels", samples.len(), sample_rate, channels); // Find devices by ID + eprintln!("Enumerating output devices..."); let devices: Vec = self .host .output_devices() @@ -69,7 +75,9 @@ impl AudioOutputState { .filter_map(|device| { let name = device.name().ok()?; let id = format!("device_{}", name.replace(' ', "_").to_lowercase()); + eprintln!("Found device: {} (id: {})", name, id); if device_ids.contains(&id) { + eprintln!(" -> Matched! Will play to this device"); Some(device) } else { None @@ -78,15 +86,21 @@ impl AudioOutputState { .collect(); if devices.is_empty() { + eprintln!("ERROR: No matching devices found"); return Err("No matching devices found".to_string()); } + eprintln!("Playing to {} device(s)", devices.len()); // Play to each device - for device in devices { - self.play_to_device(&device, samples.clone(), sample_rate, channels) - .map_err(|e| format!("Failed to play to device: {}", e))?; + for (i, device) in devices.iter().enumerate() { + let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); + eprintln!("Playing to device {}/{}: {}", i + 1, devices.len(), device_name); + self.play_to_device(device, samples.clone(), sample_rate, channels) + .map_err(|e| format!("Failed to play to device {}: {}", device_name, e))?; + eprintln!("Successfully started playback on device: {}", device_name); } + eprintln!("play_audio_to_devices completed successfully"); Ok(()) } @@ -94,83 +108,120 @@ impl AudioOutputState { use symphonia::core::formats::FormatOptions; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; - use symphonia::core::probe::Probe; + eprintln!("decode_wav: Creating MediaSourceStream from {} bytes", data.len()); let mss = MediaSourceStream::new( - Box::new(std::io::Cursor::new(data)), + Box::new(std::io::Cursor::new(data.to_vec())), Default::default(), ); - let mut probe = Probe::default(); - let mut format = probe + eprintln!("decode_wav: Probing audio format..."); + let mut format = symphonia::default::get_probe() .format( &Default::default(), mss, &FormatOptions::default(), &MetadataOptions::default(), ) - .map_err(|e| format!("Failed to probe audio: {}", e))? + .map_err(|e| { + eprintln!("decode_wav: Failed to probe audio: {}", e); + format!("Failed to probe audio: {}", e) + })? .format; + + eprintln!("decode_wav: Audio format probed successfully"); + eprintln!("decode_wav: Finding audio track..."); let track = format .tracks() .iter() .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) - .ok_or("No audio track found")?; + .ok_or_else(|| { + eprintln!("decode_wav: No audio track found"); + "No audio track found".to_string() + })?; let sample_rate = track .codec_params .sample_rate - .ok_or("No sample rate found")?; + .ok_or_else(|| { + eprintln!("decode_wav: No sample rate found in track"); + "No sample rate found".to_string() + })?; let channels = track .codec_params .channels - .ok_or("No channels found")? + .ok_or_else(|| { + eprintln!("decode_wav: No channels found in track"); + "No channels found".to_string() + })? .count() as u16; + eprintln!("decode_wav: Track info - sample_rate: {}, channels: {}", sample_rate, channels); + + eprintln!("decode_wav: Creating decoder..."); let mut decoder = symphonia::default::get_codecs() .make(&track.codec_params, &Default::default()) - .map_err(|e| format!("Failed to create decoder: {}", e))?; + .map_err(|e| { + eprintln!("decode_wav: Failed to create decoder: {}", e); + format!("Failed to create decoder: {}", e) + })?; + + eprintln!("decode_wav: Decoder created successfully"); let mut samples = Vec::new(); + let mut packet_count = 0; + eprintln!("decode_wav: Starting packet decoding loop..."); loop { let packet = match format.next_packet() { Ok(packet) => packet, - Err(_) => break, // End of stream + Err(e) => { + eprintln!("decode_wav: End of stream or error: {:?}", e); + break; + } }; + packet_count += 1; let decoded = decoder .decode(&packet) - .map_err(|e| format!("Decode error: {}", e))?; + .map_err(|e| { + eprintln!("decode_wav: Decode error on packet {}: {}", packet_count, e); + format!("Decode error: {}", e) + })?; + + // Convert to f32 samples by matching on the buffer type + use symphonia::core::audio::{AudioBufferRef, Signal}; + use symphonia::core::conv::FromSample; - // Convert to f32 samples let spec = *decoded.spec(); - let duration = decoded.capacity() as u64; + let num_channels = spec.channels.count(); + let num_frames = decoded.frames(); - // Handle multi-channel audio - if spec.channels.count() == 1 { - // Mono - let plane = decoded.plane(0); - for i in 0..duration { - if let Some(&sample) = plane.get(i as usize) { - samples.push(sample as f32 / 32768.0); - } - } - } else { - // Multi-channel - interleave - for i in 0..duration { - for ch in 0..spec.channels.count() { - if let Some(plane) = decoded.plane(ch) { - if let Some(&sample) = plane.get(i as usize) { - samples.push(sample as f32 / 32768.0); - } - } - } + eprintln!("decode_wav: Packet {} - {} frames, {} channels", packet_count, num_frames, num_channels); + + // Interleave samples from all channels + for frame_idx in 0..num_frames { + for ch in 0..num_channels { + let sample_f32 = match &decoded { + AudioBufferRef::U8(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U16(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U24(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U32(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S8(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S16(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S24(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S32(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::F32(buf) => buf.chan(ch)[frame_idx], + AudioBufferRef::F64(buf) => buf.chan(ch)[frame_idx] as f32, + }; + samples.push(sample_f32); } } } + eprintln!("decode_wav: Decoded {} packets, total {} samples", packet_count, samples.len()); + eprintln!("decode_wav: Returning sample_rate={}, channels={}", sample_rate, channels); Ok((samples, sample_rate, channels)) } @@ -181,6 +232,10 @@ impl AudioOutputState { sample_rate: u32, channels: u16, ) -> Result<(), String> { + let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); + eprintln!("play_to_device: Starting playback to device: {}", device_name); + eprintln!("play_to_device: Input - {} samples, {}Hz, {} channels", samples.len(), sample_rate, channels); + let config = device .default_output_config() .map_err(|e| format!("Failed to get default config: {}", e))?; @@ -188,16 +243,29 @@ impl AudioOutputState { // Prepare samples for the device's format let device_sample_rate = config.sample_rate().0; let device_channels = config.channels(); + let device_sample_format = config.sample_format(); + + eprintln!("play_to_device: Device config - {}Hz, {} channels, format: {:?}", + device_sample_rate, device_channels, device_sample_format); // Resample if needed (simple linear interpolation for now) let resampled = if device_sample_rate != sample_rate { - self.resample(&samples, sample_rate, device_sample_rate) + eprintln!("play_to_device: Resampling from {}Hz to {}Hz", sample_rate, device_sample_rate); + let result = self.resample(&samples, sample_rate, device_sample_rate); + eprintln!("play_to_device: Resampled {} samples to {} samples", samples.len(), result.len()); + result } else { + eprintln!("play_to_device: No resampling needed"); samples }; // Interleave/convert channels if needed + eprintln!("play_to_device: Interleaving channels from {} to {} channels", channels, device_channels); let interleaved = self.interleave_channels(&resampled, channels, device_channels); + eprintln!("play_to_device: Interleaved to {} samples", interleaved.len()); + + // Calculate duration before moving interleaved + let duration_secs = (interleaved.len() as f64 / (device_sample_rate as f64 * device_channels as f64)).ceil() as u64 + 1; // Create shared buffer for playback let buffer: Arc>> = Arc::new(Mutex::new(interleaved)); @@ -289,14 +357,24 @@ impl AudioOutputState { _ => return Err("Unsupported sample format".to_string()), }; - stream.play().map_err(|e| format!("Failed to play stream: {}", e))?; + eprintln!("play_to_device: Starting stream playback..."); + stream.play().map_err(|e| { + eprintln!("play_to_device: Failed to play stream: {}", e); + format!("Failed to play stream: {}", e) + })?; + + eprintln!("play_to_device: Stream started successfully"); // Keep stream alive until playback completes // In a real implementation, we'd track this and clean up when done + eprintln!("play_to_device: Keeping stream alive for ~{} seconds", duration_secs.min(30)); std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); // Max 30s + let sleep_duration = std::time::Duration::from_secs(duration_secs.min(30)); + std::thread::sleep(sleep_duration); + eprintln!("play_to_device: Stream sleep completed, stream will be dropped"); }); + eprintln!("play_to_device: Function completed successfully"); Ok(()) } From f7cb219f6d0d122925b34c22a943fb90f29490c5 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 16:25:38 -0800 Subject: [PATCH 3/8] Refactor AudioPlayer for improved native playback handling and debugging - Introduced a stop flag mechanism to manage audio playback more effectively. - Enhanced native playback logic to ensure proper stopping of existing streams before starting new playback. - Updated error handling and logging for better visibility during playback operations. - Refactored audio output handling in Rust to support stopping playback and outputting silence when required. - Improved the integration of native playback with WaveSurfer for seamless audio visualization. --- .../components/AudioPlayer/AudioPlayer.tsx | 292 +++++++++++++----- tauri/src-tauri/src/audio_output.rs | 57 +++- tauri/src-tauri/src/main.rs | 10 +- 3 files changed, 274 insertions(+), 85 deletions(-) diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 22aa6230..185a232f 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,14 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api/core'; import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react'; -import { useEffect, useRef, useState, useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; -import { formatAudioDuration } from '@/lib/utils/audio'; -import { usePlayerStore } from '@/stores/playerStore'; -import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { isTauri } from '@/lib/tauri'; -import { invoke } from '@tauri-apps/api/core'; +import { formatAudioDuration } from '@/lib/utils/audio'; +import { usePlayerStore } from '@/stores/playerStore'; export function AudioPlayer() { const { @@ -54,23 +54,21 @@ export function AudioPlayer() { 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), - ); - + + 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 const shouldUseNative = assignedChannels.some( (ch) => ch.device_ids.length > 0 && !ch.is_default, ); - + console.log('useNativePlayback result:', shouldUseNative); return shouldUseNative; }, [profileChannels, channels, profileId]); @@ -80,6 +78,7 @@ export function AudioPlayer() { const loadingRef = useRef(false); const previousAudioIdRef = useRef(null); const hasInitializedRef = useRef(false); + const isUsingNativePlaybackRef = useRef(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -186,8 +185,9 @@ export function AudioPlayer() { wavesurfer.setVolume(currentVolume); // Get the underlying audio element and ensure it's not muted + // (unless we're using native playback, which will be set later) const mediaElement = wavesurfer.getMediaElement(); - if (mediaElement) { + if (mediaElement && !isUsingNativePlaybackRef.current) { mediaElement.volume = currentVolume; mediaElement.muted = false; console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted); @@ -197,18 +197,18 @@ export function AudioPlayer() { // 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); @@ -217,7 +217,7 @@ export function AudioPlayer() { console.error('Failed to fetch runtime channel data:', error); } } - + console.log('Auto-play check:', { isTauri: isTauri(), currentAudioUrl, @@ -225,60 +225,125 @@ export function AudioPlayer() { hasProfileChannels: !!runtimeProfileChannels, hasChannels: !!runtimeChannels, }); - - if (isTauri() && currentAudioUrl && currentProfileId && runtimeProfileChannels && runtimeChannels) { + + if ( + isTauri() && + currentAudioUrl && + currentProfileId && + runtimeProfileChannels && + runtimeChannels + ) { console.log('Attempting native audio playback...'); + + // Stop any existing native playback first + if (isUsingNativePlaybackRef.current) { + try { + await invoke('stop_audio_playback'); + console.log('Stopped existing native playback before starting new one'); + } catch (error) { + console.error('Failed to stop existing playback:', error); + } + } + 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'); + // Reset native playback flag and unmute WaveSurfer + isUsingNativePlaybackRef.current = false; + const mediaElement = wavesurfer.getMediaElement(); + if (mediaElement) { + const currentVolume = usePlayerStore.getState().volume; + mediaElement.volume = currentVolume; + mediaElement.muted = false; + console.log('WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + } } 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); + 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; - } + // 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); + + // Mark that we're using native playback + isUsingNativePlaybackRef.current = true; + + // Mute WaveSurfer's audio element to prevent UI audio output + // Keep WaveSurfer running for visualization + const mediaElement = wavesurfer.getMediaElement(); + if (mediaElement) { + mediaElement.volume = 0; + mediaElement.muted = true; + console.log('WaveSurfer muted for native playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + } + + // Start WaveSurfer playback for visualization (muted) + wavesurfer.play().catch((error) => { + console.error('Failed to start WaveSurfer visualization:', error); + }); + + 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); + console.error( + 'Native playback failed during auto-play, falling back to WaveSurfer:', + error, + ); + // Reset native playback flag and unmute WaveSurfer + isUsingNativePlaybackRef.current = false; + const mediaElement = wavesurfer.getMediaElement(); + if (mediaElement) { + const currentVolume = usePlayerStore.getState().volume; + mediaElement.volume = currentVolume; + mediaElement.muted = false; + console.log('WaveSurfer unmuted after native playback failure - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + } // Fall through to WaveSurfer playback } } else { console.log('Not using native playback, using WaveSurfer'); + // Reset native playback flag and unmute WaveSurfer + isUsingNativePlaybackRef.current = false; + const mediaElement = wavesurfer.getMediaElement(); + if (mediaElement) { + const currentVolume = usePlayerStore.getState().volume; + mediaElement.volume = currentVolume; + mediaElement.muted = false; + console.log('WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + } } // Standard WaveSurfer auto-play @@ -294,13 +359,22 @@ export function AudioPlayer() { // Handle play/pause wavesurfer.on('play', () => { setIsPlaying(true); - // Ensure audio element is not muted when playing + // Ensure audio element volume is set correctly const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { - mediaElement.muted = false; - const currentVolume = usePlayerStore.getState().volume; - mediaElement.volume = currentVolume; - console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + // Double-check: if using native playback, keep WaveSurfer muted + // Otherwise, ensure it's unmuted + if (isUsingNativePlaybackRef.current) { + mediaElement.volume = 0; + mediaElement.muted = true; + console.log('Playing (native mode) - WaveSurfer muted for visualization only'); + } else { + // Ensure WaveSurfer is unmuted for normal playback + const currentVolume = usePlayerStore.getState().volume; + mediaElement.volume = currentVolume; + mediaElement.muted = false; + console.log('Playing (normal mode) - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + } } }); wavesurfer.on('pause', () => setIsPlaying(false)); @@ -406,10 +480,35 @@ export function AudioPlayer() { setDuration(0); setCurrentTime(0); setError(null); + // Reset native playback flag + isUsingNativePlaybackRef.current = false; } return; } + // Stop native playback if it was active + if (isUsingNativePlaybackRef.current && isTauri()) { + (async () => { + try { + await invoke('stop_audio_playback'); + console.log('Stopped native audio playback'); + } catch (error) { + console.error('Failed to stop native playback:', error); + } + })(); + } + + // Reset native playback flag when loading new audio + // Also unmute WaveSurfer if it was muted + if (isUsingNativePlaybackRef.current) { + const mediaElement = wavesurfer.getMediaElement(); + if (mediaElement) { + mediaElement.muted = false; + mediaElement.volume = usePlayerStore.getState().volume; + } + } + isUsingNativePlaybackRef.current = false; + // CRITICAL: Force stop any current playback and cancel any pending loads // This must happen BEFORE any early returns console.log('Audio URL changed to:', audioUrl); @@ -490,9 +589,16 @@ export function AudioPlayer() { // Also ensure the underlying audio element volume is set const mediaElement = wavesurferRef.current.getMediaElement(); if (mediaElement) { - mediaElement.volume = volume; - mediaElement.muted = volume === 0; - console.log('Volume synced:', volume, 'muted:', mediaElement.muted); + // If using native playback, keep WaveSurfer muted regardless of volume setting + if (isUsingNativePlaybackRef.current) { + mediaElement.volume = 0; + mediaElement.muted = true; + console.log('Volume sync: Using native playback, keeping WaveSurfer muted'); + } else { + mediaElement.volume = volume; + mediaElement.muted = volume === 0; + console.log('Volume synced:', volume, 'muted:', mediaElement.muted); + } } } }, [volume]); @@ -526,7 +632,7 @@ export function AudioPlayer() { setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); - + // Clear the restart flag clearRestartFlag(); }, [shouldRestart, duration, setIsPlaying, clearRestartFlag]); @@ -534,16 +640,44 @@ export function AudioPlayer() { // Handle loop - WaveSurfer handles this via the 'finish' event const handlePlayPause = async () => { - // If using native playback, handle differently + // Standard WaveSurfer playback (works for both normal and native playback modes) + // When using native playback, WaveSurfer is muted but still controls visualization + if (!wavesurferRef.current) { + console.error('WaveSurfer not initialized'); + return; + } + + // Check if audio is loaded + if (duration === 0 && !isLoading) { + console.error('Audio not loaded yet'); + setError('Audio not loaded. Please wait...'); + return; + } + + // If using native playback if (useNativePlayback && audioUrl && profileChannels && channels) { if (isPlaying) { - // For native playback, we'd need to track and stop streams - // For now, just toggle the state - setIsPlaying(false); + // Pause: stop native playback and pause WaveSurfer visualization + try { + await invoke('stop_audio_playback'); + console.log('Stopped native audio playback'); + } catch (error) { + console.error('Failed to stop native playback:', error); + } + wavesurferRef.current.pause(); return; } - + + // Play: trigger native playback try { + // Stop any existing native playback first + try { + await invoke('stop_audio_playback'); + } catch (error) { + // Ignore errors when stopping (might not be playing) + console.log('No existing playback to stop'); + } + // Collect all device IDs from assigned channels const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id), @@ -561,31 +695,45 @@ export function AudioPlayer() { deviceIds: deviceIds, }); - setIsPlaying(true); + // Mark that we're using native playback + isUsingNativePlaybackRef.current = true; + + // Mute WaveSurfer and start it for visualization + const mediaElement = wavesurferRef.current.getMediaElement(); + if (mediaElement) { + mediaElement.volume = 0; + mediaElement.muted = true; + } + + // Start WaveSurfer for visualization (muted) + wavesurferRef.current.play().catch((error) => { + console.error('Failed to start WaveSurfer visualization:', error); + setIsPlaying(false); + setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); + }); + return; } } catch (error) { console.error('Native playback failed, falling back to WaveSurfer:', error); // Fall through to WaveSurfer playback + isUsingNativePlaybackRef.current = false; } } - // Standard WaveSurfer playback - if (!wavesurferRef.current) { - console.error('WaveSurfer not initialized'); - return; - } - - // Check if audio is loaded - if (duration === 0 && !isLoading) { - console.error('Audio not loaded yet'); - setError('Audio not loaded. Please wait...'); - return; - } - + // Standard WaveSurfer playback (or fallback from native playback failure) if (wavesurferRef.current.isPlaying()) { wavesurferRef.current.pause(); } else { + // Ensure WaveSurfer is not muted if not using native playback + if (!isUsingNativePlaybackRef.current) { + const mediaElement = wavesurferRef.current.getMediaElement(); + if (mediaElement) { + mediaElement.muted = false; + mediaElement.volume = volume; + } + } + wavesurferRef.current.play().catch((error) => { console.error('Failed to play:', error); setIsPlaying(false); diff --git a/tauri/src-tauri/src/audio_output.rs b/tauri/src-tauri/src/audio_output.rs index ef263da9..84cc101c 100644 --- a/tauri/src-tauri/src/audio_output.rs +++ b/tauri/src-tauri/src/audio_output.rs @@ -1,7 +1,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use cpal::{Device, Host, SampleFormat, StreamConfig}; +use cpal::{Device, Host, SampleFormat, Stream, StreamConfig}; use std::sync::{Arc, Mutex}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[derive(Debug, Clone, serde::Serialize)] pub struct AudioOutputDevice { @@ -12,15 +12,24 @@ pub struct AudioOutputDevice { pub struct AudioOutputState { host: Host, + stop_flag: Arc, } impl AudioOutputState { pub fn new() -> Self { Self { host: cpal::default_host(), + stop_flag: Arc::new(AtomicBool::new(false)), } } + pub fn stop_all_playback(&self) -> Result<(), String> { + eprintln!("stop_all_playback: Setting stop flag"); + self.stop_flag.store(true, Ordering::Relaxed); + eprintln!("stop_all_playback: Stop flag set - active streams will output silence"); + Ok(()) + } + pub fn list_output_devices(&self) -> Result, String> { let devices = self .host @@ -91,11 +100,18 @@ impl AudioOutputState { } eprintln!("Playing to {} device(s)", devices.len()); + + // Stop any existing playback first + self.stop_all_playback().ok(); + + // Reset stop flag for new playback + self.stop_flag.store(false, Ordering::Relaxed); + // Play to each device for (i, device) in devices.iter().enumerate() { let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); eprintln!("Playing to device {}/{}: {}", i + 1, devices.len(), device_name); - self.play_to_device(device, samples.clone(), sample_rate, channels) + self.play_to_device(device, samples.clone(), sample_rate, channels, self.stop_flag.clone()) .map_err(|e| format!("Failed to play to device {}: {}", device_name, e))?; eprintln!("Successfully started playback on device: {}", device_name); } @@ -231,6 +247,7 @@ impl AudioOutputState { samples: Vec, sample_rate: u32, channels: u16, + stop_flag: Arc, ) -> Result<(), String> { let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); eprintln!("play_to_device: Starting playback to device: {}", device_name); @@ -281,6 +298,7 @@ impl AudioOutputState { buffer_size: cpal::BufferSize::Default, }; + let stop_flag_clone = stop_flag.clone(); let stream = match config.sample_format() { SampleFormat::F32 => { let buffer = buffer_clone.clone(); @@ -289,6 +307,14 @@ impl AudioOutputState { .build_output_stream( &stream_config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Check stop flag - if set, output silence + if stop_flag_clone.load(Ordering::Relaxed) { + for sample in data.iter_mut() { + *sample = 0.0; + } + return; + } + let mut idx = pos.load(Ordering::Relaxed); let buf = buffer.lock().unwrap(); for sample in data.iter_mut() { @@ -313,6 +339,14 @@ impl AudioOutputState { .build_output_stream( &stream_config, move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + // Check stop flag - if set, output silence + if stop_flag_clone.load(Ordering::Relaxed) { + for sample in data.iter_mut() { + *sample = 0; + } + return; + } + let mut idx = pos.load(Ordering::Relaxed); let buf = buffer.lock().unwrap(); for sample in data.iter_mut() { @@ -337,6 +371,14 @@ impl AudioOutputState { .build_output_stream( &stream_config, move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + // Check stop flag - if set, output silence + if stop_flag_clone.load(Ordering::Relaxed) { + for sample in data.iter_mut() { + *sample = 32768; + } + return; + } + let mut idx = pos.load(Ordering::Relaxed); let buf = buffer.lock().unwrap(); for sample in data.iter_mut() { @@ -365,15 +407,6 @@ impl AudioOutputState { eprintln!("play_to_device: Stream started successfully"); - // Keep stream alive until playback completes - // In a real implementation, we'd track this and clean up when done - eprintln!("play_to_device: Keeping stream alive for ~{} seconds", duration_secs.min(30)); - std::thread::spawn(move || { - let sleep_duration = std::time::Duration::from_secs(duration_secs.min(30)); - std::thread::sleep(sleep_duration); - eprintln!("play_to_device: Stream sleep completed, stream will be dropped"); - }); - eprintln!("play_to_device: Function completed successfully"); Ok(()) } diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index afaa99d1..c93cae79 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -385,6 +385,13 @@ async fn play_audio_to_devices( state.play_audio_to_devices(audio_data, device_ids).await } +#[command] +fn stop_audio_playback( + state: State<'_, audio_output::AudioOutputState>, +) -> Result<(), String> { + state.stop_all_playback() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -443,7 +450,8 @@ pub fn run() { stop_system_audio_capture, is_system_audio_supported, list_audio_output_devices, - play_audio_to_devices + play_audio_to_devices, + stop_audio_playback ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { From d8d9eeaa6a3e2a874fc73503ebf3200e52424b9e Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 16:38:37 -0800 Subject: [PATCH 4/8] Refactor App layout and introduce new components for improved organization - Replaced the main layout in App component with AppFrame for better structure. - Introduced MainEditor component to encapsulate the main editing interface, including ProfileList and HistoryTable. - Added ModelsTab component to manage model-related functionalities. - Updated Sidebar to include a new Models tab for navigation. - Removed unused components and streamlined the layout for enhanced user experience. --- app/src/App.tsx | 48 ++------- app/src/components/AppFrame/AppFrame.tsx | 16 +++ .../components/AudioPlayer/AudioPlayer.tsx | 93 ++++++++++++++---- app/src/components/History/HistoryTable.tsx | 30 ++---- app/src/components/MainEditor/MainEditor.tsx | 34 +++++++ app/src/components/ModelsTab/ModelsTab.tsx | 9 ++ app/src/components/ServerTab/ServerTab.tsx | 2 - app/src/components/Sidebar.tsx | 3 +- tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes 9 files changed, 150 insertions(+), 85 deletions(-) create mode 100644 app/src/components/AppFrame/AppFrame.tsx create mode 100644 app/src/components/MainEditor/MainEditor.tsx create mode 100644 app/src/components/ModelsTab/ModelsTab.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 8168494a..c4cd85b7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,17 +1,16 @@ import { useEffect, useState } from 'react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; -import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; +import { AppFrame } from '@/components/AppFrame/AppFrame'; +import { AudioTab } from '@/components/AudioTab/AudioTab'; +import { MainEditor } from '@/components/MainEditor/MainEditor'; +import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; +import { ServerTab } from '@/components/ServerTab/ServerTab'; // import { GenerationForm } from '@/components/Generation/GenerationForm'; -import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; -import { HistoryTable } from '@/components/History/HistoryTable'; import ShinyText from '@/components/ShinyText'; import { Sidebar } from '@/components/Sidebar'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { Toaster } from '@/components/ui/toaster'; -import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { VoicesTab } from '@/components/VoicesTab/VoicesTab'; -import { AudioTab } from '@/components/AudioTab/AudioTab'; -import { ServerTab } from '@/components/ServerTab/ServerTab'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks'; import { @@ -21,7 +20,6 @@ import { setupWindowCloseHandler, startServer, } from '@/lib/tauri'; -import { usePlayerStore } from '@/stores/playerStore'; import { useServerStore } from '@/stores/serverStore'; // Track if server is starting to prevent duplicate starts @@ -54,7 +52,6 @@ function App() { const [activeTab, setActiveTab] = useState('main'); const [serverReady, setServerReady] = useState(false); const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); - const audioUrl = usePlayerStore((state) => state.audioUrl); // Monitor active downloads/generations and show toasts for them const activeDownloads = useRestoreActiveTasks(); @@ -169,48 +166,21 @@ function App() { } return ( -
- +
- {activeTab === 'main' && ( - // Main view: Profiles top left, Generator bottom left, History right -
- {/* Left Column */} -
- {/* Profiles - Top Left */} -
- -
- - {/* Generator - Bottom Left */} - {/*
- -
*/} -
- - {/* Right Column - History */} -
- -
- - {/* Floating Generate Box */} - -
- )} + {activeTab === 'main' && } {activeTab === 'voices' && } {activeTab === 'audio' && } {activeTab === 'server' && } + {activeTab === 'models' && }
- {/* Audio Player - always visible except on server tab */} - {activeTab !== 'server' && } - {/* Show download toasts for any active downloads (from anywhere) */} {activeDownloads.map((download) => { const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name; @@ -224,7 +194,7 @@ function App() { })} -
+ ); } diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx new file mode 100644 index 00000000..6b72b3e3 --- /dev/null +++ b/app/src/components/AppFrame/AppFrame.tsx @@ -0,0 +1,16 @@ +import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; +import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; + +interface AppFrameProps { + children: React.ReactNode; +} + +export function AppFrame({ children }: AppFrameProps) { + return ( +
+ + {children} + +
+ ); +} diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 185a232f..742f9361 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api/core'; -import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react'; +import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; @@ -28,6 +28,7 @@ export function AudioPlayer() { setVolume, toggleLoop, clearRestartFlag, + reset, } = usePlayerStore(); // Check if profile has assigned channels (for native audio routing) @@ -234,7 +235,7 @@ export function AudioPlayer() { runtimeChannels ) { console.log('Attempting native audio playback...'); - + // Stop any existing native playback first if (isUsingNativePlaybackRef.current) { try { @@ -244,7 +245,7 @@ export function AudioPlayer() { console.error('Failed to stop existing playback:', error); } } - + try { // Collect all device IDs from assigned channels const assignedChannels = runtimeChannels.filter((ch: any) => @@ -267,7 +268,12 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log('WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + console.log( + 'WaveSurfer unmuted for normal playback - volume:', + mediaElement.volume, + 'muted:', + mediaElement.muted, + ); } } else { const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids); @@ -288,24 +294,29 @@ export function AudioPlayer() { deviceIds: deviceIds, }); console.log('play_audio_to_devices completed successfully, result:', result); - + // Mark that we're using native playback isUsingNativePlaybackRef.current = true; - + // Mute WaveSurfer's audio element to prevent UI audio output // Keep WaveSurfer running for visualization const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.volume = 0; mediaElement.muted = true; - console.log('WaveSurfer muted for native playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + console.log( + 'WaveSurfer muted for native playback - volume:', + mediaElement.volume, + 'muted:', + mediaElement.muted, + ); } - + // Start WaveSurfer playback for visualization (muted) wavesurfer.play().catch((error) => { console.error('Failed to start WaveSurfer visualization:', error); }); - + setIsPlaying(true); console.log('Auto-playing via native audio routing - SUCCESS'); return; @@ -329,7 +340,12 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log('WaveSurfer unmuted after native playback failure - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + console.log( + 'WaveSurfer unmuted after native playback failure - volume:', + mediaElement.volume, + 'muted:', + mediaElement.muted, + ); } // Fall through to WaveSurfer playback } @@ -342,7 +358,12 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log('WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + console.log( + 'WaveSurfer unmuted for normal playback - volume:', + mediaElement.volume, + 'muted:', + mediaElement.muted, + ); } } @@ -373,7 +394,12 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log('Playing (normal mode) - volume:', mediaElement.volume, 'muted:', mediaElement.muted); + console.log( + 'Playing (normal mode) - volume:', + mediaElement.volume, + 'muted:', + mediaElement.muted, + ); } } }); @@ -497,7 +523,7 @@ export function AudioPlayer() { } })(); } - + // Reset native playback flag when loading new audio // Also unmute WaveSurfer if it was muted if (isUsingNativePlaybackRef.current) { @@ -667,17 +693,17 @@ export function AudioPlayer() { wavesurferRef.current.pause(); return; } - + // Play: trigger native playback try { // Stop any existing native playback first try { await invoke('stop_audio_playback'); - } catch (error) { + } catch (_error) { // Ignore errors when stopping (might not be playing) console.log('No existing playback to stop'); } - + // Collect all device IDs from assigned channels const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id), @@ -697,21 +723,21 @@ export function AudioPlayer() { // Mark that we're using native playback isUsingNativePlaybackRef.current = true; - + // Mute WaveSurfer and start it for visualization const mediaElement = wavesurferRef.current.getMediaElement(); if (mediaElement) { mediaElement.volume = 0; mediaElement.muted = true; } - + // Start WaveSurfer for visualization (muted) wavesurferRef.current.play().catch((error) => { console.error('Failed to start WaveSurfer visualization:', error); setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); - + return; } } catch (error) { @@ -733,7 +759,7 @@ export function AudioPlayer() { mediaElement.volume = volume; } } - + wavesurferRef.current.play().catch((error) => { console.error('Failed to play:', error); setIsPlaying(false); @@ -752,6 +778,22 @@ export function AudioPlayer() { setVolume(value[0] / 100); }; + const handleClose = () => { + // Stop any native playback + if (isUsingNativePlaybackRef.current && isTauri()) { + invoke('stop_audio_playback').catch((error) => { + console.error('Failed to stop native playback:', error); + }); + } + // Stop WaveSurfer + if (wavesurferRef.current) { + wavesurferRef.current.pause(); + wavesurferRef.current.seekTo(0); + } + // Reset player state + reset(); + }; + // Don't render if no audio if (!audioUrl) { return null; @@ -832,6 +874,17 @@ export function AudioPlayer() { className="flex-1" /> + + {/* Close Button */} + diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 79faa64d..b333318f 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -33,7 +33,7 @@ import { usePlayerStore } from '@/stores/playerStore'; // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS export function HistoryTable() { - const [page, setPage] = useState(0); + const [page, _setPage] = useState(0); const [isScrolled, setIsScrolled] = useState(false); const scrollRef = useRef(null); const fileInputRef = useRef(null); @@ -143,7 +143,7 @@ export function HistoryTable() { const history = historyData?.items || []; const total = historyData?.total || 0; - const hasMore = history.length === limit && (page + 1) * limit < total; + const _hasMore = history.length === limit && (page + 1) * limit < total; return (
@@ -176,8 +176,8 @@ export function HistoryTable() {
{history.map((gen) => { @@ -242,7 +242,9 @@ export function HistoryTable() { - handlePlay(gen.id, gen.text, gen.profile_id)}> + handlePlay(gen.id, gen.text, gen.profile_id)} + > Play @@ -275,24 +277,6 @@ export function HistoryTable() { ); })}
- - {(total > limit || page > 0) && ( -
- -
- Page {page + 1} • {total} total -
- -
- )} )} diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx new file mode 100644 index 00000000..fd766265 --- /dev/null +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -0,0 +1,34 @@ +import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; +import { HistoryTable } from '@/components/History/HistoryTable'; +import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; +import { usePlayerStore } from '@/stores/playerStore'; + +export function MainEditor() { + const audioUrl = usePlayerStore((state) => state.audioUrl); + + return ( + // Main view: Profiles top left, Generator bottom left, History right +
+ {/* Left Column */} +
+ {/* Profiles - Top Left */} +
+ +
+ + {/* Generator - Bottom Left */} + {/*
+ +
*/} +
+ + {/* Right Column - History */} +
+ +
+ + {/* Floating Generate Box */} + +
+ ); +} diff --git a/app/src/components/ModelsTab/ModelsTab.tsx b/app/src/components/ModelsTab/ModelsTab.tsx new file mode 100644 index 00000000..3c6ebda7 --- /dev/null +++ b/app/src/components/ModelsTab/ModelsTab.tsx @@ -0,0 +1,9 @@ +import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; + +export function ModelsTab() { + return ( +
+ +
+ ); +} diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx index 78e556ae..6534e3c4 100644 --- a/app/src/components/ServerTab/ServerTab.tsx +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -1,5 +1,4 @@ import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; -import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import { isTauri } from '@/lib/tauri'; @@ -12,7 +11,6 @@ export function ServerTab() {
{isTauri() && } -
Created by{' '} %t90u@%6FJ^IQ9(dZQ1N}ZnVq-)P`J!%C@uL0cE-}e!o=ce$;IzLVq-$F z6n+E~{0MelEDTm!`Vd0nxyf&5lTDtPXJ&W*`;W1G_;j+lHrHykW}ypLUnfb4_$xg#$X)Ua0w>hGE6o@*U1yLu)a7W5G;yhN|lO{A~C7rpPUklgt~Ape#5eUwKBHw2q!iME=NvbIx901igu! zg|S+aD$?4*a^#LFj~i(s=1dYf#!44u{uAV_a~DCw)LCxVA(K@pCYBspp<2M&P>mvG z9yw1GMyo}hM6Aw(mil|U9hib?n1NZCgDcR5t1u7O;5yuZ1z3ceaF($QD{u=|VGVA> z9ax7AxC{5-K5W7RcnFW+F+6GZcAx(F{oJCyD2ati1Z)m&A)-^XoGUK1l1YqgPEa}( zI`6d7Kiqi+47Q*L90XJ#L4j@#cTDrMjtZZF6Nwe&h^deY^CBxo5=UH72LT4#xb5^Tx`C4-2OcI7jd`Y>Hq)$ delta 827 zcmZwGy-rj?6bJBy6UM2ND|- zic4YP1DNm%c5W;TR$BTegle7Sm#OA|=A6kK{P;O`0H05{*1N4%YaZrc7G_`?rl52D zd+W4&SWFDgOfmRsb_XwcW3a)+@q6yJtCC~kT#-rV7)h&S$>xwq1)p45XEv^;(h8@k zIA12Q)IJn0`RzBaUmb3)ei*v`(2c=3wBZU&z*U%RhOUz)s^~piZwiXBw4_ti6pW0d zyy767@vMr`I<^)4yw+_cEv81LFzNK01aSV_`cC}zhw*#C6yN?FFspkeH+v@4-ZR+gFA8pd&xFv3!j zwMw$EK4M-YHwv{B-dqGN_xJZYFa^^v1G6v(^U#HBumIQL2Hb>2Sb|${p0NU}a2wWO z9qzzg*nmyA2lwFtY{5f#1drhfJZ<*(p8fsvB67qinOqPxY2h$$FFiTsT&|r_S-6p& zd``-zi=I~dN4w8~U>kZ+fB*>!G??aSmz!UeHKi(T;V9K!koMA&H#v|gvX{Z&d7>CC qSesJk%YrApvVYRE;F^=(PW#=$QmbXY;}48uKf}JEX! From a3cbe7f2b60c943dc466d4f1b6ebafdd819f8436 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 17:11:38 -0800 Subject: [PATCH 5/8] Refactor UI layout and introduce safe area constants for improved responsiveness - Updated App, AppFrame, and AudioTab components to utilize new TOP_SAFE_AREA_PADDING and BOTTOM_SAFE_AREA_PADDING constants for consistent layout adjustments. - Enhanced Sidebar and MainEditor components for better organization and user experience. - Improved VoicesTab and ProfileList components by integrating new layout features and removing redundant import functionality. - Streamlined HistoryTable and ModelManagement components for better visual consistency and interaction. --- app/src/App.tsx | 4 +- app/src/components/AppFrame/AppFrame.tsx | 4 +- app/src/components/AudioTab/AudioTab.tsx | 354 ++++++++++++------ app/src/components/History/HistoryTable.tsx | 3 +- app/src/components/MainEditor/MainEditor.tsx | 146 +++++++- .../ServerSettings/ModelManagement.tsx | 3 +- app/src/components/Sidebar.tsx | 2 +- .../components/VoiceProfiles/ProfileList.tsx | 104 +---- app/src/components/VoicesTab/VoicesTab.tsx | 69 ++-- app/src/lib/constants/ui.ts | 15 + 10 files changed, 455 insertions(+), 249 deletions(-) create mode 100644 app/src/lib/constants/ui.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index c4cd85b7..89afc281 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -21,6 +21,8 @@ import { startServer, } from '@/lib/tauri'; import { useServerStore } from '@/stores/serverStore'; +import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { cn } from '@/lib/utils/cn'; // Track if server is starting to prevent duplicate starts let serverStarting = false; @@ -138,7 +140,7 @@ function App() { // Show loading screen while server is starting in Tauri if (isTauri() && !serverReady) { return ( -
+
diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx index 6b72b3e3..2caaa0dc 100644 --- a/app/src/components/AppFrame/AppFrame.tsx +++ b/app/src/components/AppFrame/AppFrame.tsx @@ -1,5 +1,7 @@ import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; +import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { cn } from '@/lib/utils/cn'; interface AppFrameProps { children: React.ReactNode; @@ -7,7 +9,7 @@ interface AppFrameProps { export function AppFrame({ children }: AppFrameProps) { return ( -
+
{children} diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx index 04e07978..059bc836 100644 --- a/app/src/components/AudioTab/AudioTab.tsx +++ b/app/src/components/AudioTab/AudioTab.tsx @@ -1,7 +1,6 @@ -import { Edit, Plus, Trash2, Speaker } from 'lucide-react'; +import { Edit, Plus, Trash2, Speaker, CheckCircle2, Check } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, @@ -19,10 +18,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { isTauri } from '@/lib/tauri'; import { invoke } from '@tauri-apps/api/core'; +import { usePlayerStore } from '@/stores/playerStore'; +import { cn } from '@/lib/utils/cn'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; interface AudioDevice { id: string; @@ -33,7 +36,10 @@ interface AudioDevice { export function AudioTab() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); + const [selectedChannelId, setSelectedChannelId] = useState(null); const queryClient = useQueryClient(); + const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; const { data: channels, isLoading: channelsLoading } = useQuery({ queryKey: ['channels'], @@ -120,98 +126,219 @@ export function AudioTab() { ); } + const allChannels = channels || []; + const allDevices = devices || []; + const selectedChannel = selectedChannelId + ? allChannels.find((c) => c.id === selectedChannelId) + : null; + return (
-
-

Audio Channels

+
+

Audio Channels

-
- {channels?.map((channel) => ( - - -
- - - {channel.name} - {channel.is_default && ( - (Default) - )} - - {!channel.is_default && ( -
- - -
- )} -
-
- -
-
- -
- {channel.device_ids.length > 0 ? ( -
    - {channel.device_ids.map((deviceId) => { - const device = devices?.find((d) => d.id === deviceId); - return ( -
  • {device?.name || deviceId || 'Default Speakers'}
  • - ); - })} -
- ) : ( - Default Speakers - )} -
-
-
- - -
-
-
-
- ))} -
- -
-

Available Devices

-
- {devices && devices.length > 0 ? ( - devices.map((device) => ( -
- {device.name} - {device.is_default && ( - (default) - )} -
- )) +
+ {/* Left Column - Channels */} +
+ {allChannels.length === 0 ? ( +
+ +

+ No audio channels yet. Create your first channel to route voices to specific devices. +

+ +
) : ( -
- {isTauri() - ? 'No audio devices found' - : 'Audio device selection requires Tauri'} +
+ {allChannels.map((channel) => { + const isSelected = selectedChannelId === channel.id; + return ( + + +
+ )} +
+ + ); + })} +
+ )} +
+ + {/* Right Column - Available Devices */} +
+
+

Available Devices

+

+ {selectedChannelId + ? selectedChannel?.is_default + ? 'Default channel uses system default device' + : 'Click devices to add or remove them from the selected channel' + : 'Select a channel to assign devices'} +

+
+ {allDevices.length > 0 ? ( +
+ {allDevices.map((device) => { + const isConnected = + selectedChannelId && + selectedChannel && + (selectedChannel.device_ids.length === 0 + ? device.is_default + : selectedChannel.device_ids.includes(device.id)); + const canToggle = selectedChannelId && selectedChannel && !selectedChannel.is_default; + + const handleDeviceClick = () => { + if (!canToggle || !selectedChannel) return; + + const currentDeviceIds = selectedChannel.device_ids; + const newDeviceIds = isConnected + ? currentDeviceIds.filter((id) => id !== device.id) + : [...currentDeviceIds, device.id]; + + updateChannel.mutate({ + channelId: selectedChannelId, + data: { device_ids: newDeviceIds }, + }); + }; + + return ( + + ); + })} +
+ ) : ( +
+ +

+ {isTauri() + ? 'No audio devices found' + : 'Audio device selection requires Tauri'} +

)}
@@ -228,28 +355,31 @@ export function AudioTab() { /> {/* Edit Channel Dialog */} - {editingChannel && ( - !open && setEditingChannel(null)} - channel={channels?.find((c) => c.id === editingChannel)!} - devices={devices || []} - profiles={profiles || []} - channelVoices={channelVoices?.profile_ids || []} - onUpdate={(name, deviceIds) => { - updateChannel.mutate({ - channelId: editingChannel, - data: { name, device_ids: deviceIds }, - }); - }} - onSetVoices={(profileIds) => { - setChannelVoices.mutate({ - channelId: editingChannel, - profileIds, - }); - }} - /> - )} + {editingChannel && (() => { + const channel = channels?.find((c) => c.id === editingChannel); + return channel ? ( + !open && setEditingChannel(null)} + channel={channel} + devices={devices || []} + profiles={profiles || []} + channelVoices={channelVoices?.profile_ids || []} + onUpdate={(name, deviceIds) => { + updateChannel.mutate({ + channelId: editingChannel, + data: { name, device_ids: deviceIds }, + }); + }} + onSetVoices={(profileIds) => { + setChannelVoices.mutate({ + channelId: editingChannel, + profileIds, + }); + }} + /> + ) : null; + })()}
); } @@ -271,11 +401,15 @@ function ChannelVoicesList({ channelId }: { channelId: string }) { .filter(Boolean) || []; return ( -
+
{voiceNames.length > 0 ? ( - {voiceNames.join(', ')} + voiceNames.map((name) => ( + + {name} + + )) ) : ( - No voices assigned + No voices assigned )}
); diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index b333318f..198bf937 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -27,6 +27,7 @@ import { import { cn } from '@/lib/utils/cn'; import { formatDate, formatDuration } from '@/lib/utils/format'; import { usePlayerStore } from '@/stores/playerStore'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // This is the new alternate history view with fixed height rows @@ -177,7 +178,7 @@ export function HistoryTable() { ref={scrollRef} className={cn( 'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4', - isPlayerVisible && 'pb-32', + isPlayerVisible && BOTTOM_SAFE_AREA_PADDING, )} > {history.map((gen) => { diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index fd766265..754e3f70 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -1,25 +1,118 @@ -import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; -import { HistoryTable } from '@/components/History/HistoryTable'; +import { Sparkles, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; +import { HistoryTable } from '@/components/History/HistoryTable'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { useImportProfile } from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; +import { useUIStore } from '@/stores/uiStore'; export function MainEditor() { const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; + const scrollRef = useRef(null); + const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const importProfile = useImportProfile(); + const fileInputRef = useRef(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!file.name.endsWith('.voicebox.zip')) { + alert('Please select a valid .voicebox.zip file'); + return; + } + setSelectedFile(file); + setImportDialogOpen(true); + } + }; + + const handleImportConfirm = () => { + if (selectedFile) { + importProfile.mutate(selectedFile, { + onSuccess: () => { + setImportDialogOpen(false); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + onError: (error) => { + alert(`Failed to import profile: ${error.message}`); + }, + }); + } + }; return ( // Main view: Profiles top left, Generator bottom left, History right
{/* Left Column */} -
- {/* Profiles - Top Left */} -
- +
+ {/* Scroll Mask - Always visible, behind content */} +
+ + {/* Fixed Header */} +
+
+

Voicebox

+
+ + + +
+
- {/* Generator - Bottom Left */} - {/*
- -
*/} + {/* Scrollable Content */} +
+
+ {/* Profiles - Top Left */} +
+ +
+ + {/* Generator - Bottom Left */} + {/*
+ +
*/} +
+
{/* Right Column - History */} @@ -29,6 +122,39 @@ export function MainEditor() { {/* Floating Generate Box */} + + {/* Import Dialog */} + + + + Import Profile + + Import the profile from "{selectedFile?.name}". This will create a new profile with + all samples. + + + + + + + +
); } diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 3b160e5b..41d2143b 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -277,14 +277,13 @@ function ModelItem({ {model.downloaded ? (
- + Ready
- - -
-
-
{allProfiles.length === 0 ? ( @@ -118,38 +50,6 @@ export function ProfileList() {
- - - - - Import Profile - - Import the profile from "{selectedFile?.name}". This will create a new profile with - all samples. - - - - - - - -
); } diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 63a67c45..4c2b8acd 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; -import { useMemo } from 'react'; +import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react'; +import { useMemo, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -22,6 +22,10 @@ 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'; +import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { cn } from '@/lib/utils/cn'; +import { usePlayerStore } from '@/stores/playerStore'; export function VoicesTab() { const { data: profiles, isLoading } = useProfiles(); @@ -30,6 +34,9 @@ export function VoicesTab() { const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const deleteProfile = useDeleteProfile(); + const scrollRef = useRef(null); + const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; // Get generation counts per profile const generationCounts = useMemo(() => { @@ -96,16 +103,29 @@ export function VoicesTab() { } return ( -
-
-

Voices

- +
+ {/* Scroll Mask - Always visible, behind content */} +
+ + {/* Fixed Header */} +
+
+

Voices

+ +
-
+ {/* Scrollable Content */} +
@@ -133,6 +153,8 @@ export function VoicesTab() {
+ +
); } @@ -159,19 +181,24 @@ function VoiceRow({ const { data: samples } = useProfileSamples(profile.id); return ( - + -
-
{profile.name}
- {profile.description && ( -
{profile.description}
- )} +
+
+ +
+
+
{profile.name}
+ {profile.description && ( +
{profile.description}
+ )} +
- {profile.language} - {generationCount} - {samples?.length || 0} - + e.stopPropagation()}>{profile.language} + e.stopPropagation()}>{generationCount} + e.stopPropagation()}>{samples?.length || 0} + e.stopPropagation()}> ({ value: ch.id, @@ -183,7 +210,7 @@ function VoiceRow({ className="min-w-[200px]" /> - + e.stopPropagation()}>
- {channel.device_ids.length > 0 ? ( - channel.device_ids.map((deviceId) => { - const device = allDevices.find((d) => d.id === deviceId); - return ( - - {device?.name || deviceId} - - ); - }) - ) : ( - (() => { - const defaultDevice = allDevices.find((d) => d.is_default); - return defaultDevice ? ( - - {defaultDevice.name} - - ) : null; - })() - )} + {channel.device_ids.length > 0 + ? channel.device_ids.map((deviceId) => { + const device = allDevices.find((d) => d.id === deviceId); + return ( + + {device?.name || deviceId} + + ); + }) + : (() => { + const defaultDevice = allDevices.find((d) => d.is_default); + return defaultDevice ? ( + + {defaultDevice.name} + + ) : null; + })()}
@@ -259,7 +262,12 @@ export function AudioTab() {
{/* Right Column - Available Devices */} -
+

Available Devices

@@ -279,22 +287,23 @@ export function AudioTab() { (selectedChannel.device_ids.length === 0 ? device.is_default : selectedChannel.device_ids.includes(device.id)); - const canToggle = selectedChannelId && selectedChannel && !selectedChannel.is_default; - + const canToggle = + selectedChannelId && selectedChannel && !selectedChannel.is_default; + const handleDeviceClick = () => { if (!canToggle || !selectedChannel) return; - + const currentDeviceIds = selectedChannel.device_ids; const newDeviceIds = isConnected ? currentDeviceIds.filter((id) => id !== device.id) : [...currentDeviceIds, device.id]; - + updateChannel.mutate({ channelId: selectedChannelId, data: { device_ids: newDeviceIds }, }); }; - + return (

); } @@ -396,9 +402,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) { }); const voiceNames = - voices?.profile_ids - .map((id) => profiles?.find((p) => p.id === id)?.name) - .filter(Boolean) || []; + voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || []; return (
@@ -422,12 +426,7 @@ interface CreateChannelDialogProps { onCreate: (name: string, deviceIds: string[]) => void; } -function CreateChannelDialog({ - open, - onOpenChange, - devices, - onCreate, -}: CreateChannelDialogProps) { +function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) { const [name, setName] = useState(''); const [selectedDevices, setSelectedDevices] = useState([]); @@ -492,7 +491,9 @@ function CreateChannelDialog({ @@ -562,11 +563,7 @@ function EditChannelDialog({
- setName(e.target.value)} - /> + setName(e.target.value)} />
@@ -602,7 +599,9 @@ function EditChannelDialog({ @@ -646,7 +645,9 @@ function EditChannelDialog({ diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 198bf937..77380dd6 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -17,6 +17,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; import { apiClient } from '@/lib/api/client'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useDeleteGeneration, useExportGeneration, @@ -27,7 +28,6 @@ import { import { cn } from '@/lib/utils/cn'; import { formatDate, formatDuration } from '@/lib/utils/format'; import { usePlayerStore } from '@/stores/playerStore'; -import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // This is the new alternate history view with fixed height rows diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 41d2143b..2b6f4e67 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -1,13 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Download, Loader2, Trash2 } from 'lucide-react'; import { useState } from 'react'; -import { apiClient } from '@/lib/api/client'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Loader2, Download, CheckCircle2, Trash2 } from 'lucide-react'; -import { ModelProgress } from './ModelProgress'; -import { useToast } from '@/components/ui/use-toast'; -import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { AlertDialog, AlertDialogAction, @@ -18,6 +11,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useToast } from '@/components/ui/use-toast'; +import { apiClient } from '@/lib/api/client'; +import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; +import { ModelProgress } from './ModelProgress'; export function ModelManagement() { const { toast } = useToast(); @@ -197,8 +197,8 @@ export function ModelManagement() { {modelToDelete?.sizeMb && ( <> {' '} - This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model - will need to be re-downloaded if you want to use it again. + This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will + need to be re-downloaded if you want to use it again. )} @@ -244,13 +244,7 @@ interface ModelItemProps { formatSize: (sizeMb?: number) => string; } -function ModelItem({ - model, - onDownload, - onDelete, - isDownloading, - formatSize, -}: ModelItemProps) { +function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) { return (
diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 4c2b8acd..12fedef5 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -17,15 +17,15 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm'; import { apiClient } from '@/lib/api/client'; import type { VoiceProfileResponse } from '@/lib/api/types'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useHistory } from '@/lib/hooks/useHistory'; import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles'; -import { useUIStore } from '@/stores/uiStore'; -import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm'; -import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; +import { useUIStore } from '@/stores/uiStore'; export function VoicesTab() { const { data: profiles, isLoading } = useProfiles(); From cb44377b096d1f339532f5dc1dacb23776ff1f08 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 17:14:17 -0800 Subject: [PATCH 7/8] Remove CheckCircle2 icon from various components for a cleaner UI - Eliminated CheckCircle2 icon from ModelManagement, ModelProgress, ServerStatus, and UpdateStatus components to streamline the visual presentation. - Updated import statements accordingly to reflect the removal of unused icons. --- .../ServerSettings/ConnectionForm.tsx | 54 ++++++++++--------- .../ServerSettings/ModelManagement.tsx | 3 +- .../ServerSettings/ModelProgress.tsx | 4 +- .../ServerSettings/ServerStatus.tsx | 3 +- .../ServerSettings/UpdateStatus.tsx | 4 +- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx index fe1d7137..cc1e2d1d 100644 --- a/app/src/components/ServerSettings/ConnectionForm.tsx +++ b/app/src/components/ServerSettings/ConnectionForm.tsx @@ -14,10 +14,11 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { Checkbox } from '@/components/ui/checkbox'; import { useToast } from '@/components/ui/use-toast'; import { useServerStore } from '@/stores/serverStore'; import { setKeepServerRunning } from '@/lib/tauri'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; const connectionSchema = z.object({ serverUrl: z.string().url('Please enter a valid URL'), @@ -83,36 +84,41 @@ export function ConnectionForm() {
-
- { - setKeepServerRunningOnClose(checked); - setKeepServerRunning(checked).catch((error) => { - console.error('Failed to sync setting to Rust:', error); - }); - toast({ - title: 'Setting updated', - description: checked - ? 'Server will continue running when app closes' - : 'Server will stop when app closes', - }); - }} - /> +
+
diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 2b6f4e67..776d5077 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle2, Download, Loader2, Trash2 } from 'lucide-react'; +import { Download, Loader2, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { AlertDialog, @@ -271,7 +271,6 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M {model.downloaded ? (
- Ready
+
diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 776d5077..4d3975e5 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Download, Loader2, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { diff --git a/app/src/components/ServerSettings/ModelProgress.tsx b/app/src/components/ServerSettings/ModelProgress.tsx index 4f651a7f..ae882c1a 100644 --- a/app/src/components/ServerSettings/ModelProgress.tsx +++ b/app/src/components/ServerSettings/ModelProgress.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react'; -import { Progress } from '@/components/ui/progress'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { useServerStore } from '@/stores/serverStore'; -import type { ModelProgress as ModelProgressType } from '@/lib/api/types'; import { Loader2, XCircle } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import type { ModelProgress as ModelProgressType } from '@/lib/api/types'; +import { useServerStore } from '@/stores/serverStore'; interface ModelProgressProps { modelName: string; @@ -63,7 +63,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) { const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; + return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`; }; const getStatusIcon = () => { diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx index d0d122e4..c946e421 100644 --- a/app/src/components/ServerSettings/UpdateStatus.tsx +++ b/app/src/components/ServerSettings/UpdateStatus.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from 'react'; +import { getVersion } from '@tauri-apps/api/app'; import { RefreshCw, Download, AlertCircle } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { useAutoUpdater } from '@/hooks/useAutoUpdater'; -import { getVersion } from '@tauri-apps/api/app'; export function UpdateStatus() { const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false); @@ -77,17 +77,18 @@ export function UpdateStatus() { Downloading update...
{status.downloadProgress !== undefined && ( - - {status.downloadProgress}% - + {status.downloadProgress}% )}
- {status.downloadedBytes !== undefined && status.totalBytes !== undefined && status.totalBytes > 0 && ( -
- {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / {(status.totalBytes / 1024 / 1024).toFixed(1)} MB -
- )} + {status.downloadedBytes !== undefined && + status.totalBytes !== undefined && + status.totalBytes > 0 && ( +
+ {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '} + {(status.totalBytes / 1024 / 1024).toFixed(1)} MB +
+ )}
)} @@ -96,11 +97,14 @@ export function UpdateStatus() {
Update Ready to Install
-
Version {status.version} has been downloaded
+
+ Version {status.version} has been downloaded +
- The app needs to restart to complete the installation. You can do this now or later at your convenience. + The app needs to restart to complete the installation. You can do this now or later at + your convenience.
); }, );