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 {