mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7a5a492e | ||
|
|
cb44377b09 | ||
|
|
a42a946586 | ||
|
|
a3cbe7f2b6 | ||
|
|
d8d9eeaa6a | ||
|
|
f7cb219f6d | ||
|
|
7f18c09628 | ||
|
|
d9c7121c5b | ||
|
|
008b58f91c | ||
|
|
c7404411d5 | ||
|
|
ac08c4fcf4 | ||
|
|
3ce7498495 | ||
|
|
ce2f09d29e | ||
|
|
f1be633dca | ||
|
|
5f58c4dc3d | ||
|
|
892f363e3a | ||
|
|
535cf362de | ||
|
|
f1116e05a6 | ||
|
|
f9aca9d418 | ||
|
|
d913a9ae2a | ||
|
|
36031a0df5 | ||
|
|
e59f86aa63 | ||
|
|
b59c0f44e5 | ||
|
|
f8c5e54962 | ||
|
|
8b67faf96d | ||
|
|
30ea627ae8 | ||
|
|
cd77b80b4f | ||
|
|
12174010f9 | ||
|
|
57c68040bc | ||
|
|
d65704aa68 | ||
|
|
83a6aca1ff | ||
|
|
f18abc0da6 | ||
|
|
0f616ff1c1 | ||
|
|
7c4b1d4dd2 | ||
|
|
48acc10422 | ||
|
|
1fdf61ca2e | ||
|
|
6a0601bd6c | ||
|
|
88d41f342b | ||
|
|
b1cf7926c7 | ||
|
|
834323068d | ||
|
|
8cd868d33f | ||
|
|
446182e16c | ||
|
|
c7c401b98c | ||
|
|
595d735143 |
@@ -0,0 +1,39 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.3
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
tag_message = Release v{new_version}
|
||||
message = Bump version: {current_version} → {new_version}
|
||||
|
||||
[bumpversion:file:tauri/src-tauri/tauri.conf.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:tauri/src-tauri/Cargo.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:app/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:tauri/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:landing/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:web/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:backend/main.py]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
+29
-5
@@ -321,11 +321,35 @@ Currently, testing is primarily manual. When adding tests:
|
||||
|
||||
Releases are managed by maintainers:
|
||||
|
||||
1. Version bump in `tauri.conf.json` and `Cargo.toml`
|
||||
2. Update CHANGELOG.md
|
||||
3. Create git tag: `git tag v0.2.0`
|
||||
4. Push tag: `git push --tags`
|
||||
5. GitHub Actions builds and releases
|
||||
1. **Bump version using bumpversion:**
|
||||
```bash
|
||||
# Install bumpversion (if not already installed)
|
||||
pip install bumpversion
|
||||
|
||||
# Bump patch version (0.1.0 -> 0.1.1)
|
||||
bumpversion patch
|
||||
|
||||
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||
bumpversion minor
|
||||
|
||||
# Or bump major version (0.1.0 -> 1.0.0)
|
||||
bumpversion major
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
||||
- Creates a git commit with the version bump
|
||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||
|
||||
2. **Update CHANGELOG.md** with release notes
|
||||
|
||||
3. **Push commits and tags:**
|
||||
```bash
|
||||
git push
|
||||
git push --tags
|
||||
```
|
||||
|
||||
4. **GitHub Actions builds and releases** automatically when tags are pushed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+45
-64
@@ -1,21 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { GenerationForm } from '@/components/Generation/GenerationForm';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { 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 ShinyText from '@/components/ShinyText';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||
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 { useRestoreActiveTasks, MODEL_DISPLAY_NAMES } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
import { isMacOS, isTauri, setupWindowCloseHandler, startServer } from '@/lib/tauri';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
import {
|
||||
isMacOS,
|
||||
isTauri,
|
||||
setKeepServerRunning,
|
||||
setupWindowCloseHandler,
|
||||
startServer,
|
||||
} from '@/lib/tauri';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
// Track if server is starting to prevent duplicate starts
|
||||
let serverStarting = false;
|
||||
@@ -51,6 +58,16 @@ function App() {
|
||||
// Monitor active downloads/generations and show toasts for them
|
||||
const activeDownloads = useRestoreActiveTasks();
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (isTauri()) {
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
setKeepServerRunning(keepRunning).catch((error) => {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
@@ -83,8 +100,10 @@ function App() {
|
||||
console.log('Production mode: Starting bundled server...');
|
||||
|
||||
startServer(false)
|
||||
.then(() => {
|
||||
console.log('Server is ready');
|
||||
.then((serverUrl) => {
|
||||
console.log('Server is ready at:', serverUrl);
|
||||
// Update the server URL in the store with the dynamically assigned port
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
@@ -121,7 +140,12 @@ function App() {
|
||||
// Show loading screen while server is starting in Tauri
|
||||
if (isTauri() && !serverReady) {
|
||||
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 />
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center relative">
|
||||
@@ -149,64 +173,21 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden pt-12">
|
||||
<TitleBarDragRegion />
|
||||
<AppFrame>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
|
||||
|
||||
<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">
|
||||
<UpdateNotification />
|
||||
|
||||
{activeTab === 'settings' ? (
|
||||
<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 />}
|
||||
<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">
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'main' && <MainEditor />}
|
||||
{activeTab === 'voices' && <VoicesTab />}
|
||||
{activeTab === 'audio' && <AudioTab />}
|
||||
{activeTab === 'server' && <ServerTab />}
|
||||
{activeTab === 'models' && <ModelsTab />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Audio Player - always visible except on settings */}
|
||||
{activeTab !== 'settings' && <AudioPlayer />}
|
||||
|
||||
{/* Show download toasts for any active downloads (from anywhere) */}
|
||||
{activeDownloads.map((download) => {
|
||||
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
|
||||
@@ -220,7 +201,7 @@ function App() {
|
||||
})}
|
||||
|
||||
<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 { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
@@ -10,6 +14,7 @@ export function AudioPlayer() {
|
||||
const {
|
||||
audioUrl,
|
||||
audioId,
|
||||
profileId,
|
||||
title,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -23,13 +28,58 @@ export function AudioPlayer() {
|
||||
setVolume,
|
||||
toggleLoop,
|
||||
clearRestartFlag,
|
||||
reset,
|
||||
} = 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 wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const previousAudioIdRef = useRef<string | null>(null);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const isUsingNativePlaybackRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -122,7 +172,7 @@ export function AudioPlayer() {
|
||||
});
|
||||
|
||||
// Update store when duration is loaded
|
||||
wavesurfer.on('ready', () => {
|
||||
wavesurfer.on('ready', async () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
@@ -136,14 +186,188 @@ export function AudioPlayer() {
|
||||
wavesurfer.setVolume(currentVolume);
|
||||
|
||||
// Get the underlying audio element and ensure it's not muted
|
||||
// (unless we're using native playback, which will be set later)
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
|
||||
// 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
|
||||
setTimeout(() => {
|
||||
wavesurfer.play().catch((error) => {
|
||||
@@ -156,13 +380,27 @@ export function AudioPlayer() {
|
||||
// Handle play/pause
|
||||
wavesurfer.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
// Ensure audio element is not muted when playing
|
||||
// Ensure audio element volume is set correctly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
// Double-check: if using native playback, keep WaveSurfer muted
|
||||
// Otherwise, ensure it's unmuted
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
console.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
||||
} else {
|
||||
// Ensure WaveSurfer is unmuted for normal playback
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
console.log(
|
||||
'Playing (normal mode) - volume:',
|
||||
mediaElement.volume,
|
||||
'muted:',
|
||||
mediaElement.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||
@@ -268,10 +506,35 @@ export function AudioPlayer() {
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setError(null);
|
||||
// Reset native playback flag
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop native playback if it was active
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
(async () => {
|
||||
try {
|
||||
await invoke('stop_audio_playback');
|
||||
console.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop native playback:', error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
// Also unmute WaveSurfer if it was muted
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = usePlayerStore.getState().volume;
|
||||
}
|
||||
}
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
|
||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||
// This must happen BEFORE any early returns
|
||||
console.log('Audio URL changed to:', audioUrl);
|
||||
@@ -352,9 +615,16 @@ export function AudioPlayer() {
|
||||
// Also ensure the underlying audio element volume is set
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
mediaElement.volume = 0;
|
||||
mediaElement.muted = true;
|
||||
console.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||
} else {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [volume]);
|
||||
@@ -388,14 +658,16 @@ export function AudioPlayer() {
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
|
||||
// Clear the restart flag
|
||||
clearRestartFlag();
|
||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||
|
||||
// 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) {
|
||||
console.error('WaveSurfer not initialized');
|
||||
return;
|
||||
@@ -408,9 +680,86 @@ export function AudioPlayer() {
|
||||
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()) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
// Ensure WaveSurfer is not muted if not using native playback
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
mediaElement.volume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
console.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
@@ -429,6 +778,22 @@ export function AudioPlayer() {
|
||||
setVolume(value[0] / 100);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Stop any native playback
|
||||
if (isUsingNativePlaybackRef.current && isTauri()) {
|
||||
invoke('stop_audio_playback').catch((error) => {
|
||||
console.error('Failed to stop native playback:', error);
|
||||
});
|
||||
}
|
||||
// Stop WaveSurfer
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.pause();
|
||||
wavesurferRef.current.seekTo(0);
|
||||
}
|
||||
// Reset player state
|
||||
reset();
|
||||
};
|
||||
|
||||
// Don't render if no audio
|
||||
if (!audioUrl) {
|
||||
return null;
|
||||
@@ -509,6 +874,17 @@ export function AudioPlayer() {
|
||||
className="flex-1"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Voice generation components
|
||||
@@ -0,0 +1,282 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
const generationSchema = z.object({
|
||||
text: z.string().min(1, 'Text is required').max(5000),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||
});
|
||||
|
||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||
|
||||
interface FloatingGenerateBoxProps {
|
||||
isPlayerOpen: boolean;
|
||||
}
|
||||
|
||||
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const generation = useGeneration();
|
||||
const { toast } = useToast();
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModelName || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModelName,
|
||||
});
|
||||
|
||||
const form = useForm<GenerationFormValues>({
|
||||
resolver: zodResolver(generationSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'en',
|
||||
modelSize: '1.7B',
|
||||
},
|
||||
});
|
||||
|
||||
// Click away handler to collapse the box
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't collapse if clicking inside the container
|
||||
if (containerRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
|
||||
if (
|
||||
target.closest('[role="listbox"]') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(false);
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
async function onSubmit(data: GenerationFormValues) {
|
||||
if (!selectedProfileId) {
|
||||
toast({
|
||||
title: 'No profile selected',
|
||||
description: 'Please select a voice profile from the cards above.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
const modelName = `qwen-tts-${data.modelSize}`;
|
||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||
|
||||
try {
|
||||
const modelStatus = await apiClient.getModelStatus();
|
||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||
|
||||
if (model && !model.downloaded) {
|
||||
setDownloadingModelName(modelName);
|
||||
setDownloadingDisplayName(displayName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check model status:', error);
|
||||
}
|
||||
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
language: data.language,
|
||||
model_size: data.modelSize,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Generation complete!',
|
||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||
});
|
||||
|
||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||
|
||||
form.reset();
|
||||
setIsExpanded(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Generation failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setDownloadingModelName(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
|
||||
style={{
|
||||
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex gap-2">
|
||||
<motion.div
|
||||
className="flex-1"
|
||||
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={
|
||||
selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'
|
||||
}
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
height: isExpanded ? '100px' : '32px',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={generation.isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
|
||||
size="icon"
|
||||
>
|
||||
{generation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className=" mt-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelSize"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
|
||||
Qwen3-TTS 1.7B
|
||||
</SelectItem>
|
||||
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
|
||||
Qwen3-TTS 0.6B
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function GenerationForm() {
|
||||
|
||||
// Autoplay the generated audio
|
||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||
setAudio(audioUrl, result.id, data.text.substring(0, 50));
|
||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteGeneration,
|
||||
useExportGeneration,
|
||||
@@ -33,7 +34,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||
export function HistoryTable() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [page, _setPage] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -69,14 +70,14 @@ export function HistoryTable() {
|
||||
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handlePlay = (audioId: string, text: string) => {
|
||||
const handlePlay = (audioId: string, text: string, profileId: string) => {
|
||||
// If clicking the same audio, restart it from the beginning
|
||||
if (currentAudioId === audioId) {
|
||||
restartCurrentAudio();
|
||||
} else {
|
||||
// Otherwise, load the new audio
|
||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||
setAudio(audioUrl, audioId, text.substring(0, 50));
|
||||
setAudio(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,7 +144,7 @@ export function HistoryTable() {
|
||||
|
||||
const history = historyData?.items || [];
|
||||
const total = historyData?.total || 0;
|
||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
@@ -176,8 +177,8 @@ export function HistoryTable() {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2',
|
||||
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{history.map((gen) => {
|
||||
@@ -195,7 +196,7 @@ export function HistoryTable() {
|
||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
handlePlay(gen.id, gen.text);
|
||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||
}}
|
||||
>
|
||||
{/* Waveform icon */}
|
||||
@@ -242,7 +243,9 @@ export function HistoryTable() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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
|
||||
</DropdownMenuItem>
|
||||
@@ -275,24 +278,6 @@ export function HistoryTable() {
|
||||
);
|
||||
})}
|
||||
</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 +0,0 @@
|
||||
# Server settings and connection components
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { setKeepServerRunning } from '@/lib/tauri';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
@@ -69,7 +70,7 @@ export function ConnectionForm() {
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://localhost:8000" {...field} />
|
||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -88,6 +89,9 @@ export function ConnectionForm() {
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
|
||||
@@ -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 { apiClient } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Download, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -18,6 +11,13 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
@@ -197,8 +197,8 @@ export function ModelManagement() {
|
||||
{modelToDelete?.sizeMb && (
|
||||
<>
|
||||
{' '}
|
||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model
|
||||
will need to be re-downloaded if you want to use it again.
|
||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
|
||||
need to be re-downloaded if you want to use it again.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
@@ -244,13 +244,7 @@ interface ModelItemProps {
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({
|
||||
model,
|
||||
onDownload,
|
||||
onDelete,
|
||||
isDownloading,
|
||||
formatSize,
|
||||
}: ModelItemProps) {
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
@@ -277,14 +271,12 @@ function ModelItem({
|
||||
{model.downloaded ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={model.loaded}
|
||||
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 { Progress } from '@/components/ui/progress';
|
||||
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 { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
@@ -63,13 +63,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'complete':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
@@ -43,7 +43,6 @@ export function ServerStatus() {
|
||||
) : health ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">Connected</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { RefreshCw, Download, AlertCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
export function UpdateStatus() {
|
||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,7 +30,7 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.installing}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
@@ -53,7 +53,7 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.installing && (
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -64,34 +64,57 @@ export function UpdateStatus() {
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Install Update
|
||||
Download Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
{status.downloadProgress !== undefined && (
|
||||
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress />
|
||||
<Progress value={status.downloadProgress} />
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.installing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Installing update...
|
||||
{status.readyToInstall && (
|
||||
<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>
|
||||
<div className="font-semibold">Update Ready to Install</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Version {status.version} has been downloaded
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">App will restart automatically</div>
|
||||
<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.
|
||||
</div>
|
||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Restart Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||
<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
|
||||
</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 { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
@@ -11,8 +11,11 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', icon: Volume2, label: 'Main' },
|
||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||
{ id: 'main', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', icon: Box, label: 'Models' },
|
||||
{ id: 'server', icon: Server, label: 'Server' },
|
||||
];
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useAutoUpdater } from '../hooks/useAutoUpdater';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Progress } from './ui/progress';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
|
||||
|
||||
if (status.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!status.available && !status.checking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status.checking) {
|
||||
return (
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<span className="text-sm">Checking for updates...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.available) {
|
||||
return (
|
||||
<Card className="p-4 mb-4 border-primary">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">Update Available</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Version {status.version} is ready to install
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Downloading update...</p>
|
||||
<Progress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.installing && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Installing update...</p>
|
||||
<p className="text-xs text-muted-foreground">App will restart automatically</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.downloading && !status.installing && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
Install Now
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Voice profile management components
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
interface AudioSampleRecordingProps {
|
||||
file: File | null | undefined;
|
||||
isRecording: boolean;
|
||||
duration: number;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onCancel: () => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isTranscribing?: boolean;
|
||||
}
|
||||
|
||||
export function AudioSampleRecording({
|
||||
file,
|
||||
isRecording,
|
||||
duration,
|
||||
onStart,
|
||||
onStop,
|
||||
onCancel,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleRecordingProps) {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Record Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
interface AudioSampleSystemProps {
|
||||
file: File | null | undefined;
|
||||
isRecording: boolean;
|
||||
duration: number;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onCancel: () => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isTranscribing?: boolean;
|
||||
}
|
||||
|
||||
export function AudioSampleSystem({
|
||||
file,
|
||||
isRecording,
|
||||
duration,
|
||||
onStart,
|
||||
onStop,
|
||||
onCancel,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleSystemProps) {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Capture System Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Start Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Capture audio from your system. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Capture
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Capture Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
|
||||
interface AudioSampleUploadProps {
|
||||
file: File | null | undefined;
|
||||
onFileChange: (file: File | undefined) => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isValidating?: boolean;
|
||||
isTranscribing?: boolean;
|
||||
isDisabled?: boolean;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export function AudioSampleUpload({
|
||||
file,
|
||||
onFileChange,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isValidating = false,
|
||||
isTranscribing = false,
|
||||
isDisabled = false,
|
||||
fieldName,
|
||||
}: AudioSampleUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
name={fieldName}
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
onFileChange(selectedFile);
|
||||
} else {
|
||||
onFileChange(undefined);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile?.type.startsWith('audio/')) {
|
||||
onFileChange(droppedFile);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${
|
||||
file
|
||||
? 'border-primary bg-primary/5'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
{!file ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
Choose File
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing || isValidating || isDisabled}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onFileChange(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,14 +16,12 @@ import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileDetail } from './ProfileDetail';
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: VoiceProfileResponse;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
@@ -85,14 +83,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Eye}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
aria-label="View details"
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
@@ -117,8 +107,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
interface ProfileDetailProps {
|
||||
profileId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
|
||||
const { data: profile, isLoading } = useProfile(profileId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<div className="text-muted-foreground">Loading profile...</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profile.name}</DialogTitle>
|
||||
<DialogDescription>Manage samples and view profile details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{profile.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-1">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{profile.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{profile.language}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formatDate(profile.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<SampleList profileId={profileId} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -27,66 +27,83 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import {
|
||||
useAddSample,
|
||||
useCreateProfile,
|
||||
useProfile,
|
||||
useUpdateProfile,
|
||||
useAddSample,
|
||||
} from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { Mic, Square, Upload } from 'lucide-react';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
// Helper function to get audio duration from File
|
||||
async function getAudioDuration(file: File): Promise<number> {
|
||||
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
||||
// If the file has a recordedDuration property (from our recording hooks),
|
||||
// use that instead of trying to read metadata. This fixes issues on Windows
|
||||
// where WebM files from MediaRecorder don't have proper duration metadata.
|
||||
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
||||
return file.recordedDuration;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(audio.duration);
|
||||
// Check if duration is valid (not Infinity or NaN)
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
resolve(audio.duration);
|
||||
} else {
|
||||
reject(new Error('Audio file has invalid duration metadata'));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load audio file'));
|
||||
});
|
||||
|
||||
|
||||
audio.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||
|
||||
const profileSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
// Sample fields - only required when creating (not editing)
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If sample file is provided, reference text is required
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Reference text is required when adding a sample',
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const profileSchema = baseProfileSchema.refine(
|
||||
(data) => {
|
||||
// If sample file is provided, reference text is required
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Reference text is required when adding a sample',
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
|
||||
@@ -101,9 +118,10 @@ export function ProfileForm() {
|
||||
const addSample = useAddSample();
|
||||
const transcribe = useTranscription();
|
||||
const { toast } = useToast();
|
||||
const [sampleMode, setSampleMode] = useState<'upload' | 'record'>('upload');
|
||||
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload');
|
||||
const [audioDuration, setAudioDuration] = useState<number | null>(null);
|
||||
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
|
||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||
const isCreating = !editingProfileId;
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
@@ -112,6 +130,8 @@ export function ProfileForm() {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
sampleFile: undefined,
|
||||
referenceText: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,7 +141,7 @@ export function ProfileForm() {
|
||||
useEffect(() => {
|
||||
if (selectedFile && selectedFile instanceof File) {
|
||||
setIsValidatingAudio(true);
|
||||
getAudioDuration(selectedFile)
|
||||
getAudioDuration(selectedFile as File & { recordedDuration?: number })
|
||||
.then((duration) => {
|
||||
setAudioDuration(duration);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
@@ -136,10 +156,19 @@ export function ProfileForm() {
|
||||
.catch((error) => {
|
||||
console.error('Failed to get audio duration:', error);
|
||||
setAudioDuration(null);
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
});
|
||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
||||
const isRecordedFile =
|
||||
selectedFile.name.startsWith('recording-') ||
|
||||
selectedFile.name.startsWith('system-audio-');
|
||||
if (!isRecordedFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
});
|
||||
} else {
|
||||
// Clear any existing errors for recorded files
|
||||
form.clearErrors('sampleFile');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsValidatingAudio(false);
|
||||
@@ -159,10 +188,14 @@ export function ProfileForm() {
|
||||
cancelRecording,
|
||||
} = useAudioRecording({
|
||||
maxDurationSeconds: 30,
|
||||
onRecordingComplete: (blob) => {
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
type: blob.type || 'audio/webm',
|
||||
});
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
@@ -171,6 +204,32 @@ export function ProfileForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isRecording: isSystemRecording,
|
||||
duration: systemDuration,
|
||||
error: systemRecordingError,
|
||||
isSupported: isSystemAudioSupported,
|
||||
startRecording: startSystemRecording,
|
||||
stopRecording: stopSystemRecording,
|
||||
cancelRecording: cancelSystemRecording,
|
||||
} = useSystemAudioCapture({
|
||||
maxDurationSeconds: 30,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||
type: blob.type || 'audio/wav',
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Show recording errors
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
@@ -182,6 +241,17 @@ export function ProfileForm() {
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProfile) {
|
||||
form.reset({
|
||||
@@ -219,11 +289,6 @@ export function ProfileForm() {
|
||||
const result = await transcribe.mutateAsync({ file, language });
|
||||
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
|
||||
toast({
|
||||
title: 'Transcription complete',
|
||||
description: 'Audio has been transcribed successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
@@ -234,8 +299,18 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
function handleCancelRecording() {
|
||||
cancelRecording();
|
||||
if (sampleMode === 'record') {
|
||||
cancelRecording();
|
||||
} else if (sampleMode === 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
form.resetField('sampleFile');
|
||||
cleanupAudio();
|
||||
}
|
||||
|
||||
function handlePlayPause() {
|
||||
const file = form.getValues('sampleFile');
|
||||
playPause(file);
|
||||
}
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
@@ -251,75 +326,91 @@ export function ProfileForm() {
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
title: 'Voice updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
});
|
||||
} else {
|
||||
// Get file and reference text directly from form state to ensure we have the values
|
||||
// Creating: require sample file and reference text
|
||||
const sampleFile = form.getValues('sampleFile');
|
||||
const referenceText = form.getValues('referenceText');
|
||||
|
||||
if (!sampleFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Audio sample is required',
|
||||
});
|
||||
toast({
|
||||
title: 'Audio sample required',
|
||||
description: 'Please provide an audio sample to create the voice profile.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!referenceText || referenceText.trim().length === 0) {
|
||||
form.setError('referenceText', {
|
||||
type: 'manual',
|
||||
message: 'Reference text is required',
|
||||
});
|
||||
toast({
|
||||
title: 'Reference text required',
|
||||
description: 'Please provide the reference text for the audio sample.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate audio duration before creating profile
|
||||
if (sampleFile) {
|
||||
try {
|
||||
const duration = await getAudioDuration(sampleFile);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Invalid audio file',
|
||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
const duration = await getAudioDuration(sampleFile);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Validation error',
|
||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||
title: 'Invalid audio file',
|
||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
});
|
||||
toast({
|
||||
title: 'Validation error',
|
||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
}
|
||||
|
||||
// Creating: create profile, then optionally add sample
|
||||
// Creating: create profile, then add sample
|
||||
const profile = await createProfile.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
language: data.language,
|
||||
});
|
||||
|
||||
// If sample file and reference text provided, add it
|
||||
if (sampleFile && referenceText && referenceText.trim().length > 0) {
|
||||
try {
|
||||
await addSample.mutateAsync({
|
||||
profileId: profile.id,
|
||||
file: sampleFile,
|
||||
referenceText: referenceText,
|
||||
});
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
});
|
||||
} catch (sampleError) {
|
||||
// Profile was created but sample failed - still show success for profile
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await addSample.mutateAsync({
|
||||
profileId: profile.id,
|
||||
file: sampleFile,
|
||||
referenceText: referenceText,
|
||||
});
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created successfully. You can add samples later.`,
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
});
|
||||
} catch (sampleError) {
|
||||
// Profile was created but sample failed - still show error
|
||||
toast({
|
||||
title: 'Failed to add sample',
|
||||
description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -345,6 +436,10 @@ export function ProfileForm() {
|
||||
if (isRecording) {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording) {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
cleanupAudio();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,17 +447,17 @@ export function ProfileForm() {
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
||||
<DialogTitle>{editingProfileId ? 'Edit Voice' : 'Create Voice Profile'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details.'
|
||||
: 'Create a new voice profile. You can add a sample now or later.'}
|
||||
? 'Update your voice profile details and manage samples.'
|
||||
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<div className="grid gap-6 grid-cols-2">
|
||||
{/* Left column: Profile info */}
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
@@ -419,223 +514,144 @@ export function ProfileForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Sample upload section - only show when creating */}
|
||||
{isCreating && (
|
||||
<div className="space-y-4 border-l pl-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add an audio sample to get started immediately. You can add more samples
|
||||
later.
|
||||
</p>
|
||||
</div>
|
||||
{/* Right column: Sample management */}
|
||||
<div className="space-y-4 border-l pl-6">
|
||||
{isCreating ? (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Provide an audio sample to clone the voice. You can add more samples later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={sampleMode}
|
||||
onValueChange={(v) => setSampleMode(v as 'upload' | 'record')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={({ field: { onChange, name, ref } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
name={name}
|
||||
ref={ref}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
} else {
|
||||
onChange(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<>
|
||||
{isValidatingAudio && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Validating audio...
|
||||
</p>
|
||||
)}
|
||||
{!isValidatingAudio && audioDuration !== null && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span
|
||||
className={
|
||||
audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||
? 'text-destructive font-medium'
|
||||
: 'text-foreground'
|
||||
}
|
||||
>
|
||||
{formatAudioDuration(audioDuration)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
/ {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supported formats: WAV, MP3, M4A. Maximum duration:{' '}
|
||||
{formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe"
|
||||
to automatically extract text from the audio.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<Tabs
|
||||
value={sampleMode}
|
||||
onValueChange={(v) => {
|
||||
const newMode = v as 'upload' | 'record' | 'system';
|
||||
// Cancel any active recordings when switching modes
|
||||
if (isRecording && newMode !== 'record') {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording && newMode !== 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
setSampleMode(newMode);
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="record" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Record Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !selectedFile && (
|
||||
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startRecording}
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={({ field: { onChange, name } }) => (
|
||||
<AudioSampleUpload
|
||||
file={selectedFile}
|
||||
onFileChange={onChange}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isValidating={isValidatingAudio}
|
||||
isTranscribing={transcribe.isPending}
|
||||
isDisabled={
|
||||
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||
}
|
||||
fieldName={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
||||
remaining)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<TabsContent value="record" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleRecording
|
||||
file={selectedFile}
|
||||
isRecording={isRecording}
|
||||
duration={duration}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{selectedFile && !isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
File: {selectedFile.name}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelRecording}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Record audio directly from your microphone. Maximum duration is 30
|
||||
seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampleFile"
|
||||
render={() => (
|
||||
<AudioSampleSystem
|
||||
file={selectedFile}
|
||||
isRecording={isSystemRecording}
|
||||
duration={systemDuration}
|
||||
onStart={startSystemRecording}
|
||||
onStop={stopSystemRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This should match exactly what is spoken in the audio file. Required if
|
||||
you add a sample.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Show sample list when editing
|
||||
editingProfileId && (
|
||||
<div>
|
||||
<SampleList profileId={editingProfileId} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
@@ -649,7 +665,7 @@ export function ProfileForm() {
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
: editingProfileId
|
||||
? 'Update Profile'
|
||||
? 'Save Changes'
|
||||
: 'Create Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Mic, Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useImportProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
@@ -18,44 +9,6 @@ import { ProfileForm } from './ProfileForm';
|
||||
export function ProfileList() {
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
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) {
|
||||
return null;
|
||||
@@ -73,27 +26,6 @@ export function ProfileList() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{allProfiles.length === 0 ? (
|
||||
<Card>
|
||||
@@ -109,7 +41,7 @@ export function ProfileList() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
||||
{allProfiles.map((profile) => (
|
||||
<ProfileCard key={profile.id} profile={profile} />
|
||||
))}
|
||||
@@ -118,38 +50,6 @@ export function ProfileList() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Button type="button" size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
@@ -60,6 +60,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
||||
@@ -69,6 +70,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -13,21 +14,23 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { Mic, Square, Upload } from 'lucide-react';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
|
||||
const sampleSchema = z.object({
|
||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||
@@ -50,7 +53,8 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
const transcribe = useTranscription();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
const { toast } = useToast();
|
||||
const [mode, setMode] = useState<'upload' | 'record'>('upload');
|
||||
const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload');
|
||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||
|
||||
const form = useForm<SampleFormValues>({
|
||||
resolver: zodResolver(sampleSchema),
|
||||
@@ -70,11 +74,15 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
cancelRecording,
|
||||
} = useAudioRecording({
|
||||
maxDurationSeconds: 30,
|
||||
onRecordingComplete: (blob) => {
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
type: blob.type || 'audio/webm',
|
||||
});
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('file', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
@@ -83,6 +91,33 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isRecording: isSystemRecording,
|
||||
duration: systemDuration,
|
||||
error: systemRecordingError,
|
||||
isSupported: isSystemAudioSupported,
|
||||
startRecording: startSystemRecording,
|
||||
stopRecording: stopSystemRecording,
|
||||
cancelRecording: cancelSystemRecording,
|
||||
} = useSystemAudioCapture({
|
||||
maxDurationSeconds: 30,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||
type: blob.type || 'audio/wav',
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('file', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Show recording errors
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
@@ -94,6 +129,17 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
|
||||
async function handleTranscribe() {
|
||||
const file = form.getValues('file');
|
||||
if (!file) {
|
||||
@@ -110,11 +156,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
const result = await transcribe.mutateAsync({ file, language });
|
||||
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
|
||||
toast({
|
||||
title: 'Transcription complete',
|
||||
description: 'Audio has been transcribed successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
@@ -154,14 +195,27 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
if (isRecording) {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording) {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
cleanupAudio();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
|
||||
function handleCancelRecording() {
|
||||
cancelRecording();
|
||||
// Reset file field by clearing the input
|
||||
if (mode === 'record') {
|
||||
cancelRecording();
|
||||
} else if (mode === 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
form.resetField('file');
|
||||
cleanupAudio();
|
||||
}
|
||||
|
||||
function handlePlayPause() {
|
||||
const file = form.getValues('file');
|
||||
playPause(file);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -176,58 +230,40 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||
<TabsList
|
||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4" />
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
|
||||
extract text from the audio.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
render={({ field: { onChange, name } }) => (
|
||||
<AudioSampleUpload
|
||||
file={selectedFile}
|
||||
onFileChange={onChange}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
fieldName={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -237,94 +273,44 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Record Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !selectedFile && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startRecording}
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
||||
remaining)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && !isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File: {selectedFile.name}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelRecording}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Record audio directly from your microphone. Maximum duration is 30 seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<AudioSampleRecording
|
||||
file={selectedFile}
|
||||
isRecording={isRecording}
|
||||
duration={duration}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isTauri() && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={() => (
|
||||
<AudioSampleSystem
|
||||
file={selectedFile}
|
||||
isRecording={isSystemRecording}
|
||||
duration={systemDuration}
|
||||
onStart={startSystemRecording}
|
||||
onStop={stopSystemRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
@@ -340,9 +326,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This should match exactly what is spoken in the audio file.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function VoicesTab() {
|
||||
const { data: profiles, isLoading } = useProfiles();
|
||||
const { data: historyData } = useHistory({ limit: 1000 });
|
||||
const queryClient = useQueryClient();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
// Get generation counts per profile
|
||||
const generationCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
if (historyData?.items) {
|
||||
historyData.items.forEach((item) => {
|
||||
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}, [historyData]);
|
||||
|
||||
// Get channel assignments for each profile
|
||||
const { data: channelAssignments } = useQuery({
|
||||
queryKey: ['profile-channels'],
|
||||
queryFn: async () => {
|
||||
if (!profiles) return {};
|
||||
const assignments: Record<string, string[]> = {};
|
||||
for (const profile of profiles) {
|
||||
try {
|
||||
const result = await apiClient.getProfileChannels(profile.id);
|
||||
assignments[profile.id] = result.channel_ids;
|
||||
} catch {
|
||||
assignments[profile.id] = [];
|
||||
}
|
||||
}
|
||||
return assignments;
|
||||
},
|
||||
enabled: !!profiles,
|
||||
});
|
||||
|
||||
// Get all channels
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const handleEdit = (profileId: string) => {
|
||||
setEditingProfileId(profileId);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (profileId: string) => {
|
||||
if (confirm('Are you sure you want to delete this profile?')) {
|
||||
deleteProfile.mutate(profileId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||
try {
|
||||
await apiClient.setProfileChannels(profileId, channelIds);
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to update channels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading voices...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-16 relative z-0',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Generations</TableHead>
|
||||
<TableHead>Samples</TableHead>
|
||||
<TableHead>Channels</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profiles?.map((profile) => (
|
||||
<VoiceRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
generationCount={generationCounts[profile.id] || 0}
|
||||
channelIds={channelAssignments?.[profile.id] || []}
|
||||
channels={channels || []}
|
||||
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||
onEdit={() => handleEdit(profile.id)}
|
||||
onDelete={() => handleDelete(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ProfileForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceRowProps {
|
||||
profile: VoiceProfileResponse;
|
||||
generationCount: number;
|
||||
channelIds: string[];
|
||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||
onChannelChange: (channelIds: string[]) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function VoiceRow({
|
||||
profile,
|
||||
generationCount,
|
||||
channelIds,
|
||||
channels,
|
||||
onChannelChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: VoiceRowProps) {
|
||||
const { data: samples } = useProfileSamples(profile.id);
|
||||
|
||||
return (
|
||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||
<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 className="font-medium">{profile.name}</div>
|
||||
{profile.description && (
|
||||
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<MultiSelect
|
||||
options={channels.map((ch) => ({
|
||||
value: ch.id,
|
||||
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||
}))}
|
||||
value={channelIds}
|
||||
onChange={onChannelChange}
|
||||
placeholder="Select channels..."
|
||||
className="min-w-[200px]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...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);
|
||||
}
|
||||
};
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
id={id}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}}
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
onChange={handleChange}
|
||||
{...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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface UpdateStatus {
|
||||
checking: boolean;
|
||||
@@ -8,9 +8,18 @@ export interface UpdateStatus {
|
||||
version?: string;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
readyToInstall: boolean;
|
||||
error?: string;
|
||||
downloadProgress?: number; // 0-100 percentage
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
// Check if we're on Windows (NSIS installer handles restart automatically)
|
||||
const isWindows = () => {
|
||||
return navigator.userAgent.includes('Windows');
|
||||
};
|
||||
|
||||
const isTauri = () => {
|
||||
return '__TAURI_INTERNALS__' in window;
|
||||
};
|
||||
@@ -21,11 +30,12 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
@@ -43,6 +53,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
version: foundUpdate.version,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
@@ -50,6 +61,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -58,41 +70,93 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Download the update (but don't install yet)
|
||||
const downloadAndInstall = async () => {
|
||||
if (!update || !isTauri()) return;
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
let downloadedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
// Just download the update
|
||||
await update.download((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
setStatus((prev) => ({ ...prev, downloading: true }));
|
||||
totalBytes = event.data.contentLength || 0;
|
||||
downloadedBytes = 0;
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: true,
|
||||
totalBytes,
|
||||
downloadedBytes: 0,
|
||||
downloadProgress: 0,
|
||||
}));
|
||||
break;
|
||||
case 'Progress':
|
||||
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
||||
case 'Progress': {
|
||||
downloadedBytes += event.data.chunkLength;
|
||||
const progress =
|
||||
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloadedBytes,
|
||||
downloadProgress: progress,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'Finished':
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: true,
|
||||
readyToInstall: true,
|
||||
downloadProgress: 100,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
await relaunch();
|
||||
} catch (error) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
downloadProgress: undefined,
|
||||
downloadedBytes: undefined,
|
||||
totalBytes: undefined,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Install the downloaded update and restart the app
|
||||
const restartAndInstall = async () => {
|
||||
if (!update || !isTauri()) return;
|
||||
|
||||
try {
|
||||
setStatus((prev) => ({ ...prev, installing: true, error: undefined }));
|
||||
|
||||
// Install the update
|
||||
await update.install();
|
||||
|
||||
// On Windows with NSIS, the installer handles the restart automatically.
|
||||
// The process will be killed by the NSIS installer, so we won't reach here.
|
||||
// On macOS/Linux, we need to manually relaunch.
|
||||
if (!isWindows()) {
|
||||
await relaunch();
|
||||
}
|
||||
// If we're on Windows and somehow still running, the NSIS installer
|
||||
// should have already handled everything. Just wait for the process to end.
|
||||
} catch (error) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
installing: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||
}));
|
||||
}
|
||||
@@ -102,11 +166,12 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
if (checkOnMount && isTauri()) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [checkOnMount]);
|
||||
}, [checkOnMount, checkForUpdates]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
restartAndInstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -279,6 +279,88 @@ class ApiClient {
|
||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
@@ -1 +0,0 @@
|
||||
# React Query hooks will be placed here
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const playPause = (file: File | null | undefined) => {
|
||||
if (!file) return;
|
||||
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
} else {
|
||||
const audio = new Audio(URL.createObjectURL(file));
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
setIsPlaying(false);
|
||||
if (audioRef.current) {
|
||||
URL.revokeObjectURL(audioRef.current.src);
|
||||
}
|
||||
audioRef.current = null;
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
setIsPlaying(false);
|
||||
toast({
|
||||
title: 'Playback error',
|
||||
description: 'Failed to play audio file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (audioRef.current) {
|
||||
URL.revokeObjectURL(audioRef.current.src);
|
||||
}
|
||||
audioRef.current = null;
|
||||
});
|
||||
|
||||
audio.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
if (audioRef.current.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audioRef.current.src);
|
||||
}
|
||||
audioRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
playPause,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import { convertToWav } from '@/lib/utils/audio';
|
||||
|
||||
interface UseAudioRecordingOptions {
|
||||
maxDurationSeconds?: number;
|
||||
onRecordingComplete?: (blob: Blob) => void;
|
||||
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||
}
|
||||
|
||||
export function useAudioRecording({
|
||||
@@ -85,9 +86,26 @@ export function useAudioRecording({
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
onRecordingComplete?.(blob);
|
||||
mediaRecorder.onstop = async () => {
|
||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
|
||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||
try {
|
||||
const wavBlob = await convertToWav(webmBlob);
|
||||
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
||||
} catch (err) {
|
||||
console.error('Error converting audio to WAV:', err);
|
||||
// Fallback to original blob if conversion fails
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||
}
|
||||
|
||||
// Stop all tracks
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
|
||||
interface UseSystemAudioCaptureOptions {
|
||||
maxDurationSeconds?: number;
|
||||
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for native system audio capture using Tauri commands.
|
||||
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
|
||||
*/
|
||||
export function useSystemAudioCapture({
|
||||
maxDurationSeconds = 30,
|
||||
onRecordingComplete,
|
||||
}: UseSystemAudioCaptureOptions = {}) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const isRecordingRef = useRef(false);
|
||||
|
||||
// Check if system audio capture is supported
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<boolean>('is_system_audio_supported')
|
||||
.then((supported) => {
|
||||
setIsSupported(supported);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSupported(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSupported) {
|
||||
const errorMsg = 'System audio capture is not supported on this platform.';
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setDuration(0);
|
||||
|
||||
// Start native capture
|
||||
await invoke('start_system_audio_capture', {
|
||||
maxDurationSecs: maxDurationSeconds,
|
||||
});
|
||||
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
// Start timer
|
||||
timerRef.current = window.setInterval(() => {
|
||||
if (startTimeRef.current) {
|
||||
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
|
||||
// Auto-stop at max duration
|
||||
if (elapsed >= maxDurationSeconds && stopRecordingRef.current) {
|
||||
void stopRecordingRef.current();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to start system audio capture. Please check permissions.';
|
||||
setError(errorMessage);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [maxDurationSeconds, isSupported]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (!isRecording || !isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop capture and get base64 WAV data
|
||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||
|
||||
// Convert base64 to Blob
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'audio/wav' });
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
onRecordingComplete?.(blob, recordedDuration);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to stop system audio capture.';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
|
||||
// Store stopRecording in ref for use in timer
|
||||
useEffect(() => {
|
||||
stopRecordingRef.current = stopRecording;
|
||||
}, [stopRecording]);
|
||||
|
||||
const cancelRecording = useCallback(async () => {
|
||||
if (isRecordingRef.current) {
|
||||
await stopRecording();
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setDuration(0);
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, [stopRecording]);
|
||||
|
||||
// Cleanup on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Cancel recording on unmount if still recording
|
||||
if (isRecordingRef.current && isTauri()) {
|
||||
// Call stop directly without the callback to avoid stale closure
|
||||
invoke('stop_system_audio_capture').catch((err) => {
|
||||
console.error('Error stopping audio capture on unmount:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
duration,
|
||||
error,
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,21 @@ export async function stopServer(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the server should keep running when the app closes (Tauri only)
|
||||
*/
|
||||
export async function setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('set_keep_server_running', { keepRunning });
|
||||
} catch (error) {
|
||||
console.error('Failed to set keep server running setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup window close handler to check setting and stop server if needed
|
||||
*/
|
||||
|
||||
@@ -16,3 +16,104 @@ export function formatAudioDuration(seconds: number): string {
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any audio blob to WAV format using Web Audio API.
|
||||
* This ensures compatibility without requiring ffmpeg on the backend.
|
||||
*/
|
||||
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||
// Create audio context
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
// Read blob as array buffer
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
|
||||
// Decode audio data
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
// Convert to WAV
|
||||
const wavBlob = audioBufferToWav(audioBuffer);
|
||||
|
||||
// Close audio context to free resources
|
||||
await audioContext.close();
|
||||
|
||||
return wavBlob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AudioBuffer to WAV blob.
|
||||
*/
|
||||
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const format = 1; // PCM
|
||||
const bitDepth = 16;
|
||||
|
||||
const bytesPerSample = bitDepth / 8;
|
||||
const blockAlign = numberOfChannels * bytesPerSample;
|
||||
|
||||
// Interleave channels
|
||||
const interleaved = interleaveChannels(buffer);
|
||||
|
||||
// Create WAV file
|
||||
const dataLength = interleaved.length * bytesPerSample;
|
||||
const buffer2 = new ArrayBuffer(44 + dataLength);
|
||||
const view = new DataView(buffer2);
|
||||
|
||||
// Write WAV header
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // fmt chunk size
|
||||
view.setUint16(20, format, true); // audio format (PCM)
|
||||
view.setUint16(22, numberOfChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bitDepth, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
// Write audio data
|
||||
floatTo16BitPCM(view, 44, interleaved);
|
||||
|
||||
return new Blob([buffer2], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleave multiple channels into a single array.
|
||||
*/
|
||||
function interleaveChannels(buffer: AudioBuffer): Float32Array {
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const length = buffer.length;
|
||||
const interleaved = new Float32Array(length * numberOfChannels);
|
||||
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < length; i++) {
|
||||
interleaved[i * numberOfChannels + channel] = channelData[i];
|
||||
}
|
||||
}
|
||||
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write string to DataView.
|
||||
*/
|
||||
function writeString(view: DataView, offset: number, string: string): void {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert float32 audio data to 16-bit PCM.
|
||||
*/
|
||||
function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void {
|
||||
for (let i = 0; i < input.length; i++, offset += 2) {
|
||||
const s = Math.max(-1, Math.min(1, input[i]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface AudioChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
device_ids: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AudioChannelStore {
|
||||
channels: AudioChannel[];
|
||||
setChannels: (channels: AudioChannel[]) => void;
|
||||
addChannel: (channel: AudioChannel) => void;
|
||||
updateChannel: (id: string, channel: Partial<AudioChannel>) => void;
|
||||
removeChannel: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useAudioChannelStore = create<AudioChannelStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
channels: [],
|
||||
setChannels: (channels) => set({ channels }),
|
||||
addChannel: (channel) =>
|
||||
set((state) => ({
|
||||
channels: [...state.channels, channel],
|
||||
})),
|
||||
updateChannel: (id, updates) =>
|
||||
set((state) => ({
|
||||
channels: state.channels.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch)),
|
||||
})),
|
||||
removeChannel: (id) =>
|
||||
set((state) => ({
|
||||
channels: state.channels.filter((ch) => ch.id !== id),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'voicebox-audio-channels',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
||||
interface PlayerState {
|
||||
audioUrl: string | null;
|
||||
audioId: string | null;
|
||||
profileId: string | null;
|
||||
title: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
@@ -11,7 +12,7 @@ interface PlayerState {
|
||||
isLooping: boolean;
|
||||
shouldRestart: boolean;
|
||||
|
||||
setAudio: (url: string, id: string, title?: string) => void;
|
||||
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
@@ -25,6 +26,7 @@ interface PlayerState {
|
||||
export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
audioUrl: null,
|
||||
audioId: null,
|
||||
profileId: null,
|
||||
title: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
@@ -33,10 +35,11 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
isLooping: false,
|
||||
shouldRestart: false,
|
||||
|
||||
setAudio: (url, id, title) =>
|
||||
setAudio: (url, id, profileId, title) =>
|
||||
set({
|
||||
audioUrl: url,
|
||||
audioId: id,
|
||||
profileId: profileId || null,
|
||||
title: title || null,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
@@ -53,6 +56,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
set({
|
||||
audioUrl: null,
|
||||
audioId: null,
|
||||
profileId: null,
|
||||
title: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ServerStore {
|
||||
export const useServerStore = create<ServerStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
serverUrl: 'http://localhost:8000',
|
||||
serverUrl: 'http://127.0.0.1:17493',
|
||||
setServerUrl: (url) => set({ serverUrl: url }),
|
||||
|
||||
isConnected: false,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Audio channel management module.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
AudioChannelCreate,
|
||||
AudioChannelUpdate,
|
||||
AudioChannelResponse,
|
||||
ChannelVoiceAssignment,
|
||||
ProfileChannelAssignment,
|
||||
)
|
||||
from .database import (
|
||||
AudioChannel as DBAudioChannel,
|
||||
ChannelDeviceMapping as DBChannelDeviceMapping,
|
||||
ProfileChannelMapping as DBProfileChannelMapping,
|
||||
VoiceProfile as DBVoiceProfile,
|
||||
)
|
||||
|
||||
|
||||
async def list_channels(db: Session) -> List[AudioChannelResponse]:
|
||||
"""List all audio channels."""
|
||||
channels = db.query(DBAudioChannel).all()
|
||||
result = []
|
||||
|
||||
for channel in channels:
|
||||
# Get device IDs for this channel
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
result.append(AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]:
|
||||
"""Get a channel by ID."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
# Get device IDs
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def create_channel(
|
||||
data: AudioChannelCreate,
|
||||
db: Session,
|
||||
) -> AudioChannelResponse:
|
||||
"""Create a new audio channel."""
|
||||
# Check if name already exists
|
||||
existing = db.query(DBAudioChannel).filter_by(name=data.name).first()
|
||||
if existing:
|
||||
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||
|
||||
# Create channel
|
||||
channel = DBAudioChannel(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
is_default=False,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
|
||||
# Add device mappings
|
||||
for device_id in data.device_ids:
|
||||
mapping = DBChannelDeviceMapping(
|
||||
id=str(uuid.uuid4()),
|
||||
channel_id=channel.id,
|
||||
device_id=device_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
db.refresh(channel)
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=data.device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_channel(
|
||||
channel_id: str,
|
||||
data: AudioChannelUpdate,
|
||||
db: Session,
|
||||
) -> Optional[AudioChannelResponse]:
|
||||
"""Update an audio channel."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
if channel.is_default:
|
||||
raise ValueError("Cannot modify the default channel")
|
||||
|
||||
# Update name if provided
|
||||
if data.name is not None:
|
||||
# Check if name already exists (excluding current channel)
|
||||
existing = db.query(DBAudioChannel).filter(
|
||||
DBAudioChannel.name == data.name,
|
||||
DBAudioChannel.id != channel_id
|
||||
).first()
|
||||
if existing:
|
||||
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||
channel.name = data.name
|
||||
|
||||
# Update device mappings if provided
|
||||
if data.device_ids is not None:
|
||||
# Delete existing mappings
|
||||
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for device_id in data.device_ids:
|
||||
mapping = DBChannelDeviceMapping(
|
||||
id=str(uuid.uuid4()),
|
||||
channel_id=channel.id,
|
||||
device_id=device_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
db.refresh(channel)
|
||||
|
||||
# Get updated device IDs
|
||||
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||
channel_id=channel.id
|
||||
).all()
|
||||
device_ids = [m.device_id for m in device_mappings]
|
||||
|
||||
return AudioChannelResponse(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
is_default=channel.is_default,
|
||||
device_ids=device_ids,
|
||||
created_at=channel.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def delete_channel(channel_id: str, db: Session) -> bool:
|
||||
"""Delete an audio channel."""
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return False
|
||||
|
||||
if channel.is_default:
|
||||
raise ValueError("Cannot delete the default channel")
|
||||
|
||||
# Delete device mappings
|
||||
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Delete profile-channel mappings
|
||||
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Delete channel
|
||||
db.delete(channel)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
|
||||
"""Get list of profile IDs assigned to a channel."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
channel_id=channel_id
|
||||
).all()
|
||||
return [m.profile_id for m in mappings]
|
||||
|
||||
|
||||
async def set_channel_voices(
|
||||
channel_id: str,
|
||||
data: ChannelVoiceAssignment,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Set which voices are assigned to a channel."""
|
||||
# Verify channel exists
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
# Verify all profiles exist
|
||||
for profile_id in data.profile_ids:
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Delete existing mappings for this channel
|
||||
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for profile_id in data.profile_ids:
|
||||
mapping = DBProfileChannelMapping(
|
||||
profile_id=profile_id,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
|
||||
"""Get list of channel IDs assigned to a profile."""
|
||||
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||
profile_id=profile_id
|
||||
).all()
|
||||
return [m.channel_id for m in mappings]
|
||||
|
||||
|
||||
async def set_profile_channels(
|
||||
profile_id: str,
|
||||
data: ProfileChannelAssignment,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Set which channels a profile is assigned to."""
|
||||
# Verify profile exists
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
# Verify all channels exist
|
||||
for channel_id in data.channel_ids:
|
||||
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
# Delete existing mappings for this profile
|
||||
db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete()
|
||||
|
||||
# Add new mappings
|
||||
for channel_id in data.channel_ids:
|
||||
mapping = DBProfileChannelMapping(
|
||||
profile_id=profile_id,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
+53
-1
@@ -2,7 +2,7 @@
|
||||
SQLite database ORM using SQLAlchemy.
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||
from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from datetime import datetime
|
||||
@@ -62,6 +62,33 @@ class Project(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class AudioChannel(Base):
|
||||
"""Audio channel (bus) database model."""
|
||||
__tablename__ = "audio_channels"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ChannelDeviceMapping(Base):
|
||||
"""Mapping between channels and OS audio devices."""
|
||||
__tablename__ = "channel_device_mappings"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
|
||||
device_id = Column(String, nullable=False) # OS device identifier
|
||||
|
||||
|
||||
class ProfileChannelMapping(Base):
|
||||
"""Mapping between voice profiles and audio channels (many-to-many)."""
|
||||
__tablename__ = "profile_channel_mappings"
|
||||
|
||||
profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
|
||||
channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
|
||||
|
||||
|
||||
# Database setup will be initialized in init_db()
|
||||
engine = None
|
||||
SessionLocal = None
|
||||
@@ -82,6 +109,31 @@ def init_db():
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create default channel if it doesn't exist
|
||||
db = SessionLocal()
|
||||
try:
|
||||
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
|
||||
if not default_channel:
|
||||
default_channel = AudioChannel(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Default",
|
||||
is_default=True
|
||||
)
|
||||
db.add(default_channel)
|
||||
|
||||
# Assign all existing profiles to default channel
|
||||
profiles = db.query(VoiceProfile).all()
|
||||
for profile in profiles:
|
||||
mapping = ProfileChannelMapping(
|
||||
profile_id=profile.id,
|
||||
channel_id=default_channel.id
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db():
|
||||
|
||||
+138
-6
@@ -19,7 +19,7 @@ import io
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from . import database, models, profiles, history, tts, transcribe, config, export_import
|
||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels
|
||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .utils.progress import get_progress_manager
|
||||
from .utils.tasks import get_task_manager
|
||||
@@ -47,7 +47,7 @@ app.add_middleware(
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {"message": "voicebox API", "version": "0.1.0"}
|
||||
return {"message": "voicebox API", "version": "0.1.3"}
|
||||
|
||||
|
||||
@app.get("/health", response_model=models.HealthResponse)
|
||||
@@ -58,10 +58,14 @@ async def health():
|
||||
import os
|
||||
|
||||
tts_model = tts.get_tts_model()
|
||||
gpu_available = torch.cuda.is_available()
|
||||
|
||||
|
||||
# Check for GPU availability (CUDA or MPS)
|
||||
has_cuda = torch.cuda.is_available()
|
||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||
gpu_available = has_cuda or has_mps
|
||||
|
||||
vram_used = None
|
||||
if gpu_available:
|
||||
if has_cuda:
|
||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||
|
||||
# Check if model is loaded - use the same logic as model status endpoint
|
||||
@@ -292,6 +296,125 @@ async def export_profile(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================
|
||||
# AUDIO CHANNEL ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@app.get("/channels", response_model=List[models.AudioChannelResponse])
|
||||
async def list_channels(db: Session = Depends(get_db)):
|
||||
"""List all audio channels."""
|
||||
return await channels.list_channels(db)
|
||||
|
||||
|
||||
@app.post("/channels", response_model=models.AudioChannelResponse)
|
||||
async def create_channel(
|
||||
data: models.AudioChannelCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new audio channel."""
|
||||
try:
|
||||
return await channels.create_channel(data, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/channels/{channel_id}", response_model=models.AudioChannelResponse)
|
||||
async def get_channel(
|
||||
channel_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get an audio channel by ID."""
|
||||
channel = await channels.get_channel(channel_id, db)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
|
||||
@app.put("/channels/{channel_id}", response_model=models.AudioChannelResponse)
|
||||
async def update_channel(
|
||||
channel_id: str,
|
||||
data: models.AudioChannelUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an audio channel."""
|
||||
try:
|
||||
channel = await channels.update_channel(channel_id, data, db)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/channels/{channel_id}")
|
||||
async def delete_channel(
|
||||
channel_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete an audio channel."""
|
||||
try:
|
||||
success = await channels.delete_channel(channel_id, db)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return {"message": "Channel deleted successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/channels/{channel_id}/voices")
|
||||
async def get_channel_voices(
|
||||
channel_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get list of profile IDs assigned to a channel."""
|
||||
try:
|
||||
profile_ids = await channels.get_channel_voices(channel_id, db)
|
||||
return {"profile_ids": profile_ids}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.put("/channels/{channel_id}/voices")
|
||||
async def set_channel_voices(
|
||||
channel_id: str,
|
||||
data: models.ChannelVoiceAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set which voices are assigned to a channel."""
|
||||
try:
|
||||
await channels.set_channel_voices(channel_id, data, db)
|
||||
return {"message": "Channel voices updated successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/profiles/{profile_id}/channels")
|
||||
async def get_profile_channels(
|
||||
profile_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get list of channel IDs assigned to a profile."""
|
||||
try:
|
||||
channel_ids = await channels.get_profile_channels(profile_id, db)
|
||||
return {"channel_ids": channel_ids}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.put("/profiles/{profile_id}/channels")
|
||||
async def set_profile_channels(
|
||||
profile_id: str,
|
||||
data: models.ProfileChannelAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set which channels a profile is assigned to."""
|
||||
try:
|
||||
await channels.set_profile_channels(profile_id, data, db)
|
||||
return {"message": "Profile channels updated successfully"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============================================
|
||||
# GENERATION ENDPOINTS
|
||||
# ============================================
|
||||
@@ -1053,13 +1176,22 @@ async def get_active_tasks():
|
||||
# STARTUP & SHUTDOWN
|
||||
# ============================================
|
||||
|
||||
def _get_gpu_status() -> str:
|
||||
"""Get GPU availability status."""
|
||||
if torch.cuda.is_available():
|
||||
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
return "None (CPU only)"
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Run on application startup."""
|
||||
print("voicebox API starting up...")
|
||||
database.init_db()
|
||||
print(f"Database initialized at {database._db_path}")
|
||||
print(f"GPU available: {torch.cuda.is_available()}")
|
||||
print(f"GPU available: {_get_gpu_status()}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
|
||||
@@ -159,3 +159,37 @@ class ActiveTasksResponse(BaseModel):
|
||||
"""Response model for active tasks."""
|
||||
downloads: List[ActiveDownloadTask]
|
||||
generations: List[ActiveGenerationTask]
|
||||
|
||||
|
||||
class AudioChannelCreate(BaseModel):
|
||||
"""Request model for creating an audio channel."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
device_ids: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AudioChannelUpdate(BaseModel):
|
||||
"""Request model for updating an audio channel."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
device_ids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AudioChannelResponse(BaseModel):
|
||||
"""Response model for audio channel."""
|
||||
id: str
|
||||
name: str
|
||||
is_default: bool
|
||||
device_ids: List[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChannelVoiceAssignment(BaseModel):
|
||||
"""Request model for assigning voices to a channel."""
|
||||
profile_ids: List[str]
|
||||
|
||||
|
||||
class ProfileChannelAssignment(BaseModel):
|
||||
"""Request model for assigning channels to a profile."""
|
||||
channel_ids: List[str]
|
||||
|
||||
@@ -4,15 +4,16 @@ from PyInstaller.utils.hooks import collect_submodules
|
||||
from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli']
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
datas += copy_metadata('qwen-tts')
|
||||
hiddenimports += collect_submodules('qwen_tts')
|
||||
hiddenimports += collect_submodules('jaraco')
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['server.py'],
|
||||
pathex=['/Users/jamespine/Projects/voice/Qwen3-TTS'],
|
||||
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
|
||||
@@ -151,11 +151,13 @@ export default function Home() {
|
||||
<span className="flex-1 text-center px-4">Windows</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="w-full px-0">
|
||||
<Button asChild size="lg" className="w-full px-0" disabled>
|
||||
<a
|
||||
href={downloadLinks.linux}
|
||||
download
|
||||
className="flex items-center w-full relative"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="flex items-center w-full relative opacity-50 cursor-not-allowed"
|
||||
title="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
|
||||
aria-label="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||
<LinuxIcon className="h-5 w-5" />
|
||||
|
||||
@@ -12,18 +12,21 @@ export function DownloadSection() {
|
||||
icon: Laptop,
|
||||
link: DOWNLOAD_LINKS.macArm,
|
||||
description: 'macOS (Intel + Apple Silicon)',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
platform: 'Windows',
|
||||
icon: Monitor,
|
||||
link: DOWNLOAD_LINKS.windows,
|
||||
description: 'Windows x64',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
platform: 'Linux',
|
||||
icon: Terminal,
|
||||
link: DOWNLOAD_LINKS.linux,
|
||||
description: 'Linux AppImage',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,10 +37,14 @@ export function DownloadSection() {
|
||||
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{downloads.map(({ platform, icon: Icon, link, description }) => (
|
||||
{downloads.map(({ platform, icon: Icon, link, description, disabled }) => (
|
||||
<Card
|
||||
key={platform}
|
||||
className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5"
|
||||
className={`transition-all duration-200 ${
|
||||
disabled
|
||||
? 'opacity-50'
|
||||
: 'hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 hover:-translate-y-0.5'
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
@@ -48,8 +55,8 @@ export function DownloadSection() {
|
||||
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<a href={link} download>
|
||||
<Button asChild size="lg" className="w-full" disabled={disabled}>
|
||||
<a href={link} download className={disabled ? 'pointer-events-none' : ''}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -52,13 +52,18 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
const name = asset.name.toLowerCase();
|
||||
const url = asset.browser_download_url;
|
||||
|
||||
if (name.includes('aarch64') || name.includes('arm64')) {
|
||||
// Skip signature files and other non-downloadable files
|
||||
if (name.endsWith('.sig') || name.endsWith('.json') || name.endsWith('.txt')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((name.includes('aarch64') || name.includes('arm64')) && name.endsWith('.app.tar.gz')) {
|
||||
downloadLinks.macArm = url;
|
||||
} else if (name.includes('x64') && name.includes('.dmg')) {
|
||||
} else if (name.includes('x64') && name.endsWith('.app.tar.gz')) {
|
||||
downloadLinks.macIntel = url;
|
||||
} else if (name.includes('.msi')) {
|
||||
} else if (name.endsWith('.msi')) {
|
||||
downloadLinks.windows = url;
|
||||
} else if (name.includes('.appimage') || name.includes('.deb')) {
|
||||
} else if (name.endsWith('.appimage') || name.endsWith('.deb')) {
|
||||
downloadLinks.linux = url;
|
||||
}
|
||||
}
|
||||
@@ -69,10 +74,8 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
const releaseInfo: ReleaseInfo = {
|
||||
version,
|
||||
downloadLinks: {
|
||||
macArm:
|
||||
downloadLinks.macArm || `${baseUrl}/voicebox_${version.replace('v', '')}_aarch64.dmg`,
|
||||
macIntel:
|
||||
downloadLinks.macIntel || `${baseUrl}/voicebox_${version.replace('v', '')}_x64.dmg`,
|
||||
macArm: downloadLinks.macArm || `${baseUrl}/voicebox_aarch64.app.tar.gz`,
|
||||
macIntel: downloadLinks.macIntel || `${baseUrl}/voicebox_x64.app.tar.gz`,
|
||||
windows:
|
||||
downloadLinks.windows || `${baseUrl}/voicebox_${version.replace('v', '')}_x64_en-US.msi`,
|
||||
linux: downloadLinks.linux || `${baseUrl}/voicebox_x86_64-unknown-linux-gnu.AppImage`,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
@@ -12,7 +12,7 @@
|
||||
"dev": "cd tauri && bun run tauri dev",
|
||||
"dev:web": "cd web && bun run dev",
|
||||
"dev:landing": "cd landing && bun run dev",
|
||||
"dev:server": "source backend/venv/bin/activate && uvicorn backend.main:app --reload --port 8000",
|
||||
"dev:server": "uvicorn backend.main:app --reload --port 8000",
|
||||
"build": "cd tauri && bun run tauri build",
|
||||
"build:web": "cd web && bun run build",
|
||||
"build:landing": "cd landing && bun run build",
|
||||
|
||||
@@ -95,6 +95,25 @@ for size in 30 44 71 89 107 142 150 284 310; do
|
||||
done
|
||||
sips -s format png -z 50 50 "$SOURCE_ICON" --out "$ICONS_DIR/StoreLogo.png" 2>/dev/null
|
||||
|
||||
# Windows icon.ico (multi-size ICO file)
|
||||
echo "Generating Windows icon.ico..."
|
||||
if command -v convert &> /dev/null; then
|
||||
# Create temporary PNG files at different sizes for ICO
|
||||
# Windows typically uses: 16x16, 32x32, 48x48, 256x256
|
||||
sips -s format png -z 16 16 "$SOURCE_ICON" --out /tmp/icon-16.png 2>/dev/null
|
||||
sips -s format png -z 32 32 "$SOURCE_ICON" --out /tmp/icon-32.png 2>/dev/null
|
||||
sips -s format png -z 48 48 "$SOURCE_ICON" --out /tmp/icon-48.png 2>/dev/null
|
||||
sips -s format png -z 256 256 "$SOURCE_ICON" --out /tmp/icon-256.png 2>/dev/null
|
||||
# Combine into proper multi-size ICO file
|
||||
convert /tmp/icon-16.png /tmp/icon-32.png /tmp/icon-48.png /tmp/icon-256.png "$ICONS_DIR/icon.ico" 2>/dev/null
|
||||
rm -f /tmp/icon-16.png /tmp/icon-32.png /tmp/icon-48.png /tmp/icon-256.png 2>/dev/null
|
||||
echo " ✓ Generated Windows icon.ico"
|
||||
else
|
||||
# Fallback: use sips to create a basic ICO (single size)
|
||||
echo " ⚠ ImageMagick not found - generating basic icon.ico (single size)"
|
||||
sips -s format ico -z 256 256 "$SOURCE_ICON" --out "$ICONS_DIR/icon.ico" 2>/dev/null || echo " ⚠ Failed to generate icon.ico (sips may not support ICO format)"
|
||||
fi
|
||||
|
||||
# iOS Icons
|
||||
echo "Generating iOS icons..."
|
||||
mkdir -p "$ICONS_DIR/ios"
|
||||
@@ -188,6 +207,7 @@ echo "Updated:"
|
||||
echo " ✓ Liquid Glass icon bundle with all appearance variants"
|
||||
echo " ✓ macOS/Desktop fallback icons"
|
||||
echo " ✓ Windows Square logos"
|
||||
echo " ✓ Windows icon.ico (multi-size)"
|
||||
echo " ✓ iOS AppIcons (18 sizes)"
|
||||
echo " ✓ Android mipmap icons (5 densities)"
|
||||
echo " ✓ Landing page logo"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+607
-18
@@ -32,6 +32,28 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -56,6 +78,12 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -103,6 +131,24 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -258,6 +304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -267,6 +315,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
@@ -312,6 +369,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -378,6 +446,49 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -493,6 +604,12 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -646,6 +763,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -702,6 +825,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1212,6 +1341,12 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -1534,6 +1669,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1585,6 +1729,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
@@ -1666,7 +1820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||
dependencies = [
|
||||
"gtk-sys",
|
||||
"libloading",
|
||||
"libloading 0.7.4",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -1686,6 +1840,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@@ -1736,6 +1900,24 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -1788,6 +1970,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.4"
|
||||
@@ -1836,6 +2024,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -1845,7 +2047,7 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1857,6 +2059,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -1878,12 +2089,42 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1915,6 +2156,15 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
@@ -2139,6 +2389,29 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -3016,6 +3289,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "screencapturekit"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -3321,7 +3600,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"js-sys",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -3415,6 +3694,201 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-alac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-caf",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-mkv",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-adpcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-alac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-caf"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-mkv"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -3491,9 +3965,9 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
@@ -3504,7 +3978,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3586,7 +4060,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3709,6 +4183,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.4"
|
||||
@@ -3784,7 +4268,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3810,7 +4294,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4356,17 +4840,29 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voicebox"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
"cpal",
|
||||
"hound",
|
||||
"objc",
|
||||
"scopeguard",
|
||||
"screencapturekit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"wasapi",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4408,6 +4904,19 @@ dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasapi"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7834ac561bea8a7413661fdda62180f9e054f99815269cd3572cf7e40d9c3191"
|
||||
dependencies = [
|
||||
"log",
|
||||
"num-integer",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
@@ -4582,7 +5091,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
@@ -4606,7 +5115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4656,17 +5165,39 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4678,6 +5209,25 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
@@ -4712,7 +5262,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4759,6 +5320,25 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4897,6 +5477,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
@@ -5105,7 +5694,7 @@ dependencies = [
|
||||
"jni",
|
||||
"kuchikiki",
|
||||
"libc",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
@@ -5123,7 +5712,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -20,9 +20,25 @@ tauri-plugin-shell = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
hound = "3.5"
|
||||
base64 = "0.22"
|
||||
cpal = "0.15"
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
scopeguard = "1.2.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
screencapturekit = { version = "1", features = ["async"] }
|
||||
coreaudio-sys = "0.2"
|
||||
objc = "0.2"
|
||||
core-foundation-sys = "0.8"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
wasapi = "0.22"
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2.0"
|
||||
tauri-plugin-process = "2.0"
|
||||
|
||||
[features]
|
||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<string>voicebox</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>voicebox needs microphone access to record voice samples for voice cloning.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>Voicebox needs screen capture access to record system audio for voice samples.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Link Swift runtime libraries for screencapturekit crate
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Add Swift runtime library paths to RPATH
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift");
|
||||
println!("cargo:rustc-link-arg=-L/usr/lib/swift");
|
||||
|
||||
// Also try Xcode's Swift libraries
|
||||
if let Ok(output) = Command::new("xcode-select").arg("-p").output() {
|
||||
if output.status.success() {
|
||||
let xcode_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let swift_lib_path = format!(
|
||||
"{}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx",
|
||||
xcode_path
|
||||
);
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", swift_lib_path);
|
||||
println!("cargo:rustc-link-arg=-L{}", swift_lib_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile macOS Liquid Glass icon
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -20,14 +42,21 @@ fn main() {
|
||||
let output = Command::new("xcrun")
|
||||
.args([
|
||||
"actool",
|
||||
"--compile", &gen_dir,
|
||||
"--output-format", "human-readable-text",
|
||||
"--output-partial-info-plist", &partial_plist,
|
||||
"--app-icon", "voicebox",
|
||||
"--compile",
|
||||
&gen_dir,
|
||||
"--output-format",
|
||||
"human-readable-text",
|
||||
"--output-partial-info-plist",
|
||||
&partial_plist,
|
||||
"--app-icon",
|
||||
"voicebox",
|
||||
"--include-all-app-icons",
|
||||
"--target-device", "mac",
|
||||
"--minimum-deployment-target", "11.0",
|
||||
"--platform", "macosx",
|
||||
"--target-device",
|
||||
"mac",
|
||||
"--minimum-deployment-target",
|
||||
"11.0",
|
||||
"--platform",
|
||||
"macosx",
|
||||
&icon_source,
|
||||
])
|
||||
.output();
|
||||
@@ -48,7 +77,10 @@ fn main() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("cargo:warning=Icon source not found at {}, skipping icon compilation", icon_source);
|
||||
println!(
|
||||
"cargo:warning=Icon source not found at {}, skipping icon compilation",
|
||||
icon_source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for voicebox","remote":{"urls":["http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default","dialog:default","dialog:allow-save","dialog:allow-open","fs:default","fs:read-all","fs:write-all"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions for voicebox","remote":{"urls":["http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default","process:default","dialog:default","dialog:allow-save","dialog:allow-open","fs:default","fs:read-all","fs:write-all"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -5948,6 +5948,36 @@
|
||||
"const": "fs:write-files",
|
||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -5948,6 +5948,36 @@
|
||||
"const": "fs:write-files",
|
||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||
"type": "string",
|
||||
"const": "process:default",
|
||||
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:allow-restart",
|
||||
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the restart command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,255 @@
|
||||
use crate::audio_capture::AudioCaptureState;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use hound::{WavSpec, WavWriter};
|
||||
use screencapturekit::{
|
||||
cm::CMSampleBuffer,
|
||||
shareable_content::SCShareableContent,
|
||||
stream::{
|
||||
configuration::SCStreamConfiguration,
|
||||
content_filter::SCContentFilter,
|
||||
output_trait::SCStreamOutputTrait,
|
||||
output_type::SCStreamOutputType,
|
||||
sc_stream::SCStream,
|
||||
},
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub async fn start_capture(
|
||||
state: &AudioCaptureState,
|
||||
max_duration_secs: u32,
|
||||
) -> Result<(), String> {
|
||||
// Reset previous samples
|
||||
state.reset();
|
||||
|
||||
// Get shareable content
|
||||
let content = SCShareableContent::get()
|
||||
.map_err(|e| format!("Failed to get shareable content: {}", e))?;
|
||||
|
||||
// Get first display
|
||||
let displays = content.displays();
|
||||
if displays.is_empty() {
|
||||
return Err("No displays available".to_string());
|
||||
}
|
||||
let display = &displays[0];
|
||||
|
||||
// Create content filter for desktop audio
|
||||
let filter = SCContentFilter::create()
|
||||
.with_display(display)
|
||||
.with_excluding_windows(&[])
|
||||
.build();
|
||||
|
||||
// Create stream configuration - audio only
|
||||
let mut config = SCStreamConfiguration::default();
|
||||
config.set_captures_audio(true);
|
||||
config.set_excludes_current_process_audio(false);
|
||||
config.set_sample_rate(48000); // Use i32 directly
|
||||
config.set_channel_count(2); // Use i32 directly
|
||||
|
||||
// Create stream using builder
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
*state.stop_tx.lock().unwrap() = Some(tx);
|
||||
|
||||
let samples = state.samples.clone();
|
||||
let sample_rate = state.sample_rate.clone();
|
||||
let channels = state.channels.clone();
|
||||
|
||||
// Set sample rate and channels
|
||||
*sample_rate.lock().unwrap() = 48000;
|
||||
*channels.lock().unwrap() = 2;
|
||||
|
||||
// Create output handler struct
|
||||
struct AudioHandler {
|
||||
samples: Arc<Mutex<Vec<f32>>>,
|
||||
}
|
||||
|
||||
impl SCStreamOutputTrait for AudioHandler {
|
||||
fn did_output_sample_buffer(
|
||||
&self,
|
||||
sample: CMSampleBuffer,
|
||||
_type: SCStreamOutputType,
|
||||
) {
|
||||
if _type == SCStreamOutputType::Audio {
|
||||
if let Ok(audio_samples) = extract_audio_samples(sample) {
|
||||
let mut samples_guard = self.samples.lock().unwrap();
|
||||
samples_guard.extend_from_slice(&audio_samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let handler = AudioHandler {
|
||||
samples: samples.clone(),
|
||||
};
|
||||
|
||||
// Create stream
|
||||
let mut stream = SCStream::new(&filter, &config);
|
||||
|
||||
// Add output handler for audio (order: handler, then output_type)
|
||||
stream.add_output_handler(handler, SCStreamOutputType::Audio);
|
||||
|
||||
// Store stream reference
|
||||
*state.stream.lock().unwrap() = Some(stream.clone());
|
||||
|
||||
stream.start_capture().map_err(|e| format!("Failed to start capture: {}", e))?;
|
||||
|
||||
// Spawn task to stop after max duration
|
||||
let stream_clone = stream.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(max_duration_secs as u64)) => {
|
||||
// Timeout reached
|
||||
}
|
||||
_ = rx.recv() => {
|
||||
// Manual stop
|
||||
}
|
||||
}
|
||||
let _ = stream_clone.stop_capture();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, String> {
|
||||
// Signal stop
|
||||
if let Some(tx) = state.stop_tx.lock().unwrap().take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
// Stop stream if still active
|
||||
if let Some(stream) = state.stream.lock().unwrap().take() {
|
||||
let _ = stream.stop_capture();
|
||||
}
|
||||
|
||||
// Wait a bit for capture to stop
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Get samples
|
||||
let samples = state.samples.lock().unwrap().clone();
|
||||
let sample_rate = *state.sample_rate.lock().unwrap();
|
||||
let channels = *state.channels.lock().unwrap();
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err("No audio samples captured".to_string());
|
||||
}
|
||||
|
||||
// Convert to WAV
|
||||
let wav_data = samples_to_wav(&samples, sample_rate, channels)?;
|
||||
|
||||
// Encode to base64
|
||||
let base64_data = general_purpose::STANDARD.encode(&wav_data);
|
||||
|
||||
Ok(base64_data)
|
||||
}
|
||||
|
||||
pub fn is_supported() -> bool {
|
||||
// ScreenCaptureKit requires macOS 12.3+
|
||||
// Check if we're on a supported version
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Basic check - ScreenCaptureKit should be available on macOS 12.3+
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_audio_samples(sample_buffer: CMSampleBuffer) -> Result<Vec<f32>, String> {
|
||||
// Use the crate's built-in method to get audio buffer list
|
||||
let audio_buffer_list = sample_buffer
|
||||
.audio_buffer_list()
|
||||
.ok_or_else(|| "Failed to get audio buffer list".to_string())?;
|
||||
|
||||
let buffers: Vec<_> = audio_buffer_list.iter().collect();
|
||||
let num_buffers = buffers.len();
|
||||
|
||||
if num_buffers == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// ScreenCaptureKit on macOS provides audio in Float32 format
|
||||
// The audio can be either:
|
||||
// - Interleaved (1 buffer with L,R,L,R,... samples)
|
||||
// - Planar (2 buffers, one for L channel, one for R channel)
|
||||
|
||||
if num_buffers == 1 {
|
||||
// Interleaved stereo or mono in a single buffer
|
||||
let buffer = &buffers[0];
|
||||
let data_bytes = buffer.data();
|
||||
let num_samples = data_bytes.len() / std::mem::size_of::<f32>();
|
||||
|
||||
if num_samples > 0 {
|
||||
unsafe {
|
||||
let data_ptr = data_bytes.as_ptr() as *const f32;
|
||||
let data = std::slice::from_raw_parts(data_ptr, num_samples);
|
||||
return Ok(data.to_vec());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Planar format - separate buffer for each channel
|
||||
// We need to interleave them: L0, R0, L1, R1, ...
|
||||
let mut channel_data: Vec<Vec<f32>> = Vec::new();
|
||||
let mut max_samples = 0;
|
||||
|
||||
for buffer in &buffers {
|
||||
let data_bytes = buffer.data();
|
||||
let num_samples = data_bytes.len() / std::mem::size_of::<f32>();
|
||||
|
||||
if num_samples > 0 {
|
||||
unsafe {
|
||||
let data_ptr = data_bytes.as_ptr() as *const f32;
|
||||
let data = std::slice::from_raw_parts(data_ptr, num_samples);
|
||||
channel_data.push(data.to_vec());
|
||||
max_samples = max_samples.max(num_samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interleave the channels
|
||||
let mut interleaved = Vec::with_capacity(max_samples * num_buffers);
|
||||
for i in 0..max_samples {
|
||||
for channel in &channel_data {
|
||||
if i < channel.len() {
|
||||
interleaved.push(channel[i]);
|
||||
} else {
|
||||
interleaved.push(0.0); // Pad with silence if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(interleaved);
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> Result<Vec<u8>, String> {
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
|
||||
let spec = WavSpec {
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = WavWriter::new(cursor, spec)
|
||||
.map_err(|e| format!("Failed to create WAV writer: {}", e))?;
|
||||
|
||||
// Convert f32 samples to i16
|
||||
for sample in samples {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let i16_sample = (clamped * 32767.0) as i16;
|
||||
writer.write_sample(i16_sample)
|
||||
.map_err(|e| format!("Failed to write sample: {}", e))?;
|
||||
}
|
||||
|
||||
writer.finalize()
|
||||
.map_err(|e| format!("Failed to finalize WAV: {}", e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::*;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::*;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use screencapturekit::stream::sc_stream::SCStream;
|
||||
|
||||
pub struct AudioCaptureState {
|
||||
pub samples: Arc<Mutex<Vec<f32>>>,
|
||||
pub sample_rate: Arc<Mutex<u32>>,
|
||||
pub channels: Arc<Mutex<u16>>,
|
||||
pub stop_tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||
pub error: Arc<Mutex<Option<String>>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub stream: Arc<Mutex<Option<SCStream>>>,
|
||||
}
|
||||
|
||||
impl AudioCaptureState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
samples: Arc::new(Mutex::new(Vec::new())),
|
||||
sample_rate: Arc::new(Mutex::new(44100)),
|
||||
channels: Arc::new(Mutex::new(2)),
|
||||
stop_tx: Arc::new(Mutex::new(None)),
|
||||
error: Arc::new(Mutex::new(None)),
|
||||
#[cfg(target_os = "macos")]
|
||||
stream: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
*self.samples.lock().unwrap() = Vec::new();
|
||||
*self.error.lock().unwrap() = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use crate::audio_capture::AudioCaptureState;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use hound::{WavSpec, WavWriter};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use wasapi::*;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED};
|
||||
|
||||
pub async fn start_capture(
|
||||
state: &AudioCaptureState,
|
||||
max_duration_secs: u32,
|
||||
) -> Result<(), String> {
|
||||
// Reset previous samples
|
||||
state.reset();
|
||||
|
||||
let samples = state.samples.clone();
|
||||
let sample_rate_arc = state.sample_rate.clone();
|
||||
let channels_arc = state.channels.clone();
|
||||
let stop_tx = state.stop_tx.clone();
|
||||
let error_arc = state.error.clone();
|
||||
|
||||
// Use AtomicBool for stop signal (works with non-Send types)
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stop_flag_clone = stop_flag.clone();
|
||||
|
||||
// Create tokio channel and spawn a task to bridge it to the AtomicBool
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
*stop_tx.lock().unwrap() = Some(tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
rx.recv().await;
|
||||
stop_flag_clone.store(true, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
// Spawn capture task on a dedicated thread (WASAPI COM objects are not Send)
|
||||
// All WASAPI objects must be created and used on the same thread
|
||||
thread::spawn(move || {
|
||||
// Initialize COM for this thread
|
||||
unsafe {
|
||||
let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
if hr.is_err() {
|
||||
eprintln!("Failed to initialize COM: {:?}", hr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure COM is uninitialized when thread exits
|
||||
let _com_guard = scopeguard::guard((), |_| unsafe {
|
||||
CoUninitialize();
|
||||
});
|
||||
|
||||
// Initialize WASAPI on this thread
|
||||
let device = match DeviceEnumerator::new()
|
||||
.and_then(|enumerator| enumerator.get_default_device(&Direction::Render))
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to get audio device: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut audio_client = match device.get_iaudioclient() {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to get audio client: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mix_format = match audio_client.get_mixformat() {
|
||||
Ok(format) => format,
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to get mix format: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Set sample rate and channels
|
||||
let channels = mix_format.get_nchannels() as usize;
|
||||
let bytes_per_sample = (mix_format.get_bitspersample() / 8) as usize;
|
||||
*sample_rate_arc.lock().unwrap() = mix_format.get_samplespersec();
|
||||
*channels_arc.lock().unwrap() = mix_format.get_nchannels();
|
||||
|
||||
// Get device period
|
||||
let (_def_period, min_period) = match audio_client.get_device_period() {
|
||||
Ok(periods) => periods,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get device period: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize audio client for loopback with StreamMode
|
||||
// For loopback mode: get Render device, initialize with Capture direction
|
||||
// This triggers AUDCLNT_STREAMFLAGS_LOOPBACK in the wasapi crate
|
||||
let stream_mode = StreamMode::EventsShared {
|
||||
autoconvert: true, // Enable automatic format conversion
|
||||
buffer_duration_hns: min_period, // Use minimum period
|
||||
};
|
||||
|
||||
if let Err(e) = audio_client.initialize_client(&mix_format, &Direction::Capture, &stream_mode) {
|
||||
let error_msg = format!("Failed to initialize audio client: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event handle for EventsShared mode
|
||||
let h_event = match audio_client.set_get_eventhandle() {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to set event handle: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let capture_client = match audio_client.get_audiocaptureclient() {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to get capture client: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = audio_client.start_stream() {
|
||||
let error_msg = format!("Failed to start stream: {}", e);
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
// Check if stop signal was received
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to get available data
|
||||
match capture_client.get_next_packet_size() {
|
||||
Ok(Some(frames_available)) => {
|
||||
if frames_available > 0 {
|
||||
// Calculate buffer size needed (frames * channels * bytes_per_sample)
|
||||
let buffer_size = frames_available as usize * channels * bytes_per_sample;
|
||||
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
match capture_client.read_from_device(&mut buffer) {
|
||||
Ok((frames_read, _buffer_info)) => {
|
||||
if frames_read > 0 {
|
||||
// Convert bytes to f32 samples
|
||||
let samples_read = (frames_read as usize * channels) as usize;
|
||||
let mut samples_guard = samples.lock().unwrap();
|
||||
|
||||
// Assuming 32-bit float format
|
||||
if bytes_per_sample == 4 {
|
||||
for i in 0..samples_read {
|
||||
let byte_offset = i * 4;
|
||||
if byte_offset + 4 <= buffer.len() {
|
||||
let sample = f32::from_le_bytes([
|
||||
buffer[byte_offset],
|
||||
buffer[byte_offset + 1],
|
||||
buffer[byte_offset + 2],
|
||||
buffer[byte_offset + 3],
|
||||
]);
|
||||
samples_guard.push(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error reading from device: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Exclusive mode - handle differently if needed
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error getting next packet size: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for event signal (with timeout to allow checking stop flag)
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
// Timeout is expected - just continue to check stop flag
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the stream when done
|
||||
audio_client.stop_stream().ok();
|
||||
});
|
||||
|
||||
// Spawn timeout task
|
||||
let stop_tx_clone = state.stop_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(max_duration_secs as u64)).await;
|
||||
// Take the sender out of the mutex before awaiting
|
||||
let tx = stop_tx_clone.lock().unwrap().take();
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, String> {
|
||||
// Signal stop
|
||||
if let Some(tx) = state.stop_tx.lock().unwrap().take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
// Wait a bit for capture to stop
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Check if there was an error during capture
|
||||
if let Some(error) = state.error.lock().unwrap().as_ref() {
|
||||
return Err(error.clone());
|
||||
}
|
||||
|
||||
// Get samples
|
||||
let samples = state.samples.lock().unwrap().clone();
|
||||
let sample_rate = *state.sample_rate.lock().unwrap();
|
||||
let channels = *state.channels.lock().unwrap();
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err("No audio samples captured. Make sure audio is playing on your system during recording.".to_string());
|
||||
}
|
||||
|
||||
// Convert to WAV
|
||||
let wav_data = samples_to_wav(&samples, sample_rate, channels)?;
|
||||
|
||||
// Encode to base64
|
||||
let base64_data = general_purpose::STANDARD.encode(&wav_data);
|
||||
|
||||
Ok(base64_data)
|
||||
}
|
||||
|
||||
pub fn is_supported() -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> Result<Vec<u8>, String> {
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
|
||||
let spec = WavSpec {
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = WavWriter::new(cursor, spec)
|
||||
.map_err(|e| format!("Failed to create WAV writer: {}", e))?;
|
||||
|
||||
// Convert f32 samples to i16
|
||||
for sample in samples {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let i16_sample = (clamped * 32767.0) as i16;
|
||||
writer.write_sample(i16_sample)
|
||||
.map_err(|e| format!("Failed to write sample: {}", e))?;
|
||||
}
|
||||
|
||||
writer.finalize()
|
||||
.map_err(|e| format!("Failed to finalize WAV: {}", e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod audio_capture;
|
||||
+358
-12
@@ -1,13 +1,21 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio_capture;
|
||||
mod audio_output;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener};
|
||||
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const LEGACY_PORT: u16 = 8000;
|
||||
const SERVER_PORT: u16 = 17493;
|
||||
|
||||
struct ServerState {
|
||||
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
||||
server_pid: Mutex<Option<u32>>,
|
||||
keep_running_on_close: Mutex<bool>,
|
||||
}
|
||||
|
||||
#[command]
|
||||
@@ -16,11 +24,145 @@ async fn start_server(
|
||||
state: State<'_, ServerState>,
|
||||
remote: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
// Check if server is already running
|
||||
// Check if server is already running (managed by this app instance)
|
||||
if state.child.lock().unwrap().is_some() {
|
||||
return Ok("Server already running on http://localhost:8000".to_string());
|
||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||
}
|
||||
|
||||
// Check if a voicebox server is already running on our port (from previous session with keep_running=true)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(output) = Command::new("lsof")
|
||||
.args(["-i", &format!(":{}", SERVER_PORT), "-sTCP:LISTEN"])
|
||||
.output()
|
||||
{
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let command = parts[0];
|
||||
let pid_str = parts[1];
|
||||
if command.contains("voicebox") {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
|
||||
// Store the PID so we can kill it on exit if needed
|
||||
*state.server_pid.lock().unwrap() = Some(pid);
|
||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(output) = Command::new("netstat")
|
||||
.args(["-ano"])
|
||||
.output()
|
||||
{
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
if line.contains(&format!(":{}", SERVER_PORT)) && line.contains("LISTENING") {
|
||||
if let Some(pid_str) = line.split_whitespace().last() {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
if let Ok(tasklist_output) = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||
.output()
|
||||
{
|
||||
let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
|
||||
if tasklist_str.to_lowercase().contains("voicebox") {
|
||||
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
|
||||
// Store the PID so we can kill it on exit if needed
|
||||
*state.server_pid.lock().unwrap() = Some(pid);
|
||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill any orphaned voicebox-server from previous session on legacy port 8000
|
||||
// This handles upgrades from older versions that used a fixed port
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
// Find processes listening on legacy port 8000 with their command names
|
||||
if let Ok(output) = Command::new("lsof")
|
||||
.args(["-i", &format!(":{}", LEGACY_PORT), "-sTCP:LISTEN"])
|
||||
.output()
|
||||
{
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines().skip(1) { // Skip header line
|
||||
// lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let command = parts[0];
|
||||
let pid_str = parts[1];
|
||||
|
||||
// Only kill if it's a voicebox-server process
|
||||
if command.contains("voicebox") {
|
||||
if let Ok(pid) = pid_str.parse::<i32>() {
|
||||
println!("Found orphaned voicebox-server on legacy port {} (PID: {}, CMD: {}), killing it...", LEGACY_PORT, pid, command);
|
||||
// Kill the process group
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
} else {
|
||||
println!("Legacy port {} is in use by non-voicebox process: {} (PID: {}), not killing", LEGACY_PORT, command, pid_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
// On Windows, find PIDs on legacy port 8000, then check their names
|
||||
if let Ok(output) = Command::new("netstat")
|
||||
.args(["-ano"])
|
||||
.output()
|
||||
{
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
if line.contains(&format!(":{}", LEGACY_PORT)) && line.contains("LISTENING") {
|
||||
if let Some(pid_str) = line.split_whitespace().last() {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
// Get process name for this PID
|
||||
if let Ok(tasklist_output) = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||
.output()
|
||||
{
|
||||
let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
|
||||
if tasklist_str.to_lowercase().contains("voicebox") {
|
||||
println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid);
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||
.output();
|
||||
} else {
|
||||
println!("Legacy port {} is in use by non-voicebox process (PID: {}), not killing", LEGACY_PORT, pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brief wait for port to be released
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
// Get app data directory
|
||||
let data_dir = app
|
||||
.path()
|
||||
@@ -47,12 +189,14 @@ async fn start_server(
|
||||
|
||||
println!("Sidecar command created successfully");
|
||||
|
||||
// Pass data directory to Python server
|
||||
// Pass data directory and port to Python server
|
||||
sidecar = sidecar.args([
|
||||
"--data-dir",
|
||||
data_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid data dir path".to_string())?,
|
||||
"--port",
|
||||
&SERVER_PORT.to_string(),
|
||||
]);
|
||||
|
||||
if remote.unwrap_or(false) {
|
||||
@@ -75,7 +219,9 @@ async fn start_server(
|
||||
println!("Server process spawned, waiting for ready signal...");
|
||||
println!("=================================================================");
|
||||
|
||||
// Store child process
|
||||
// Store child process and PID
|
||||
let process_pid = child.pid();
|
||||
*state.server_pid.lock().unwrap() = Some(process_pid);
|
||||
*state.child.lock().unwrap() = Some(child);
|
||||
|
||||
// Wait for server to be ready by listening for startup log
|
||||
@@ -154,17 +300,98 @@ async fn start_server(
|
||||
}
|
||||
});
|
||||
|
||||
Ok("Server started on http://localhost:8000".to_string())
|
||||
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||
if let Some(child) = state.child.lock().unwrap().take() {
|
||||
child.kill().map_err(|e| format!("Failed to kill: {}", e))?;
|
||||
let pid = state.server_pid.lock().unwrap().take();
|
||||
let _child = state.child.lock().unwrap().take();
|
||||
|
||||
if let Some(pid) = pid {
|
||||
println!("stop_server: Killing server process group with PID: {}", pid);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
// Kill process group with SIGTERM first
|
||||
let _ = Command::new("kill")
|
||||
.args(["-TERM", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
|
||||
// Brief wait then force kill
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||
.output();
|
||||
}
|
||||
|
||||
println!("stop_server: Process group kill completed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
|
||||
*state.keep_running_on_close.lock().unwrap() = keep_running;
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn start_system_audio_capture(
|
||||
state: State<'_, audio_capture::AudioCaptureState>,
|
||||
max_duration_secs: u32,
|
||||
) -> Result<(), String> {
|
||||
audio_capture::start_capture(&state, max_duration_secs).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn stop_system_audio_capture(
|
||||
state: State<'_, audio_capture::AudioCaptureState>,
|
||||
) -> Result<String, String> {
|
||||
audio_capture::stop_capture(&state).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn is_system_audio_supported() -> bool {
|
||||
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)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -173,10 +400,35 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(ServerState {
|
||||
child: Mutex::new(None),
|
||||
server_pid: Mutex::new(None),
|
||||
keep_running_on_close: Mutex::new(false),
|
||||
})
|
||||
.manage(audio_capture::AudioCaptureState::new())
|
||||
.manage(audio_output::AudioOutputState::new())
|
||||
.setup(|app| {
|
||||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
{
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
}
|
||||
|
||||
// Hide title bar icon on Windows
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{SetClassLongPtrW, GCLP_HICON, GCLP_HICONSM};
|
||||
|
||||
if let Some((_, window)) = app.webview_windows().iter().next() {
|
||||
if let Ok(hwnd) = window.hwnd() {
|
||||
let hwnd = HWND(hwnd.0);
|
||||
unsafe {
|
||||
// Set both small and regular icons to NULL to hide the title bar icon
|
||||
SetClassLongPtrW(hwnd, GCLP_HICON, 0);
|
||||
SetClassLongPtrW(hwnd, GCLP_HICONSM, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
@@ -190,7 +442,17 @@ pub fn run() {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![start_server, stop_server])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_server,
|
||||
stop_server,
|
||||
set_keep_server_running,
|
||||
start_system_audio_capture,
|
||||
stop_system_audio_capture,
|
||||
is_system_audio_supported,
|
||||
list_audio_output_devices,
|
||||
play_audio_to_devices,
|
||||
stop_audio_playback
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Prevent automatic close
|
||||
@@ -235,8 +497,92 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
match &event {
|
||||
RunEvent::Exit => {
|
||||
println!("=================================================================");
|
||||
println!("RunEvent::Exit received - checking server cleanup");
|
||||
let state = app.state::<ServerState>();
|
||||
let keep_running = *state.keep_running_on_close.lock().unwrap();
|
||||
println!("keep_running_on_close = {}", keep_running);
|
||||
|
||||
if !keep_running {
|
||||
// Get the stored PID for process group killing
|
||||
let pid = state.server_pid.lock().unwrap().take();
|
||||
// Also take the child to clean up
|
||||
let _child = state.child.lock().unwrap().take();
|
||||
|
||||
if let Some(pid) = pid {
|
||||
println!("Killing server process group with PID: {}", pid);
|
||||
|
||||
// Kill the entire process group on Unix systems
|
||||
// Using negative PID sends signal to all processes in the group
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
// First try SIGTERM to the process group
|
||||
let pgid_kill = Command::new("kill")
|
||||
.args(["-TERM", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
|
||||
match pgid_kill {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
println!("SIGTERM sent to process group -{}", pid);
|
||||
} else {
|
||||
// Process group kill failed, try direct kill
|
||||
println!("Process group kill failed, trying direct kill");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to execute kill command: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Give it a moment, then force kill if needed
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// Force kill with SIGKILL
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
println!("Server process group kill completed");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, use taskkill with /T to kill child processes
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||
.output();
|
||||
println!("Server process tree kill completed");
|
||||
}
|
||||
} else {
|
||||
println!("No server PID found (already stopped or never started)");
|
||||
}
|
||||
} else {
|
||||
println!("Keeping server running per user setting");
|
||||
}
|
||||
println!("=================================================================");
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
println!("RunEvent::ExitRequested received");
|
||||
// Don't prevent exit, just log it
|
||||
let _ = api;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "voicebox",
|
||||
"version": "0.1.0",
|
||||
"productName": "Voicebox",
|
||||
"version": "0.1.3",
|
||||
"identifier": "sh.voicebox.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// NOTE: This test requires system audio to be playing during execution.
|
||||
// To run this test successfully:
|
||||
// 1. Start playing audio (music, video, etc.)
|
||||
// 2. Run: cargo test --test audio_capture_test -- --nocapture
|
||||
// 3. The test will capture audio for 5 seconds and verify the output
|
||||
|
||||
use voicebox::audio_capture::{AudioCaptureState, start_capture, stop_capture};
|
||||
use base64::Engine;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_system_audio_capture() {
|
||||
// Create AudioCaptureState
|
||||
let state = AudioCaptureState::new();
|
||||
|
||||
println!("Starting system audio capture with 5 second max duration...");
|
||||
|
||||
// Start capture with 5 second max duration
|
||||
let result = start_capture(&state, 5).await;
|
||||
|
||||
if let Err(e) = result {
|
||||
panic!("Failed to start capture: {}", e);
|
||||
}
|
||||
|
||||
println!("Capture started, waiting 5 seconds...");
|
||||
|
||||
// Wait 5 seconds for capture to complete
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
println!("Stopping capture...");
|
||||
|
||||
// Stop capture and get the result
|
||||
let audio_data = stop_capture(&state).await;
|
||||
|
||||
match audio_data {
|
||||
Ok(base64_wav) => {
|
||||
println!("Capture stopped successfully");
|
||||
|
||||
// Validate the returned base64 WAV data
|
||||
println!("Validating base64 WAV data...");
|
||||
|
||||
// Decode base64 to bytes
|
||||
let decoded_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&base64_wav)
|
||||
.expect("Failed to decode base64 data");
|
||||
|
||||
// Verify bytes array is not empty
|
||||
assert!(!decoded_bytes.is_empty(), "Decoded bytes array is empty");
|
||||
|
||||
// Confirm data has content (length > 0)
|
||||
println!("WAV data length: {} bytes", decoded_bytes.len());
|
||||
assert!(decoded_bytes.len() > 0, "WAV data has no content");
|
||||
|
||||
println!("✓ Test passed: Audio capture produced valid WAV data");
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Failed to stop capture or get audio data: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user