mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7a5a492e | ||
|
|
cb44377b09 | ||
|
|
a42a946586 | ||
|
|
a3cbe7f2b6 | ||
|
|
d8d9eeaa6a | ||
|
|
f7cb219f6d | ||
|
|
7f18c09628 | ||
|
|
d9c7121c5b | ||
|
|
30ea627ae8 |
+21
-62
@@ -1,18 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
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 { GenerationForm } from '@/components/Generation/GenerationForm';
|
||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
|
||||||
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 ShinyText from '@/components/ShinyText';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
setupWindowCloseHandler,
|
setupWindowCloseHandler,
|
||||||
startServer,
|
startServer,
|
||||||
} from '@/lib/tauri';
|
} from '@/lib/tauri';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
// Track if server is starting to prevent duplicate starts
|
||||||
@@ -55,7 +54,6 @@ function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('main');
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
|
|
||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
@@ -142,7 +140,12 @@ function App() {
|
|||||||
// Show loading screen while server is starting in Tauri
|
// Show loading screen while server is starting in Tauri
|
||||||
if (isTauri() && !serverReady) {
|
if (isTauri() && !serverReady) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center pt-12">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-screen bg-background flex items-center justify-center',
|
||||||
|
TOP_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<TitleBarDragRegion />
|
<TitleBarDragRegion />
|
||||||
<div className="text-center space-y-6">
|
<div className="text-center space-y-6">
|
||||||
<div className="flex justify-center relative">
|
<div className="flex justify-center relative">
|
||||||
@@ -170,65 +173,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background flex flex-col overflow-hidden pt-12">
|
<AppFrame>
|
||||||
<TitleBarDragRegion />
|
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
|
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
|
||||||
|
|
||||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||||
{activeTab === 'settings' ? (
|
{activeTab === 'main' && <MainEditor />}
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
{activeTab === 'voices' && <VoicesTab />}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{activeTab === 'audio' && <AudioTab />}
|
||||||
<ConnectionForm />
|
{activeTab === 'server' && <ServerTab />}
|
||||||
<ServerStatus />
|
{activeTab === 'models' && <ModelsTab />}
|
||||||
</div>
|
|
||||||
{isTauri() && <UpdateStatus />}
|
|
||||||
<ModelManagement />
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Created by{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/jamiepine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
Jamie Pine
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
|
||||||
{/* Profiles - Top Left */}
|
|
||||||
<div className="shrink-0 flex flex-col">
|
|
||||||
<ProfileList />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Generator - Bottom Left */}
|
|
||||||
{/* <div className="shrink-0">
|
|
||||||
<GenerationForm />
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - History */}
|
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<HistoryTable />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Generate Box */}
|
|
||||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio Player - always visible except on settings */}
|
|
||||||
{activeTab !== 'settings' && <AudioPlayer />}
|
|
||||||
|
|
||||||
{/* Show download toasts for any active downloads (from anywhere) */}
|
{/* Show download toasts for any active downloads (from anywhere) */}
|
||||||
{activeDownloads.map((download) => {
|
{activeDownloads.map((download) => {
|
||||||
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
||||||
@@ -242,7 +201,7 @@ function App() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</AppFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppFrame({ children }: AppFrameProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||||
|
<TitleBarDragRegion />
|
||||||
|
{children}
|
||||||
|
<AudioPlayer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ export function AudioPlayer() {
|
|||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioId,
|
audioId,
|
||||||
|
profileId,
|
||||||
title,
|
title,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -23,13 +28,58 @@ export function AudioPlayer() {
|
|||||||
setVolume,
|
setVolume,
|
||||||
toggleLoop,
|
toggleLoop,
|
||||||
clearRestartFlag,
|
clearRestartFlag,
|
||||||
|
reset,
|
||||||
} = usePlayerStore();
|
} = 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(() => {
|
||||||
|
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
|
||||||
|
const shouldUseNative = assignedChannels.some(
|
||||||
|
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('useNativePlayback result:', shouldUseNative);
|
||||||
|
return shouldUseNative;
|
||||||
|
}, [profileChannels, channels, profileId]);
|
||||||
|
|
||||||
const waveformRef = useRef<HTMLDivElement>(null);
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const previousAudioIdRef = useRef<string | null>(null);
|
const previousAudioIdRef = useRef<string | null>(null);
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
const isUsingNativePlaybackRef = useRef(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -122,7 +172,7 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update store when duration is loaded
|
// Update store when duration is loaded
|
||||||
wavesurfer.on('ready', () => {
|
wavesurfer.on('ready', async () => {
|
||||||
const dur = wavesurfer.getDuration();
|
const dur = wavesurfer.getDuration();
|
||||||
setDuration(dur);
|
setDuration(dur);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
@@ -136,14 +186,188 @@ export function AudioPlayer() {
|
|||||||
wavesurfer.setVolume(currentVolume);
|
wavesurfer.setVolume(currentVolume);
|
||||||
|
|
||||||
// Get the underlying audio element and ensure it's not muted
|
// 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();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
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...');
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
// 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
|
||||||
// Use a small delay to ensure audio element is fully ready
|
// Use a small delay to ensure audio element is fully ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
@@ -156,13 +380,27 @@ export function AudioPlayer() {
|
|||||||
// Handle play/pause
|
// Handle play/pause
|
||||||
wavesurfer.on('play', () => {
|
wavesurfer.on('play', () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
// Ensure audio element is not muted when playing
|
// Ensure audio element volume is set correctly
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
mediaElement.muted = false;
|
// 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;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
mediaElement.muted = false;
|
||||||
|
console.log(
|
||||||
|
'Playing (normal mode) - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||||
@@ -268,10 +506,35 @@ export function AudioPlayer() {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Reset native playback flag
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
}
|
}
|
||||||
return;
|
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
|
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||||
// This must happen BEFORE any early returns
|
// This must happen BEFORE any early returns
|
||||||
console.log('Audio URL changed to:', audioUrl);
|
console.log('Audio URL changed to:', audioUrl);
|
||||||
@@ -352,11 +615,18 @@ export function AudioPlayer() {
|
|||||||
// Also ensure the underlying audio element volume is set
|
// Also ensure the underlying audio element volume is set
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
|
// 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.volume = volume;
|
||||||
mediaElement.muted = volume === 0;
|
mediaElement.muted = volume === 0;
|
||||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
// Mark as initialized when audio is ready, reset when audioId changes
|
// Mark as initialized when audio is ready, reset when audioId changes
|
||||||
@@ -395,7 +665,9 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = async () => {
|
||||||
|
// 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) {
|
if (!wavesurferRef.current) {
|
||||||
console.error('WaveSurfer not initialized');
|
console.error('WaveSurfer not initialized');
|
||||||
return;
|
return;
|
||||||
@@ -408,9 +680,86 @@ export function AudioPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If using native playback
|
||||||
|
if (useNativePlayback && audioUrl && profileChannels && channels) {
|
||||||
|
if (isPlaying) {
|
||||||
|
// 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),
|
||||||
|
);
|
||||||
|
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', {
|
||||||
|
audioData: Array.from(audioData),
|
||||||
|
deviceIds: deviceIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (or fallback from native playback failure)
|
||||||
if (wavesurferRef.current.isPlaying()) {
|
if (wavesurferRef.current.isPlaying()) {
|
||||||
wavesurferRef.current.pause();
|
wavesurferRef.current.pause();
|
||||||
} else {
|
} 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) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to play:', error);
|
console.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -429,6 +778,22 @@ export function AudioPlayer() {
|
|||||||
setVolume(value[0] / 100);
|
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
|
// Don't render if no audio
|
||||||
if (!audioUrl) {
|
if (!audioUrl) {
|
||||||
return null;
|
return null;
|
||||||
@@ -509,6 +874,17 @@ export function AudioPlayer() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="shrink-0"
|
||||||
|
title="Close player"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
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 { apiClient } from '@/lib/api/client';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
interface AudioDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioTab() {
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
|
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<AudioDevice[]>('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 (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allChannels = channels || [];
|
||||||
|
const allDevices = devices || [];
|
||||||
|
const selectedChannel = selectedChannelId
|
||||||
|
? allChannels.find((c) => c.id === selectedChannelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
|
||||||
|
{/* Left Column - Channels */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col min-h-0 overflow-y-auto',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{allChannels.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
|
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
No audio channels yet. Create your first channel to route voices to specific
|
||||||
|
devices.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 p-2">
|
||||||
|
{allChannels.map((channel) => {
|
||||||
|
const isSelected = selectedChannelId === channel.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
|
||||||
|
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 ml-10">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
Output Devices
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{channel.device_ids.length > 0
|
||||||
|
? channel.device_ids.map((deviceId) => {
|
||||||
|
const device = allDevices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={deviceId}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
{device?.name || deviceId}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: (() => {
|
||||||
|
const defaultDevice = allDevices.find((d) => d.is_default);
|
||||||
|
return defaultDevice ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
{defaultDevice.name}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
Assigned Voices
|
||||||
|
</div>
|
||||||
|
<ChannelVoicesList channelId={channel.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!channel.is_default && (
|
||||||
|
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingChannel(channel.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Delete this channel?')) {
|
||||||
|
deleteChannel.mutate(channel.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Available Devices */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col min-h-0 overflow-y-auto',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{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'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{allDevices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeviceClick}
|
||||||
|
disabled={!canToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
|
||||||
|
isConnected
|
||||||
|
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
|
||||||
|
: 'hover:bg-muted/50',
|
||||||
|
!canToggle && 'cursor-default opacity-60',
|
||||||
|
canToggle && 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{canToggle ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
|
||||||
|
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||||
|
</div>
|
||||||
|
) : device.is_default ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
) : null}
|
||||||
|
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||||
|
{device.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
{isTauri() ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Channel Dialog */}
|
||||||
|
<CreateChannelDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
devices={devices || []}
|
||||||
|
onCreate={(name, deviceIds) => {
|
||||||
|
createChannel.mutate({ name, device_ids: deviceIds });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Channel Dialog */}
|
||||||
|
{editingChannel &&
|
||||||
|
(() => {
|
||||||
|
const channel = channels?.find((c) => c.id === editingChannel);
|
||||||
|
return channel ? (
|
||||||
|
<EditChannelDialog
|
||||||
|
open={!!editingChannel}
|
||||||
|
onOpenChange={(open) => !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;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{voiceNames.length > 0 ? (
|
||||||
|
voiceNames.map((name) => (
|
||||||
|
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (name.trim()) {
|
||||||
|
onCreate(name.trim(), selectedDevices);
|
||||||
|
setName('');
|
||||||
|
setSelectedDevices([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Audio Channel</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new audio channel (bus) to route voices to specific output devices.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="channel-name">Channel Name</Label>
|
||||||
|
<Input
|
||||||
|
id="channel-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Virtual Cable, Broadcast"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output Devices</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDevices[0] || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedDevices.includes(value)) {
|
||||||
|
setSelectedDevices([...selectedDevices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select device" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.id} value={device.id}>
|
||||||
|
{device.name} {device.is_default && '(default)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedDevices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedDevices.map((deviceId) => {
|
||||||
|
const device = devices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deviceId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{device?.name || deviceId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string[]>(channel.device_ids);
|
||||||
|
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (name.trim()) {
|
||||||
|
onUpdate(name.trim(), selectedDevices);
|
||||||
|
onSetVoices(selectedVoices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Channel</DialogTitle>
|
||||||
|
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||||
|
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output Devices</Label>
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedDevices.includes(value)) {
|
||||||
|
setSelectedDevices([...selectedDevices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Add device" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.id} value={device.id}>
|
||||||
|
{device.name} {device.is_default && '(default)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedDevices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedDevices.map((deviceId) => {
|
||||||
|
const device = devices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deviceId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{device?.name || deviceId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Assigned Voices</Label>
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedVoices.includes(value)) {
|
||||||
|
setSelectedVoices([...selectedVoices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Add voice" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<SelectItem key={profile.id} value={profile.id}>
|
||||||
|
{profile.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedVoices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedVoices.map((profileId) => {
|
||||||
|
const profile = profiles.find((p) => p.id === profileId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={profileId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{profile?.name || profileId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
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();
|
form.reset();
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function GenerationForm() {
|
|||||||
|
|
||||||
// Autoplay the generated audio
|
// Autoplay the generated audio
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
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();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
useExportGeneration,
|
useExportGeneration,
|
||||||
@@ -33,7 +34,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
|||||||
|
|
||||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, _setPage] = useState(0);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -69,14 +70,14 @@ export function HistoryTable() {
|
|||||||
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
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 clicking the same audio, restart it from the beginning
|
||||||
if (currentAudioId === audioId) {
|
if (currentAudioId === audioId) {
|
||||||
restartCurrentAudio();
|
restartCurrentAudio();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, load the new audio
|
// Otherwise, load the new audio
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||||
setAudio(audioUrl, audioId, text.substring(0, 50));
|
setAudio(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ export function HistoryTable() {
|
|||||||
|
|
||||||
const history = historyData?.items || [];
|
const history = historyData?.items || [];
|
||||||
const total = historyData?.total || 0;
|
const total = historyData?.total || 0;
|
||||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
@@ -176,8 +177,8 @@ export function HistoryTable() {
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 min-h-0 overflow-y-auto space-y-2',
|
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
|
||||||
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
@@ -195,7 +196,7 @@ export function HistoryTable() {
|
|||||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePlay(gen.id, gen.text);
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Waveform icon */}
|
||||||
@@ -242,7 +243,9 @@ export function HistoryTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Play
|
Play
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -275,24 +278,6 @@ export function HistoryTable() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(total > limit || page > 0) && (
|
|
||||||
<div className="flex justify-between items-center mt-4 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Page {page + 1} • {total} total
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
const importProfile = useImportProfile();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
||||||
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10">
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleImportClick}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Import Voice
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".voicebox.zip"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
Create Voice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-h-0 overflow-y-auto pt-14',
|
||||||
|
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Profiles - Top Left */}
|
||||||
|
<div className="shrink-0 flex flex-col">
|
||||||
|
<ProfileList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generator - Bottom Left */}
|
||||||
|
{/* <div className="shrink-0">
|
||||||
|
<GenerationForm />
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - History */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<HistoryTable />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box */}
|
||||||
|
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||||
|
|
||||||
|
{/* Import Dialog */}
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Profile</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||||
|
all samples.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImportConfirm}
|
||||||
|
disabled={importProfile.isPending || !selectedFile}
|
||||||
|
>
|
||||||
|
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||||
|
|
||||||
|
export function ModelsTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
|
<ModelManagement />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
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 { 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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -18,6 +11,13 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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() {
|
export function ModelManagement() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -197,8 +197,8 @@ export function ModelManagement() {
|
|||||||
{modelToDelete?.sizeMb && (
|
{modelToDelete?.sizeMb && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model
|
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
|
||||||
will need to be re-downloaded if you want to use it again.
|
need to be re-downloaded if you want to use it again.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
@@ -244,13 +244,7 @@ interface ModelItemProps {
|
|||||||
formatSize: (sizeMb?: number) => string;
|
formatSize: (sizeMb?: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelItem({
|
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||||
model,
|
|
||||||
onDownload,
|
|
||||||
onDelete,
|
|
||||||
isDownloading,
|
|
||||||
formatSize,
|
|
||||||
}: ModelItemProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -277,14 +271,12 @@ function ModelItem({
|
|||||||
{model.downloaded ? (
|
{model.downloaded ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
<span>Ready</span>
|
<span>Ready</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
disabled={model.loaded}
|
disabled={model.loaded}
|
||||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
||||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
interface ModelProgressProps {
|
interface ModelProgressProps {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
@@ -63,13 +63,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
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 = () => {
|
const getStatusIcon = () => {
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'complete':
|
|
||||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
@@ -43,7 +43,6 @@ export function ServerStatus() {
|
|||||||
) : health ? (
|
) : health ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
<span className="text-sm">Connected</span>
|
<span className="text-sm">Connected</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
|
||||||
|
|
||||||
export function UpdateStatus() {
|
export function UpdateStatus() {
|
||||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||||
@@ -77,15 +77,16 @@ export function UpdateStatus() {
|
|||||||
Downloading update...
|
Downloading update...
|
||||||
</div>
|
</div>
|
||||||
{status.downloadProgress !== undefined && (
|
{status.downloadProgress !== undefined && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
||||||
{status.downloadProgress}%
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Progress value={status.downloadProgress} />
|
<Progress value={status.downloadProgress} />
|
||||||
{status.downloadedBytes !== undefined && status.totalBytes !== undefined && status.totalBytes > 0 && (
|
{status.downloadedBytes !== undefined &&
|
||||||
|
status.totalBytes !== undefined &&
|
||||||
|
status.totalBytes > 0 && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / {(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||||
|
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,14 +95,16 @@ export function UpdateStatus() {
|
|||||||
{status.readyToInstall && (
|
{status.readyToInstall && (
|
||||||
<div className="space-y-3 p-4 border rounded-lg bg-green-500/10 border-green-500/20">
|
<div className="space-y-3 p-4 border rounded-lg bg-green-500/10 border-green-500/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Update Ready to Install</div>
|
<div className="font-semibold">Update Ready to Install</div>
|
||||||
<div className="text-sm text-muted-foreground">Version {status.version} has been downloaded</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Version {status.version} has been downloaded
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
@@ -112,7 +115,6 @@ export function UpdateStatus() {
|
|||||||
|
|
||||||
{!status.available && !status.checking && !status.error && status.checking === false && (
|
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
You're up to date
|
You're up to date
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
|
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||||
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
|
export function ServerTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<ConnectionForm />
|
||||||
|
<ServerStatus />
|
||||||
|
</div>
|
||||||
|
{isTauri() && <UpdateStatus />}
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Created by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/jamiepine"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Jamie Pine
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loader2, Settings, Volume2 } from 'lucide-react';
|
import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
@@ -11,8 +11,11 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', icon: Volume2, label: 'Main' },
|
{ id: 'main', icon: Volume2, label: 'Generate' },
|
||||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
{ id: 'voices', icon: Mic, label: 'Voices' },
|
||||||
|
{ id: 'audio', icon: Speaker, label: 'Audio' },
|
||||||
|
{ id: 'models', icon: Box, label: 'Models' },
|
||||||
|
{ id: 'server', icon: Server, label: 'Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { Mic, Sparkles, Upload } from 'lucide-react';
|
import { Mic, Sparkles } from 'lucide-react';
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useImportProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ProfileCard } from './ProfileCard';
|
import { ProfileCard } from './ProfileCard';
|
||||||
import { ProfileForm } from './ProfileForm';
|
import { ProfileForm } from './ProfileForm';
|
||||||
@@ -18,44 +9,6 @@ import { ProfileForm } from './ProfileForm';
|
|||||||
export function ProfileList() {
|
export function ProfileList() {
|
||||||
const { data: profiles, isLoading, error } = useProfiles();
|
const { data: profiles, isLoading, error } = useProfiles();
|
||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const importProfile = useImportProfile();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const handleImportClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
// Validate file extension
|
|
||||||
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}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,27 +26,6 @@ export function ProfileList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
|
||||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleImportClick}>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Import Voice
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".voicebox.zip"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
|
||||||
Create Voice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{allProfiles.length === 0 ? (
|
{allProfiles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,38 +50,6 @@ export function ProfileList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
|
|
||||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Import Profile</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
|
||||||
all samples.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setImportDialogOpen(false);
|
|
||||||
setSelectedFile(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleImportConfirm}
|
|
||||||
disabled={importProfile.isPending || !selectedFile}
|
|
||||||
>
|
|
||||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,9 +17,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||||
import { apiClient } from '@/lib/api/client';
|
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 { useHistory } from '@/lib/hooks/useHistory';
|
||||||
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
export function VoicesTab() {
|
export function VoicesTab() {
|
||||||
@@ -28,6 +34,9 @@ export function VoicesTab() {
|
|||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
// Get generation counts per profile
|
// Get generation counts per profile
|
||||||
const generationCounts = useMemo(() => {
|
const generationCounts = useMemo(() => {
|
||||||
@@ -94,7 +103,12 @@ export function VoicesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col relative overflow-hidden">
|
||||||
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-20">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Voices</h1>
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
@@ -102,8 +116,16 @@ export function VoicesTab() {
|
|||||||
New Voice
|
New Voice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
{/* Scrollable Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-y-auto pt-16 relative z-0',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -131,17 +153,14 @@ export function VoicesTab() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProfileForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceRowProps {
|
interface VoiceRowProps {
|
||||||
profile: {
|
profile: VoiceProfileResponse;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
language: string;
|
|
||||||
};
|
|
||||||
generationCount: number;
|
generationCount: number;
|
||||||
channelIds: string[];
|
channelIds: string[];
|
||||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||||
@@ -162,37 +181,36 @@ function VoiceRow({
|
|||||||
const { data: samples } = useProfileSamples(profile.id);
|
const { data: samples } = useProfileSamples(profile.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{profile.name}</div>
|
<div className="font-medium">{profile.name}</div>
|
||||||
{profile.description && (
|
{profile.description && (
|
||||||
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{profile.language}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
||||||
<TableCell>{generationCount}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
||||||
<TableCell>{samples?.length || 0}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
|
||||||
<TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<MultiSelect
|
||||||
multiple
|
options={channels.map((ch) => ({
|
||||||
|
value: ch.id,
|
||||||
|
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||||
|
}))}
|
||||||
value={channelIds}
|
value={channelIds}
|
||||||
onChange={(e) => {
|
onChange={onChannelChange}
|
||||||
const selected = Array.from(e.target.selectedOptions, (opt) => opt.value);
|
placeholder="Select channels..."
|
||||||
onChannelChange(selected);
|
className="min-w-[200px]"
|
||||||
}}
|
/>
|
||||||
className="w-full min-w-[200px] border rounded px-2 py-1 text-sm"
|
|
||||||
size={Math.min(channels.length + 1, 5)}
|
|
||||||
>
|
|
||||||
{channels.map((ch) => (
|
|
||||||
<option key={ch.id} value={ch.id}>
|
|
||||||
{ch.name} {ch.is_default && '(Default)'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
export interface CheckboxProps {
|
||||||
|
checked?: boolean;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||||
({ className, onCheckedChange, ...props }, ref) => {
|
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(e.target.checked);
|
|
||||||
}
|
|
||||||
// Call original onChange if provided
|
|
||||||
if (props.onChange) {
|
|
||||||
props.onChange(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
type="button"
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled && onCheckedChange) {
|
||||||
|
onCheckedChange(!checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||||
|
checked ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
!disabled && 'cursor-pointer',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
|
||||||
onChange={handleChange}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectProps {
|
||||||
|
options: MultiSelectOption[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
MultiSelectCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
export function MultiSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
className,
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleSelect = (optionValue: string) => {
|
||||||
|
const newValue = value.includes(optionValue)
|
||||||
|
? value.filter((v) => v !== optionValue)
|
||||||
|
: [...value, optionValue];
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayText =
|
||||||
|
value.length === 0
|
||||||
|
? placeholder
|
||||||
|
: value.length === 1
|
||||||
|
? options.find((opt) => opt.value === value[0])?.label || placeholder
|
||||||
|
: `${value.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-full items-center justify-between rounded-full border border-border bg-card px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-background/50 transition-all',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-1">{displayText}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="max-h-96 overflow-auto"
|
||||||
|
align="start"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MultiSelectCheckboxItem
|
||||||
|
key={option.value}
|
||||||
|
checked={value.includes(option.value)}
|
||||||
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
onCheckedChange={() => handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MultiSelectCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -279,6 +279,88 @@ class ApiClient {
|
|||||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
return this.request<ActiveTasksResponse>('/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();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* UI layout constants for safe area padding
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top safe area padding - height of the drag region bar
|
||||||
|
* Corresponds to Tailwind's pt-12 (3rem / 48px)
|
||||||
|
*/
|
||||||
|
export const TOP_SAFE_AREA_PADDING = 'pt-12';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom safe area padding - height of the audio player
|
||||||
|
* Corresponds to Tailwind's pb-32 (8rem / 128px)
|
||||||
|
*/
|
||||||
|
export const BOTTOM_SAFE_AREA_PADDING = 'pb-32';
|
||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
audioUrl: string | null;
|
audioUrl: string | null;
|
||||||
audioId: string | null;
|
audioId: string | null;
|
||||||
|
profileId: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@@ -11,7 +12,7 @@ interface PlayerState {
|
|||||||
isLooping: boolean;
|
isLooping: boolean;
|
||||||
shouldRestart: 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;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
@@ -25,6 +26,7 @@ interface PlayerState {
|
|||||||
export const usePlayerStore = create<PlayerState>((set) => ({
|
export const usePlayerStore = create<PlayerState>((set) => ({
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
audioId: null,
|
audioId: null,
|
||||||
|
profileId: null,
|
||||||
title: null,
|
title: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
@@ -33,10 +35,11 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
isLooping: false,
|
isLooping: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
|
||||||
setAudio: (url, id, title) =>
|
setAudio: (url, id, profileId, title) =>
|
||||||
set({
|
set({
|
||||||
audioUrl: url,
|
audioUrl: url,
|
||||||
audioId: id,
|
audioId: id,
|
||||||
|
profileId: profileId || null,
|
||||||
title: title || null,
|
title: title || null,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -53,6 +56,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
set({
|
set({
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
audioId: null,
|
audioId: null,
|
||||||
|
profileId: null,
|
||||||
title: null,
|
title: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
|||||||
+53
-1
@@ -2,7 +2,7 @@
|
|||||||
SQLite database ORM using SQLAlchemy.
|
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.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -62,6 +62,33 @@ class Project(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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()
|
# Database setup will be initialized in init_db()
|
||||||
engine = None
|
engine = None
|
||||||
SessionLocal = None
|
SessionLocal = None
|
||||||
@@ -83,6 +110,31 @@ def init_db():
|
|||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base.metadata.create_all(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():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
+120
-1
@@ -19,7 +19,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
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 .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.progress import get_progress_manager
|
from .utils.progress import get_progress_manager
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
@@ -296,6 +296,125 @@ async def export_profile(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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
|
# GENERATION ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -159,3 +159,37 @@ class ActiveTasksResponse(BaseModel):
|
|||||||
"""Response model for active tasks."""
|
"""Response model for active tasks."""
|
||||||
downloads: List[ActiveDownloadTask]
|
downloads: List[ActiveDownloadTask]
|
||||||
generations: List[ActiveGenerationTask]
|
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]
|
||||||
|
|||||||
Generated
+383
-5
@@ -32,6 +32,28 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -56,6 +78,12 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -276,6 +304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -416,6 +446,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "coreaudio-sys"
|
name = "coreaudio-sys"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -425,6 +466,29 @@ dependencies = [
|
|||||||
"bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -540,6 +604,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -755,6 +825,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1653,6 +1729,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
@@ -1814,6 +1900,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -1929,6 +2024,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1938,7 +2047,7 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"jni-sys",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1950,6 +2059,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
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]]
|
[[package]]
|
||||||
name = "ndk-sys"
|
name = "ndk-sys"
|
||||||
version = "0.6.0+11769913"
|
version = "0.6.0+11769913"
|
||||||
@@ -1987,6 +2105,17 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
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]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -2260,6 +2389,29 @@ dependencies = [
|
|||||||
"objc2-security",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -3448,7 +3600,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -3542,6 +3694,201 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -3618,9 +3965,9 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
@@ -4498,12 +4845,14 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
"objc",
|
"objc",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"symphonia",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
@@ -4816,6 +5165,16 @@ dependencies = [
|
|||||||
"windows-version",
|
"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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -4859,6 +5218,16 @@ dependencies = [
|
|||||||
"windows-core 0.62.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4961,6 +5330,15 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5316,7 +5694,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"kuchikiki",
|
"kuchikiki",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ serde_json = "1.0"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
cpal = "0.15"
|
||||||
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,468 @@
|
|||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{Device, Host, SampleFormat, Stream, StreamConfig};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::sync::atomic::{AtomicBool, 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,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<AudioOutputDevice>, 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<u8>,
|
||||||
|
device_ids: Vec<String>,
|
||||||
|
) -> 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<Device> = 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());
|
||||||
|
eprintln!("Found device: {} (id: {})", name, id);
|
||||||
|
if device_ids.contains(&id) {
|
||||||
|
eprintln!(" -> Matched! Will play to this device");
|
||||||
|
Some(device)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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());
|
||||||
|
|
||||||
|
// 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.stop_flag.clone())
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_wav(&self, data: &[u8]) -> Result<(Vec<f32>, u32, u16), String> {
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
|
||||||
|
eprintln!("decode_wav: Creating MediaSourceStream from {} bytes", data.len());
|
||||||
|
let mss = MediaSourceStream::new(
|
||||||
|
Box::new(std::io::Cursor::new(data.to_vec())),
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
eprintln!("decode_wav: Probing audio format...");
|
||||||
|
let mut format = symphonia::default::get_probe()
|
||||||
|
.format(
|
||||||
|
&Default::default(),
|
||||||
|
mss,
|
||||||
|
&FormatOptions::default(),
|
||||||
|
&MetadataOptions::default(),
|
||||||
|
)
|
||||||
|
.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_else(|| {
|
||||||
|
eprintln!("decode_wav: No audio track found");
|
||||||
|
"No audio track found".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let sample_rate = track
|
||||||
|
.codec_params
|
||||||
|
.sample_rate
|
||||||
|
.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_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| {
|
||||||
|
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(e) => {
|
||||||
|
eprintln!("decode_wav: End of stream or error: {:?}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
packet_count += 1;
|
||||||
|
let decoded = decoder
|
||||||
|
.decode(&packet)
|
||||||
|
.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;
|
||||||
|
|
||||||
|
let spec = *decoded.spec();
|
||||||
|
let num_channels = spec.channels.count();
|
||||||
|
let num_frames = decoded.frames();
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_to_device(
|
||||||
|
&self,
|
||||||
|
device: &Device,
|
||||||
|
samples: Vec<f32>,
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
) -> 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))?;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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<Mutex<Vec<f32>>> = 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 stop_flag_clone = stop_flag.clone();
|
||||||
|
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| {
|
||||||
|
// 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() {
|
||||||
|
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| {
|
||||||
|
// 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() {
|
||||||
|
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| {
|
||||||
|
// 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() {
|
||||||
|
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()),
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
eprintln!("play_to_device: Function completed successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resample(&self, samples: &[f32], from_rate: u32, to_rate: u32) -> Vec<f32> {
|
||||||
|
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<f32> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod audio_capture;
|
mod audio_capture;
|
||||||
|
mod audio_output;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent};
|
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent};
|
||||||
@@ -368,6 +369,29 @@ fn is_system_audio_supported() -> bool {
|
|||||||
audio_capture::is_supported()
|
audio_capture::is_supported()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
fn list_audio_output_devices(
|
||||||
|
state: State<'_, audio_output::AudioOutputState>,
|
||||||
|
) -> Result<Vec<audio_output::AudioOutputDevice>, String> {
|
||||||
|
state.list_output_devices()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn play_audio_to_devices(
|
||||||
|
state: State<'_, audio_output::AudioOutputState>,
|
||||||
|
audio_data: Vec<u8>,
|
||||||
|
device_ids: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -380,6 +404,7 @@ pub fn run() {
|
|||||||
keep_running_on_close: Mutex::new(false),
|
keep_running_on_close: Mutex::new(false),
|
||||||
})
|
})
|
||||||
.manage(audio_capture::AudioCaptureState::new())
|
.manage(audio_capture::AudioCaptureState::new())
|
||||||
|
.manage(audio_output::AudioOutputState::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -423,7 +448,10 @@ pub fn run() {
|
|||||||
set_keep_server_running,
|
set_keep_server_running,
|
||||||
start_system_audio_capture,
|
start_system_audio_capture,
|
||||||
stop_system_audio_capture,
|
stop_system_audio_capture,
|
||||||
is_system_audio_supported
|
is_system_audio_supported,
|
||||||
|
list_audio_output_devices,
|
||||||
|
play_audio_to_devices,
|
||||||
|
stop_audio_playback
|
||||||
])
|
])
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
|||||||
Reference in New Issue
Block a user