diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index b8af49d6..22aa6230 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -48,17 +48,32 @@ export function AudioPlayer() { // Determine if we should use native playback const useNativePlayback = useMemo(() => { - if (!isTauri() || !profileChannels || !channels) return false; + console.log('useNativePlayback memo:', { + isTauri: isTauri(), + profileId, + profileChannels, + channels, + }); + + if (!isTauri() || !profileChannels || !channels) { + console.log('useNativePlayback: false - missing requirements'); + return false; + } const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id), ); + console.log('Assigned channels:', assignedChannels); + // Use native playback if any assigned channel has non-default devices - return assignedChannels.some( + const shouldUseNative = assignedChannels.some( (ch) => ch.device_ids.length > 0 && !ch.is_default, ); - }, [profileChannels, channels, isTauri()]); + + console.log('useNativePlayback result:', shouldUseNative); + return shouldUseNative; + }, [profileChannels, channels, profileId]); const waveformRef = useRef(null); const wavesurferRef = useRef(null); @@ -157,7 +172,7 @@ export function AudioPlayer() { }); // Update store when duration is loaded - wavesurfer.on('ready', () => { + wavesurfer.on('ready', async () => { const dur = wavesurfer.getDuration(); setDuration(dur); loadingRef.current = false; @@ -178,7 +193,95 @@ export function AudioPlayer() { console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted); } - // Auto-play when ready + // Auto-play when ready - check if we should use native playback + // Get current values from the store and queries at runtime (not captured closure values) + const currentAudioUrl = usePlayerStore.getState().audioUrl; + const currentProfileId = usePlayerStore.getState().profileId; + + console.log('Auto-play check - capturing runtime values...'); + + // Fetch profile channels at runtime (not using captured value) + let runtimeProfileChannels = null; + let runtimeChannels = null; + + if (isTauri() && currentProfileId) { + try { + runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId); + console.log('Runtime profileChannels:', runtimeProfileChannels); + + if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) { + runtimeChannels = await apiClient.listChannels(); + console.log('Runtime channels:', runtimeChannels); + } + } catch (error) { + console.error('Failed to fetch runtime channel data:', error); + } + } + + console.log('Auto-play check:', { + isTauri: isTauri(), + currentAudioUrl, + currentProfileId, + hasProfileChannels: !!runtimeProfileChannels, + hasChannels: !!runtimeChannels, + }); + + if (isTauri() && currentAudioUrl && currentProfileId && runtimeProfileChannels && runtimeChannels) { + console.log('Attempting native audio playback...'); + try { + // Collect all device IDs from assigned channels + const assignedChannels = runtimeChannels.filter((ch: any) => + runtimeProfileChannels.channel_ids.includes(ch.id), + ); + console.log('Assigned channels for playback:', assignedChannels); + + // Check if any assigned channel has non-default devices + const shouldUseNative = assignedChannels.some( + (ch: any) => ch.device_ids.length > 0 && !ch.is_default, + ); + console.log('Should use native playback:', shouldUseNative); + + if (!shouldUseNative) { + console.log('No custom devices assigned, falling back to WaveSurfer'); + } else { + const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids); + console.log('Device IDs to play to:', deviceIds); + + if (deviceIds.length > 0) { + console.log('Fetching audio data from:', currentAudioUrl); + // Fetch audio data + const response = await fetch(currentAudioUrl); + const audioData = new Uint8Array(await response.arrayBuffer()); + console.log('Audio data size:', audioData.length); + + // Play via native audio + console.log('Invoking play_audio_to_devices...'); + try { + const result = await invoke('play_audio_to_devices', { + audioData: Array.from(audioData), + deviceIds: deviceIds, + }); + console.log('play_audio_to_devices completed successfully, result:', result); + setIsPlaying(true); + console.log('Auto-playing via native audio routing - SUCCESS'); + return; + } catch (invokeError) { + console.error('play_audio_to_devices invoke failed:', invokeError); + throw invokeError; + } + } else { + console.log('No device IDs found, falling back to WaveSurfer'); + } + } + } catch (error) { + console.error('Native playback failed during auto-play, falling back to WaveSurfer:', error); + // Fall through to WaveSurfer playback + } + } else { + console.log('Not using native playback, using WaveSurfer'); + } + + // Standard WaveSurfer auto-play // Use a small delay to ensure audio element is fully ready setTimeout(() => { wavesurfer.play().catch((error) => { @@ -454,8 +557,8 @@ export function AudioPlayer() { // Play via native audio await invoke('play_audio_to_devices', { - audio_data: Array.from(audioData), - device_ids: deviceIds, + audioData: Array.from(audioData), + deviceIds: deviceIds, }); setIsPlaying(true); diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index e0f1daf4..1cf11c83 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -134,7 +134,7 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) }); const audioUrl = apiClient.getAudioUrl(result.id); - setAudio(audioUrl, result.id, data.text.substring(0, 50)); + setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50)); form.reset(); setIsExpanded(false); diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 60052f25..63a67c45 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { MultiSelect } from '@/components/ui/multi-select'; import { Table, TableBody, @@ -17,6 +18,7 @@ import { TableRow, } from '@/components/ui/table'; import { apiClient } from '@/lib/api/client'; +import type { VoiceProfileResponse } from '@/lib/api/types'; import { useHistory } from '@/lib/hooks/useHistory'; import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles'; import { useUIStore } from '@/stores/uiStore'; @@ -136,12 +138,7 @@ export function VoicesTab() { } interface VoiceRowProps { - profile: { - id: string; - name: string; - description: string | null; - language: string; - }; + profile: VoiceProfileResponse; generationCount: number; channelIds: string[]; channels: Array<{ id: string; name: string; is_default: boolean }>; @@ -175,22 +172,16 @@ function VoiceRow({ {generationCount} {samples?.length || 0} - + onChange={onChannelChange} + placeholder="Select channels..." + className="min-w-[200px]" + /> diff --git a/app/src/components/ui/multi-select.tsx b/app/src/components/ui/multi-select.tsx new file mode 100644 index 00000000..0f3ffb5e --- /dev/null +++ b/app/src/components/ui/multi-select.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { ChevronDown, Check } from 'lucide-react'; +import { cn } from '@/lib/utils/cn'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; + +export interface MultiSelectOption { + value: string; + label: string; +} + +export interface MultiSelectProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + className?: string; +} + +const MultiSelectCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +MultiSelectCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +export function MultiSelect({ + options, + value, + onChange, + placeholder = 'Select...', + className, +}: MultiSelectProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = (optionValue: string) => { + const newValue = value.includes(optionValue) + ? value.filter((v) => v !== optionValue) + : [...value, optionValue]; + onChange(newValue); + }; + + const displayText = + value.length === 0 + ? placeholder + : value.length === 1 + ? options.find((opt) => opt.value === value[0])?.label || placeholder + : `${value.length} selected`; + + return ( + + + + + e.preventDefault()} + > + {options.map((option) => ( + handleSelect(option.value)} + onCheckedChange={() => handleSelect(option.value)} + > + {option.label} + + ))} + + + ); +} diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 44ae0a52..5bba547c 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -56,6 +78,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atk" version = "0.18.2" @@ -276,6 +304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -416,6 +446,17 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + [[package]] name = "coreaudio-sys" version = "0.2.17" @@ -425,6 +466,29 @@ dependencies = [ "bindgen", ] +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -540,6 +604,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "deranged" version = "0.5.5" @@ -755,6 +825,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1653,6 +1729,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1814,6 +1900,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1929,6 +2024,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1938,7 +2047,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -1950,6 +2059,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1987,6 +2105,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2260,6 +2389,29 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3448,7 +3600,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3542,6 +3694,201 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -3618,9 +3965,9 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc2", "objc2-app-kit", "objc2-foundation", @@ -4498,12 +4845,14 @@ dependencies = [ "base64 0.22.1", "core-foundation-sys", "coreaudio-sys", + "cpal", "hound", "objc", "scopeguard", "screencapturekit", "serde", "serde_json", + "symphonia", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4816,6 +5165,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4859,6 +5218,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4961,6 +5330,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5316,7 +5694,7 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-core-foundation", diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index ab893880..07cc32fb 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tokio = { version = "1", features = ["full"] } hound = "3.5" base64 = "0.22" cpal = "0.15" -symphonia = { version = "0.5", features = ["wav", "pcm"] } +symphonia = { version = "0.5", features = ["all"] } scopeguard = "1.2.0" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 6265d4db..dae2dd83 100644 Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ diff --git a/tauri/src-tauri/src/audio_output.rs b/tauri/src-tauri/src/audio_output.rs index defa294f..ef263da9 100644 --- a/tauri/src-tauri/src/audio_output.rs +++ b/tauri/src-tauri/src/audio_output.rs @@ -58,10 +58,16 @@ impl AudioOutputState { audio_data: Vec, device_ids: Vec, ) -> Result<(), String> { + eprintln!("play_audio_to_devices called with {} bytes, {} device IDs", audio_data.len(), device_ids.len()); + eprintln!("Requested device IDs: {:?}", device_ids); + // Decode audio file (assuming WAV format) + eprintln!("Decoding audio data..."); let (samples, sample_rate, channels) = self.decode_wav(&audio_data)?; + eprintln!("Audio decoded: {} samples, {}Hz, {} channels", samples.len(), sample_rate, channels); // Find devices by ID + eprintln!("Enumerating output devices..."); let devices: Vec = self .host .output_devices() @@ -69,7 +75,9 @@ impl AudioOutputState { .filter_map(|device| { let name = device.name().ok()?; let id = format!("device_{}", name.replace(' ', "_").to_lowercase()); + eprintln!("Found device: {} (id: {})", name, id); if device_ids.contains(&id) { + eprintln!(" -> Matched! Will play to this device"); Some(device) } else { None @@ -78,15 +86,21 @@ impl AudioOutputState { .collect(); if devices.is_empty() { + eprintln!("ERROR: No matching devices found"); return Err("No matching devices found".to_string()); } + eprintln!("Playing to {} device(s)", devices.len()); // Play to each device - for device in devices { - self.play_to_device(&device, samples.clone(), sample_rate, channels) - .map_err(|e| format!("Failed to play to device: {}", e))?; + for (i, device) in devices.iter().enumerate() { + let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); + eprintln!("Playing to device {}/{}: {}", i + 1, devices.len(), device_name); + self.play_to_device(device, samples.clone(), sample_rate, channels) + .map_err(|e| format!("Failed to play to device {}: {}", device_name, e))?; + eprintln!("Successfully started playback on device: {}", device_name); } + eprintln!("play_audio_to_devices completed successfully"); Ok(()) } @@ -94,83 +108,120 @@ impl AudioOutputState { use symphonia::core::formats::FormatOptions; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; - use symphonia::core::probe::Probe; + eprintln!("decode_wav: Creating MediaSourceStream from {} bytes", data.len()); let mss = MediaSourceStream::new( - Box::new(std::io::Cursor::new(data)), + Box::new(std::io::Cursor::new(data.to_vec())), Default::default(), ); - let mut probe = Probe::default(); - let mut format = probe + eprintln!("decode_wav: Probing audio format..."); + let mut format = symphonia::default::get_probe() .format( &Default::default(), mss, &FormatOptions::default(), &MetadataOptions::default(), ) - .map_err(|e| format!("Failed to probe audio: {}", e))? + .map_err(|e| { + eprintln!("decode_wav: Failed to probe audio: {}", e); + format!("Failed to probe audio: {}", e) + })? .format; + + eprintln!("decode_wav: Audio format probed successfully"); + eprintln!("decode_wav: Finding audio track..."); let track = format .tracks() .iter() .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) - .ok_or("No audio track found")?; + .ok_or_else(|| { + eprintln!("decode_wav: No audio track found"); + "No audio track found".to_string() + })?; let sample_rate = track .codec_params .sample_rate - .ok_or("No sample rate found")?; + .ok_or_else(|| { + eprintln!("decode_wav: No sample rate found in track"); + "No sample rate found".to_string() + })?; let channels = track .codec_params .channels - .ok_or("No channels found")? + .ok_or_else(|| { + eprintln!("decode_wav: No channels found in track"); + "No channels found".to_string() + })? .count() as u16; + eprintln!("decode_wav: Track info - sample_rate: {}, channels: {}", sample_rate, channels); + + eprintln!("decode_wav: Creating decoder..."); let mut decoder = symphonia::default::get_codecs() .make(&track.codec_params, &Default::default()) - .map_err(|e| format!("Failed to create decoder: {}", e))?; + .map_err(|e| { + eprintln!("decode_wav: Failed to create decoder: {}", e); + format!("Failed to create decoder: {}", e) + })?; + + eprintln!("decode_wav: Decoder created successfully"); let mut samples = Vec::new(); + let mut packet_count = 0; + eprintln!("decode_wav: Starting packet decoding loop..."); loop { let packet = match format.next_packet() { Ok(packet) => packet, - Err(_) => break, // End of stream + Err(e) => { + eprintln!("decode_wav: End of stream or error: {:?}", e); + break; + } }; + packet_count += 1; let decoded = decoder .decode(&packet) - .map_err(|e| format!("Decode error: {}", e))?; + .map_err(|e| { + eprintln!("decode_wav: Decode error on packet {}: {}", packet_count, e); + format!("Decode error: {}", e) + })?; + + // Convert to f32 samples by matching on the buffer type + use symphonia::core::audio::{AudioBufferRef, Signal}; + use symphonia::core::conv::FromSample; - // Convert to f32 samples let spec = *decoded.spec(); - let duration = decoded.capacity() as u64; + let num_channels = spec.channels.count(); + let num_frames = decoded.frames(); - // Handle multi-channel audio - if spec.channels.count() == 1 { - // Mono - let plane = decoded.plane(0); - for i in 0..duration { - if let Some(&sample) = plane.get(i as usize) { - samples.push(sample as f32 / 32768.0); - } - } - } else { - // Multi-channel - interleave - for i in 0..duration { - for ch in 0..spec.channels.count() { - if let Some(plane) = decoded.plane(ch) { - if let Some(&sample) = plane.get(i as usize) { - samples.push(sample as f32 / 32768.0); - } - } - } + eprintln!("decode_wav: Packet {} - {} frames, {} channels", packet_count, num_frames, num_channels); + + // Interleave samples from all channels + for frame_idx in 0..num_frames { + for ch in 0..num_channels { + let sample_f32 = match &decoded { + AudioBufferRef::U8(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U16(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U24(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::U32(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S8(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S16(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S24(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::S32(buf) => f32::from_sample(buf.chan(ch)[frame_idx]), + AudioBufferRef::F32(buf) => buf.chan(ch)[frame_idx], + AudioBufferRef::F64(buf) => buf.chan(ch)[frame_idx] as f32, + }; + samples.push(sample_f32); } } } + eprintln!("decode_wav: Decoded {} packets, total {} samples", packet_count, samples.len()); + eprintln!("decode_wav: Returning sample_rate={}, channels={}", sample_rate, channels); Ok((samples, sample_rate, channels)) } @@ -181,6 +232,10 @@ impl AudioOutputState { sample_rate: u32, channels: u16, ) -> Result<(), String> { + let device_name = device.name().unwrap_or_else(|_| "unknown".to_string()); + eprintln!("play_to_device: Starting playback to device: {}", device_name); + eprintln!("play_to_device: Input - {} samples, {}Hz, {} channels", samples.len(), sample_rate, channels); + let config = device .default_output_config() .map_err(|e| format!("Failed to get default config: {}", e))?; @@ -188,16 +243,29 @@ impl AudioOutputState { // Prepare samples for the device's format let device_sample_rate = config.sample_rate().0; let device_channels = config.channels(); + let device_sample_format = config.sample_format(); + + eprintln!("play_to_device: Device config - {}Hz, {} channels, format: {:?}", + device_sample_rate, device_channels, device_sample_format); // Resample if needed (simple linear interpolation for now) let resampled = if device_sample_rate != sample_rate { - self.resample(&samples, sample_rate, device_sample_rate) + eprintln!("play_to_device: Resampling from {}Hz to {}Hz", sample_rate, device_sample_rate); + let result = self.resample(&samples, sample_rate, device_sample_rate); + eprintln!("play_to_device: Resampled {} samples to {} samples", samples.len(), result.len()); + result } else { + eprintln!("play_to_device: No resampling needed"); samples }; // Interleave/convert channels if needed + eprintln!("play_to_device: Interleaving channels from {} to {} channels", channels, device_channels); let interleaved = self.interleave_channels(&resampled, channels, device_channels); + eprintln!("play_to_device: Interleaved to {} samples", interleaved.len()); + + // Calculate duration before moving interleaved + let duration_secs = (interleaved.len() as f64 / (device_sample_rate as f64 * device_channels as f64)).ceil() as u64 + 1; // Create shared buffer for playback let buffer: Arc>> = Arc::new(Mutex::new(interleaved)); @@ -289,14 +357,24 @@ impl AudioOutputState { _ => return Err("Unsupported sample format".to_string()), }; - stream.play().map_err(|e| format!("Failed to play stream: {}", e))?; + eprintln!("play_to_device: Starting stream playback..."); + stream.play().map_err(|e| { + eprintln!("play_to_device: Failed to play stream: {}", e); + format!("Failed to play stream: {}", e) + })?; + + eprintln!("play_to_device: Stream started successfully"); // Keep stream alive until playback completes // In a real implementation, we'd track this and clean up when done + eprintln!("play_to_device: Keeping stream alive for ~{} seconds", duration_secs.min(30)); std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); // Max 30s + let sleep_duration = std::time::Duration::from_secs(duration_secs.min(30)); + std::thread::sleep(sleep_duration); + eprintln!("play_to_device: Stream sleep completed, stream will be dropped"); }); + eprintln!("play_to_device: Function completed successfully"); Ok(()) }