mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535cf362de | ||
|
|
f1116e05a6 | ||
|
|
f9aca9d418 | ||
|
|
d913a9ae2a | ||
|
|
36031a0df5 | ||
|
|
e59f86aa63 | ||
|
|
b59c0f44e5 | ||
|
|
f8c5e54962 | ||
|
|
8b67faf96d | ||
|
|
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.2
|
||||||
|
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}"
|
||||||
@@ -30,6 +30,8 @@ target/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
tauri/src-tauri/gen/Assets.car
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
+29
-5
@@ -321,11 +321,35 @@ Currently, testing is primarily manual. When adding tests:
|
|||||||
|
|
||||||
Releases are managed by maintainers:
|
Releases are managed by maintainers:
|
||||||
|
|
||||||
1. Version bump in `tauri.conf.json` and `Cargo.toml`
|
1. **Bump version using bumpversion:**
|
||||||
2. Update CHANGELOG.md
|
```bash
|
||||||
3. Create git tag: `git tag v0.2.0`
|
# Install bumpversion (if not already installed)
|
||||||
4. Push tag: `git push --tags`
|
pip install bumpversion
|
||||||
5. GitHub Actions builds and releases
|
|
||||||
|
# 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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+23
-7
@@ -10,12 +10,18 @@ import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
|||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { useRestoreActiveTasks, MODEL_DISPLAY_NAMES } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
import { isMacOS, isTauri, setupWindowCloseHandler, startServer } from '@/lib/tauri';
|
import {
|
||||||
|
isMacOS,
|
||||||
|
isTauri,
|
||||||
|
setKeepServerRunning,
|
||||||
|
setupWindowCloseHandler,
|
||||||
|
startServer,
|
||||||
|
} from '@/lib/tauri';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
// Track if server is starting to prevent duplicate starts
|
||||||
let serverStarting = false;
|
let serverStarting = false;
|
||||||
@@ -51,6 +57,16 @@ function App() {
|
|||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
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)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
@@ -83,8 +99,10 @@ function App() {
|
|||||||
console.log('Production mode: Starting bundled server...');
|
console.log('Production mode: Starting bundled server...');
|
||||||
|
|
||||||
startServer(false)
|
startServer(false)
|
||||||
.then(() => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready');
|
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);
|
setServerReady(true);
|
||||||
// Mark that we started the server (so we know to stop it on close)
|
// Mark that we started the server (so we know to stop it on close)
|
||||||
// @ts-expect-error - adding property to window
|
// @ts-expect-error - adding property to window
|
||||||
@@ -156,8 +174,6 @@ function App() {
|
|||||||
|
|
||||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||||
<UpdateNotification />
|
|
||||||
|
|
||||||
{activeTab === 'settings' ? (
|
{activeTab === 'settings' ? (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { setKeepServerRunning } from '@/lib/tauri';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
serverUrl: z.string().url('Please enter a valid URL'),
|
||||||
@@ -69,7 +70,7 @@ export function ConnectionForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server URL</FormLabel>
|
<FormLabel>Server URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="http://localhost:8000" {...field} />
|
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -88,6 +89,9 @@ export function ConnectionForm() {
|
|||||||
checked={keepServerRunningOnClose}
|
checked={keepServerRunningOnClose}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setKeepServerRunningOnClose(checked);
|
setKeepServerRunningOnClose(checked);
|
||||||
|
setKeepServerRunning(checked).catch((error) => {
|
||||||
|
console.error('Failed to sync setting to Rust:', error);
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Setting updated',
|
title: 'Setting updated',
|
||||||
description: checked
|
description: checked
|
||||||
|
|||||||
@@ -71,11 +71,23 @@ export function UpdateStatus() {
|
|||||||
|
|
||||||
{status.downloading && (
|
{status.downloading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Downloading update...
|
Downloading update...
|
||||||
</div>
|
</div>
|
||||||
<Progress />
|
{status.downloadProgress !== undefined && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{status.downloadProgress}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -27,31 +27,48 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import {
|
import {
|
||||||
|
useAddSample,
|
||||||
useCreateProfile,
|
useCreateProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
useUpdateProfile,
|
useUpdateProfile,
|
||||||
useAddSample,
|
|
||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { isTauri } from '@/lib/tauri';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
|
||||||
import { Mic, Square, Upload } from 'lucide-react';
|
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
|
||||||
// Helper function to get audio duration from File
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const audio = new Audio();
|
const audio = new Audio();
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
// Check if duration is valid (not Infinity or NaN)
|
||||||
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
audio.addEventListener('error', () => {
|
audio.addEventListener('error', () => {
|
||||||
@@ -65,16 +82,15 @@ async function getAudioDuration(file: File): Promise<number> {
|
|||||||
|
|
||||||
const MAX_AUDIO_DURATION_SECONDS = 30;
|
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||||
|
|
||||||
const profileSchema = z
|
const baseProfileSchema = z.object({
|
||||||
.object({
|
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
// Sample fields - only required when creating (not editing)
|
|
||||||
sampleFile: z.instanceof(File).optional(),
|
sampleFile: z.instanceof(File).optional(),
|
||||||
referenceText: z.string().max(1000).optional(),
|
referenceText: z.string().max(1000).optional(),
|
||||||
})
|
});
|
||||||
.refine(
|
|
||||||
|
const profileSchema = baseProfileSchema.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// If sample file is provided, reference text is required
|
// If sample file is provided, reference text is required
|
||||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||||
@@ -101,9 +117,10 @@ export function ProfileForm() {
|
|||||||
const addSample = useAddSample();
|
const addSample = useAddSample();
|
||||||
const transcribe = useTranscription();
|
const transcribe = useTranscription();
|
||||||
const { toast } = useToast();
|
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 [audioDuration, setAudioDuration] = useState<number | null>(null);
|
||||||
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
|
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
|
||||||
|
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
const isCreating = !editingProfileId;
|
const isCreating = !editingProfileId;
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
@@ -112,6 +129,8 @@ export function ProfileForm() {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
sampleFile: undefined,
|
||||||
|
referenceText: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +140,7 @@ export function ProfileForm() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFile && selectedFile instanceof File) {
|
if (selectedFile && selectedFile instanceof File) {
|
||||||
setIsValidatingAudio(true);
|
setIsValidatingAudio(true);
|
||||||
getAudioDuration(selectedFile)
|
getAudioDuration(selectedFile as File & { recordedDuration?: number })
|
||||||
.then((duration) => {
|
.then((duration) => {
|
||||||
setAudioDuration(duration);
|
setAudioDuration(duration);
|
||||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
@@ -136,10 +155,19 @@ export function ProfileForm() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get audio duration:', error);
|
console.error('Failed to get audio duration:', error);
|
||||||
setAudioDuration(null);
|
setAudioDuration(null);
|
||||||
|
// 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', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: 'Failed to validate audio file. Please try a different file.',
|
message: 'Failed to validate audio file. Please try a different file.',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Clear any existing errors for recorded files
|
||||||
|
form.clearErrors('sampleFile');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsValidatingAudio(false);
|
setIsValidatingAudio(false);
|
||||||
@@ -159,10 +187,14 @@ export function ProfileForm() {
|
|||||||
cancelRecording,
|
cancelRecording,
|
||||||
} = useAudioRecording({
|
} = useAudioRecording({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 30,
|
||||||
onRecordingComplete: (blob) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
type: blob.type || 'audio/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 });
|
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||||
toast({
|
toast({
|
||||||
title: 'Recording complete',
|
title: 'Recording complete',
|
||||||
@@ -171,6 +203,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
|
// Show recording errors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recordingError) {
|
if (recordingError) {
|
||||||
@@ -182,6 +240,17 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}, [recordingError, toast]);
|
}, [recordingError, toast]);
|
||||||
|
|
||||||
|
// Show system audio recording errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemRecordingError) {
|
||||||
|
toast({
|
||||||
|
title: 'System audio capture error',
|
||||||
|
description: systemRecordingError,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [systemRecordingError, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingProfile) {
|
if (editingProfile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -219,11 +288,6 @@ export function ProfileForm() {
|
|||||||
const result = await transcribe.mutateAsync({ file, language });
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Transcription complete',
|
|
||||||
description: 'Audio has been transcribed successfully.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: 'Transcription failed',
|
||||||
@@ -234,8 +298,18 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelRecording() {
|
function handleCancelRecording() {
|
||||||
|
if (sampleMode === 'record') {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
|
} else if (sampleMode === 'system') {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
form.resetField('sampleFile');
|
form.resetField('sampleFile');
|
||||||
|
cleanupAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayPause() {
|
||||||
|
const file = form.getValues('sampleFile');
|
||||||
|
playPause(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
@@ -255,12 +329,37 @@ export function ProfileForm() {
|
|||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
});
|
});
|
||||||
} else {
|
} 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 sampleFile = form.getValues('sampleFile');
|
||||||
const referenceText = form.getValues('referenceText');
|
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
|
// Validate audio duration before creating profile
|
||||||
if (sampleFile) {
|
|
||||||
try {
|
try {
|
||||||
const duration = await getAudioDuration(sampleFile);
|
const duration = await getAudioDuration(sampleFile);
|
||||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
@@ -287,17 +386,14 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
return; // Prevent form submission
|
return; // Prevent form submission
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Creating: create profile, then optionally add sample
|
// Creating: create profile, then add sample
|
||||||
const profile = await createProfile.mutateAsync({
|
const profile = await createProfile.mutateAsync({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If sample file and reference text provided, add it
|
|
||||||
if (sampleFile && referenceText && referenceText.trim().length > 0) {
|
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
@@ -309,19 +405,13 @@ export function ProfileForm() {
|
|||||||
description: `"${data.name}" has been created with a sample.`,
|
description: `"${data.name}" has been created with a sample.`,
|
||||||
});
|
});
|
||||||
} catch (sampleError) {
|
} catch (sampleError) {
|
||||||
// Profile was created but sample failed - still show success for profile
|
// Profile was created but sample failed - still show error
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile created',
|
title: 'Failed to add sample',
|
||||||
description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'Profile created',
|
|
||||||
description: `"${data.name}" has been created successfully. You can add samples later.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -345,6 +435,10 @@ export function ProfileForm() {
|
|||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
|
if (isSystemRecording) {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
|
cleanupAudio();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +450,7 @@ export function ProfileForm() {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingProfileId
|
{editingProfileId
|
||||||
? 'Update your voice profile details.'
|
? 'Update your voice profile details.'
|
||||||
: 'Create a new voice profile. You can add a sample now or later.'}
|
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -423,96 +517,63 @@ export function ProfileForm() {
|
|||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div className="space-y-4 border-l pl-6">
|
<div className="space-y-4 border-l pl-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3>
|
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Add an audio sample to get started immediately. You can add more samples
|
Provide an audio sample to clone the voice. You can add more samples later.
|
||||||
later.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={sampleMode}
|
value={sampleMode}
|
||||||
onValueChange={(v) => setSampleMode(v as 'upload' | 'record')}
|
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'}`}
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-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
|
Upload
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isTauri() && isSystemAudioSupported && (
|
||||||
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
|
System Audio
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="upload" className="space-y-4">
|
<TabsContent value="upload" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={({ field: { onChange, name, ref } }) => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<FormItem>
|
<AudioSampleUpload
|
||||||
<FormLabel>Audio File</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
onFileChange={onChange}
|
||||||
<div className="flex flex-col gap-2">
|
onTranscribe={handleTranscribe}
|
||||||
<Input
|
onPlayPause={handlePlayPause}
|
||||||
type="file"
|
isPlaying={isPlaying}
|
||||||
accept="audio/*"
|
isValidating={isValidatingAudio}
|
||||||
name={name}
|
isTranscribing={transcribe.isPending}
|
||||||
ref={ref}
|
isDisabled={
|
||||||
onChange={(e) => {
|
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onChange(file);
|
|
||||||
} else {
|
|
||||||
onChange(undefined);
|
|
||||||
}
|
}
|
||||||
}}
|
fieldName={name}
|
||||||
/>
|
/>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -522,95 +583,44 @@ export function ProfileForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleRecording
|
||||||
<FormLabel>Record Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isRecording}
|
||||||
<div className="space-y-4">
|
duration={duration}
|
||||||
{!isRecording && !selectedFile && (
|
onStart={startRecording}
|
||||||
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg">
|
onStop={stopRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<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-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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
</TabsContent>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -626,10 +636,6 @@ export function ProfileForm() {
|
|||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This should match exactly what is spoken in the audio file. Required if
|
|
||||||
you add a sample.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -13,21 +14,23 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import { Mic, Square, Upload } from 'lucide-react';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
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({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
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 transcribe = useTranscription();
|
||||||
const { data: profile } = useProfile(profileId);
|
const { data: profile } = useProfile(profileId);
|
||||||
const { toast } = useToast();
|
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>({
|
const form = useForm<SampleFormValues>({
|
||||||
resolver: zodResolver(sampleSchema),
|
resolver: zodResolver(sampleSchema),
|
||||||
@@ -70,11 +74,15 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
cancelRecording,
|
cancelRecording,
|
||||||
} = useAudioRecording({
|
} = useAudioRecording({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 30,
|
||||||
onRecordingComplete: (blob) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
// Convert blob to File object
|
// Convert blob to File object
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
type: blob.type || 'audio/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 });
|
form.setValue('file', file, { shouldValidate: true });
|
||||||
toast({
|
toast({
|
||||||
title: 'Recording complete',
|
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
|
// Show recording errors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recordingError) {
|
if (recordingError) {
|
||||||
@@ -94,6 +129,17 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
}
|
}
|
||||||
}, [recordingError, toast]);
|
}, [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() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('file');
|
const file = form.getValues('file');
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -110,11 +156,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
const result = await transcribe.mutateAsync({ file, language });
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Transcription complete',
|
|
||||||
description: 'Audio has been transcribed successfully.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: 'Transcription failed',
|
||||||
@@ -154,14 +195,27 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
|
if (isSystemRecording) {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
|
cleanupAudio();
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelRecording() {
|
function handleCancelRecording() {
|
||||||
|
if (mode === 'record') {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
// Reset file field by clearing the input
|
} else if (mode === 'system') {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
form.resetField('file');
|
form.resetField('file');
|
||||||
|
cleanupAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayPause() {
|
||||||
|
const file = form.getValues('file');
|
||||||
|
playPause(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,58 +230,40 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList
|
||||||
|
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-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
|
Upload
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isTauri() && isSystemAudioSupported && (
|
||||||
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
|
System Audio
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="upload" className="space-y-4">
|
<TabsContent value="upload" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={({ field: { onChange, value, ...field } }) => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<FormItem>
|
<AudioSampleUpload
|
||||||
<FormLabel>Audio File</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
onFileChange={onChange}
|
||||||
<div className="flex items-center gap-2">
|
onTranscribe={handleTranscribe}
|
||||||
<Input
|
onPlayPause={handlePlayPause}
|
||||||
type="file"
|
isPlaying={isPlaying}
|
||||||
accept="audio/*"
|
isTranscribing={transcribe.isPending}
|
||||||
onChange={(e) => {
|
fieldName={name}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -237,94 +273,44 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleRecording
|
||||||
<FormLabel>Record Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isRecording}
|
||||||
<div className="space-y-4">
|
duration={duration}
|
||||||
{!isRecording && !selectedFile && (
|
onStart={startRecording}
|
||||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
|
onStop={stopRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -340,9 +326,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This should match exactly what is spoken in the audio file.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ export interface UpdateStatus {
|
|||||||
downloading: boolean;
|
downloading: boolean;
|
||||||
installing: boolean;
|
installing: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
downloadProgress?: number; // 0-100 percentage
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTauri = () => {
|
const isTauri = () => {
|
||||||
@@ -25,7 +28,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
|
|
||||||
const [update, setUpdate] = useState<Update | null>(null);
|
const [update, setUpdate] = useState<Update | null>(null);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const downloadAndInstall = async () => {
|
const downloadAndInstall = async () => {
|
||||||
if (!update || !isTauri()) return;
|
if (!update || !isTauri()) return;
|
||||||
@@ -69,19 +72,40 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
try {
|
try {
|
||||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
||||||
|
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
await update.downloadAndInstall((event) => {
|
await update.downloadAndInstall((event) => {
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'Started':
|
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;
|
break;
|
||||||
case 'Progress':
|
case 'Progress': {
|
||||||
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
downloadedBytes += event.data.chunkLength;
|
||||||
|
const progress = totalBytes > 0
|
||||||
|
? Math.round((downloadedBytes / totalBytes) * 100)
|
||||||
|
: undefined;
|
||||||
|
setStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloadedBytes,
|
||||||
|
downloadProgress: progress
|
||||||
|
}));
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'Finished':
|
case 'Finished':
|
||||||
setStatus((prev) => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: true,
|
installing: true,
|
||||||
|
downloadProgress: 100
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,9 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
...prev,
|
...prev,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
downloadProgress: undefined,
|
||||||
|
downloadedBytes: undefined,
|
||||||
|
totalBytes: undefined,
|
||||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -102,7 +129,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
if (checkOnMount && isTauri()) {
|
if (checkOnMount && isTauri()) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount]);
|
}, [checkOnMount, checkForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -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 { isTauri } from '@/lib/tauri';
|
||||||
|
import { convertToWav } from '@/lib/utils/audio';
|
||||||
|
|
||||||
interface UseAudioRecordingOptions {
|
interface UseAudioRecordingOptions {
|
||||||
maxDurationSeconds?: number;
|
maxDurationSeconds?: number;
|
||||||
onRecordingComplete?: (blob: Blob) => void;
|
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudioRecording({
|
export function useAudioRecording({
|
||||||
@@ -85,9 +86,26 @@ export function useAudioRecording({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = async () => {
|
||||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
onRecordingComplete?.(blob);
|
|
||||||
|
// 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
|
// Stop all tracks
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
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
|
* 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);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface ServerStore {
|
|||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
serverUrl: 'http://localhost:8000',
|
serverUrl: 'http://127.0.0.1:17493',
|
||||||
setServerUrl: (url) => set({ serverUrl: url }),
|
setServerUrl: (url) => set({ serverUrl: url }),
|
||||||
|
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ app.add_middleware(
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint."""
|
||||||
return {"message": "voicebox API", "version": "0.1.0"}
|
return {"message": "voicebox API", "version": "0.1.2"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=models.HealthResponse)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ from PyInstaller.utils.hooks import collect_submodules
|
|||||||
from PyInstaller.utils.hooks import copy_metadata
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
datas = []
|
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 += collect_data_files('qwen_tts')
|
||||||
datas += copy_metadata('qwen-tts')
|
datas += copy_metadata('qwen-tts')
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
|
hiddenimports += collect_submodules('jaraco')
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['server.py'],
|
['server.py'],
|
||||||
pathex=['/Users/jamespine/Projects/voice/Qwen3-TTS'],
|
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=hiddenimports,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"description": "Landing page for voicebox.sh",
|
"description": "Landing page for voicebox.sh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev --turbo",
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
|||||||
@@ -151,11 +151,13 @@ export default function Home() {
|
|||||||
<span className="flex-1 text-center px-4">Windows</span>
|
<span className="flex-1 text-center px-4">Windows</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="lg" className="w-full px-0">
|
<Button asChild size="lg" className="w-full px-0" disabled>
|
||||||
<a
|
<a
|
||||||
href={downloadLinks.linux}
|
href={downloadLinks.linux}
|
||||||
download
|
onClick={(e) => e.preventDefault()}
|
||||||
className="flex items-center w-full relative"
|
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">
|
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||||
<LinuxIcon className="h-5 w-5" />
|
<LinuxIcon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -12,18 +12,21 @@ export function DownloadSection() {
|
|||||||
icon: Laptop,
|
icon: Laptop,
|
||||||
link: DOWNLOAD_LINKS.macArm,
|
link: DOWNLOAD_LINKS.macArm,
|
||||||
description: 'macOS (Intel + Apple Silicon)',
|
description: 'macOS (Intel + Apple Silicon)',
|
||||||
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
platform: 'Windows',
|
platform: 'Windows',
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
link: DOWNLOAD_LINKS.windows,
|
link: DOWNLOAD_LINKS.windows,
|
||||||
description: 'Windows x64',
|
description: 'Windows x64',
|
||||||
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
platform: 'Linux',
|
platform: 'Linux',
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
link: DOWNLOAD_LINKS.linux,
|
link: DOWNLOAD_LINKS.linux,
|
||||||
description: 'Linux AppImage',
|
description: 'Linux AppImage',
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,10 +37,14 @@ export function DownloadSection() {
|
|||||||
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
|
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
<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
|
<Card
|
||||||
key={platform}
|
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">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col items-center text-center space-y-4">
|
<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>
|
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild size="lg" className="w-full">
|
<Button asChild size="lg" className="w-full" disabled={disabled}>
|
||||||
<a href={link} download>
|
<a href={link} download className={disabled ? 'pointer-events-none' : ''}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -52,13 +52,18 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
|||||||
const name = asset.name.toLowerCase();
|
const name = asset.name.toLowerCase();
|
||||||
const url = asset.browser_download_url;
|
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;
|
downloadLinks.macArm = url;
|
||||||
} else if (name.includes('x64') && name.includes('.dmg')) {
|
} else if (name.includes('x64') && name.endsWith('.app.tar.gz')) {
|
||||||
downloadLinks.macIntel = url;
|
downloadLinks.macIntel = url;
|
||||||
} else if (name.includes('.msi')) {
|
} else if (name.endsWith('.msi')) {
|
||||||
downloadLinks.windows = url;
|
downloadLinks.windows = url;
|
||||||
} else if (name.includes('.appimage') || name.includes('.deb')) {
|
} else if (name.endsWith('.appimage') || name.endsWith('.deb')) {
|
||||||
downloadLinks.linux = url;
|
downloadLinks.linux = url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,10 +74,8 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
|||||||
const releaseInfo: ReleaseInfo = {
|
const releaseInfo: ReleaseInfo = {
|
||||||
version,
|
version,
|
||||||
downloadLinks: {
|
downloadLinks: {
|
||||||
macArm:
|
macArm: downloadLinks.macArm || `${baseUrl}/voicebox_aarch64.app.tar.gz`,
|
||||||
downloadLinks.macArm || `${baseUrl}/voicebox_${version.replace('v', '')}_aarch64.dmg`,
|
macIntel: downloadLinks.macIntel || `${baseUrl}/voicebox_x64.app.tar.gz`,
|
||||||
macIntel:
|
|
||||||
downloadLinks.macIntel || `${baseUrl}/voicebox_${version.replace('v', '')}_x64.dmg`,
|
|
||||||
windows:
|
windows:
|
||||||
downloadLinks.windows || `${baseUrl}/voicebox_${version.replace('v', '')}_x64_en-US.msi`,
|
downloadLinks.windows || `${baseUrl}/voicebox_${version.replace('v', '')}_x64_en-US.msi`,
|
||||||
linux: downloadLinks.linux || `${baseUrl}/voicebox_x86_64-unknown-linux-gnu.AppImage`,
|
linux: downloadLinks.linux || `${baseUrl}/voicebox_x86_64-unknown-linux-gnu.AppImage`,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dev": "cd tauri && bun run tauri dev",
|
"dev": "cd tauri && bun run tauri dev",
|
||||||
"dev:web": "cd web && bun run dev",
|
"dev:web": "cd web && bun run dev",
|
||||||
"dev:landing": "cd landing && 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 17493",
|
||||||
"build": "cd tauri && bun run tauri build",
|
"build": "cd tauri && bun run tauri build",
|
||||||
"build:web": "cd web && bun run build",
|
"build:web": "cd web && bun run build",
|
||||||
"build:landing": "cd landing && 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
|
done
|
||||||
sips -s format png -z 50 50 "$SOURCE_ICON" --out "$ICONS_DIR/StoreLogo.png" 2>/dev/null
|
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
|
# iOS Icons
|
||||||
echo "Generating iOS icons..."
|
echo "Generating iOS icons..."
|
||||||
mkdir -p "$ICONS_DIR/ios"
|
mkdir -p "$ICONS_DIR/ios"
|
||||||
@@ -188,6 +207,7 @@ echo "Updated:"
|
|||||||
echo " ✓ Liquid Glass icon bundle with all appearance variants"
|
echo " ✓ Liquid Glass icon bundle with all appearance variants"
|
||||||
echo " ✓ macOS/Desktop fallback icons"
|
echo " ✓ macOS/Desktop fallback icons"
|
||||||
echo " ✓ Windows Square logos"
|
echo " ✓ Windows Square logos"
|
||||||
|
echo " ✓ Windows icon.ico (multi-size)"
|
||||||
echo " ✓ iOS AppIcons (18 sizes)"
|
echo " ✓ iOS AppIcons (18 sizes)"
|
||||||
echo " ✓ Android mipmap icons (5 densities)"
|
echo " ✓ Android mipmap icons (5 densities)"
|
||||||
echo " ✓ Landing page logo"
|
echo " ✓ Landing page logo"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+213
-13
@@ -103,6 +103,24 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -267,6 +285,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfb"
|
name = "cfb"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -312,6 +339,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -378,6 +416,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-sys"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -646,6 +693,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -1212,6 +1265,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hound"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -1534,6 +1593,15 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1666,7 +1734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"libloading",
|
"libloading 0.7.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1686,6 +1754,16 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1736,6 +1814,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -1788,6 +1875,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minisign-verify"
|
name = "minisign-verify"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -1878,12 +1971,31 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1915,6 +2027,15 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -3016,6 +3137,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "screencapturekit"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -3504,7 +3631,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3586,7 +3713,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3784,7 +3911,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3810,7 +3937,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4356,8 +4483,15 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"coreaudio-sys",
|
||||||
|
"hound",
|
||||||
|
"objc",
|
||||||
|
"scopeguard",
|
||||||
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -4367,6 +4501,8 @@ dependencies = [
|
|||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"wasapi",
|
||||||
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4408,6 +4544,19 @@ dependencies = [
|
|||||||
"try-lock",
|
"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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.9.0+wasi-snapshot-preview1"
|
version = "0.9.0+wasi-snapshot-preview1"
|
||||||
@@ -4582,7 +4731,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -4606,7 +4755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4662,11 +4811,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -4678,6 +4839,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4712,7 +4882,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -4759,6 +4940,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4897,6 +5088,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -5123,7 +5323,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
@@ -20,6 +20,19 @@ tauri-plugin-shell = "2.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
hound = "3.5"
|
||||||
|
base64 = "0.22"
|
||||||
|
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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2.0"
|
tauri-plugin-updater = "2.0"
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<string>voicebox</string>
|
<string>voicebox</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>voicebox needs microphone access to record voice samples for voice cloning.</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
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
|
// Compile macOS Liquid Glass icon
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
@@ -20,14 +42,21 @@ fn main() {
|
|||||||
let output = Command::new("xcrun")
|
let output = Command::new("xcrun")
|
||||||
.args([
|
.args([
|
||||||
"actool",
|
"actool",
|
||||||
"--compile", &gen_dir,
|
"--compile",
|
||||||
"--output-format", "human-readable-text",
|
&gen_dir,
|
||||||
"--output-partial-info-plist", &partial_plist,
|
"--output-format",
|
||||||
"--app-icon", "voicebox",
|
"human-readable-text",
|
||||||
|
"--output-partial-info-plist",
|
||||||
|
&partial_plist,
|
||||||
|
"--app-icon",
|
||||||
|
"voicebox",
|
||||||
"--include-all-app-icons",
|
"--include-all-app-icons",
|
||||||
"--target-device", "mac",
|
"--target-device",
|
||||||
"--minimum-deployment-target", "11.0",
|
"mac",
|
||||||
"--platform", "macosx",
|
"--minimum-deployment-target",
|
||||||
|
"11.0",
|
||||||
|
"--platform",
|
||||||
|
"macosx",
|
||||||
&icon_source,
|
&icon_source,
|
||||||
])
|
])
|
||||||
.output();
|
.output();
|
||||||
@@ -48,7 +77,10 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
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 @@
|
|||||||
|
pub mod audio_capture;
|
||||||
+326
-11
@@ -1,13 +1,20 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod audio_capture;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
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 tauri_plugin_shell::ShellExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
const LEGACY_PORT: u16 = 8000;
|
||||||
|
const SERVER_PORT: u16 = 17493;
|
||||||
|
|
||||||
struct ServerState {
|
struct ServerState {
|
||||||
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
||||||
|
server_pid: Mutex<Option<u32>>,
|
||||||
|
keep_running_on_close: Mutex<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -16,11 +23,145 @@ async fn start_server(
|
|||||||
state: State<'_, ServerState>,
|
state: State<'_, ServerState>,
|
||||||
remote: Option<bool>,
|
remote: Option<bool>,
|
||||||
) -> Result<String, String> {
|
) -> 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() {
|
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
|
// Get app data directory
|
||||||
let data_dir = app
|
let data_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -47,12 +188,14 @@ async fn start_server(
|
|||||||
|
|
||||||
println!("Sidecar command created successfully");
|
println!("Sidecar command created successfully");
|
||||||
|
|
||||||
// Pass data directory to Python server
|
// Pass data directory and port to Python server
|
||||||
sidecar = sidecar.args([
|
sidecar = sidecar.args([
|
||||||
"--data-dir",
|
"--data-dir",
|
||||||
data_dir
|
data_dir
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| "Invalid data dir path".to_string())?,
|
.ok_or_else(|| "Invalid data dir path".to_string())?,
|
||||||
|
"--port",
|
||||||
|
&SERVER_PORT.to_string(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if remote.unwrap_or(false) {
|
if remote.unwrap_or(false) {
|
||||||
@@ -75,7 +218,9 @@ async fn start_server(
|
|||||||
println!("Server process spawned, waiting for ready signal...");
|
println!("Server process spawned, waiting for ready signal...");
|
||||||
println!("=================================================================");
|
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);
|
*state.child.lock().unwrap() = Some(child);
|
||||||
|
|
||||||
// Wait for server to be ready by listening for startup log
|
// Wait for server to be ready by listening for startup log
|
||||||
@@ -154,17 +299,75 @@ async fn start_server(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok("Server started on http://localhost:8000".to_string())
|
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||||
if let Some(child) = state.child.lock().unwrap().take() {
|
let pid = state.server_pid.lock().unwrap().take();
|
||||||
child.kill().map_err(|e| format!("Failed to kill: {}", e))?;
|
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(())
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -173,11 +376,32 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(ServerState {
|
.manage(ServerState {
|
||||||
child: Mutex::new(None),
|
child: Mutex::new(None),
|
||||||
|
server_pid: Mutex::new(None),
|
||||||
|
keep_running_on_close: Mutex::new(false),
|
||||||
})
|
})
|
||||||
|
.manage(audio_capture::AudioCaptureState::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
|
|
||||||
|
// 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)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
// Get all windows and open devtools on the first one
|
// Get all windows and open devtools on the first one
|
||||||
@@ -190,7 +414,14 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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
|
||||||
|
])
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
// Prevent automatic close
|
// Prevent automatic close
|
||||||
@@ -235,8 +466,92 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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() {
|
fn main() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "voicebox",
|
"productName": "Voicebox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"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",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user