mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
34b4445c13
Two independent user-facing fixes rolled together. ── Lyrics: YouLyPlus provider (issue #172) ── Adds word-by-word synced (karaoke) lyrics as an alternative lyrics mode. Backed by the public lyricsplus aggregator (Apple Music / Spotify / Musixmatch / QQ Music) — no API keys on our side, subscription costs are borne by the backend operator. Five mirrors are tried on network failure. Settings → Lyrics now exposes a mode radio (Standard vs YouLyPlus) plus a static-only toggle. YouLyPlus misses silently fall back to the existing server + LRCLIB + Netease pipeline so obscure tracks still resolve. The drag-to-reorder source list is hidden in YouLyPlus mode since the external aggregator manages its own source priority. Rendering: per-word spans on each line, active word highlighted with accent-tinted glow. Subscribed imperatively via usePlayerStore.subscribe so 500 ms progress ticks do not re-render the whole lyrics block. The Fullscreen Player reuses the same pattern; the 5-line rail layout is unchanged. white-space: pre on .fs-lyric-word preserves the trailing spaces the API embeds in each syllabus token (flex context would otherwise collapse them). i18n: new keys in all 8 locales (de, en, fr, nl, nb, ru, es, zh). ── macOS mic-prompt suppression ── Ships a vendored cpal 0.15.3 at patches/cpal-0.15.3/ wired via [patch.crates-io]. The only change is in src/host/coreaudio/macos/mod.rs: audio_unit_from_device now unconditionally uses IOType::DefaultOutput for output streams instead of conditionally choosing HalOutput. AUHAL (HalOutput) is classified as input-capable by macOS TCC and triggers the microphone-permission dialog at first launch / after each update even for playback-only apps. Previous attempts (removing NSMicrophoneUsageDescription, setting com.apple.security.device.audio-input false in Entitlements.plist) did not suppress the prompt because TCC fires at AudioUnit instantiation, not at plist level. The upstream fix in cpal PR #1070 / cpal 0.17 only helps when the pinned device equals the system default; always-DefaultOutput covers all cases. Tradeoff: per-device output selection is a no-op on macOS — the stream always follows the system default. Settings.tsx surfaces this via audioOutputDeviceMacNotice (hides the CustomSelect on macOS) and skips audio_list_devices + device-watcher effects there. Matches the behaviour of Apple Music / Spotify on macOS. Also removes the now-obsolete com.apple.security.device.audio-input entitlement (sandbox is disabled anyway, so it was ignored). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
8.0 KiB
Rust
233 lines
8.0 KiB
Rust
pub type SupportedInputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
|
pub type SupportedOutputConfigs = std::vec::IntoIter<SupportedStreamConfigRange>;
|
|
|
|
use super::sys;
|
|
use crate::BackendSpecificError;
|
|
use crate::DefaultStreamConfigError;
|
|
use crate::DeviceNameError;
|
|
use crate::DevicesError;
|
|
use crate::SampleFormat;
|
|
use crate::SampleRate;
|
|
use crate::SupportedBufferSize;
|
|
use crate::SupportedStreamConfig;
|
|
use crate::SupportedStreamConfigRange;
|
|
use crate::SupportedStreamConfigsError;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::atomic::AtomicI32;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// A ASIO Device
|
|
#[derive(Clone)]
|
|
pub struct Device {
|
|
/// The driver represented by this device.
|
|
pub driver: Arc<sys::Driver>,
|
|
|
|
// Input and/or Output stream.
|
|
// A driver can only have one of each.
|
|
// They need to be created at the same time.
|
|
pub asio_streams: Arc<Mutex<sys::AsioStreams>>,
|
|
pub current_buffer_index: Arc<AtomicI32>,
|
|
}
|
|
|
|
/// All available devices.
|
|
pub struct Devices {
|
|
asio: Arc<sys::Asio>,
|
|
drivers: std::vec::IntoIter<String>,
|
|
}
|
|
|
|
impl PartialEq for Device {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.driver.name() == other.driver.name()
|
|
}
|
|
}
|
|
|
|
impl Eq for Device {}
|
|
|
|
impl Hash for Device {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
self.driver.name().hash(state);
|
|
}
|
|
}
|
|
|
|
impl Device {
|
|
pub fn name(&self) -> Result<String, DeviceNameError> {
|
|
Ok(self.driver.name().to_string())
|
|
}
|
|
|
|
/// Gets the supported input configs.
|
|
/// TODO currently only supports the default.
|
|
/// Need to find all possible configs.
|
|
pub fn supported_input_configs(
|
|
&self,
|
|
) -> Result<SupportedInputConfigs, SupportedStreamConfigsError> {
|
|
// Retrieve the default config for the total supported channels and supported sample
|
|
// format.
|
|
let f = match self.default_input_config() {
|
|
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
|
|
Ok(f) => f,
|
|
};
|
|
|
|
// Collect a config for every combination of supported sample rate and number of channels.
|
|
let mut supported_configs = vec![];
|
|
for &rate in crate::COMMON_SAMPLE_RATES {
|
|
if !self
|
|
.driver
|
|
.can_sample_rate(rate.0.into())
|
|
.ok()
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
for channels in 1..f.channels + 1 {
|
|
supported_configs.push(SupportedStreamConfigRange {
|
|
channels,
|
|
min_sample_rate: rate,
|
|
max_sample_rate: rate,
|
|
buffer_size: f.buffer_size,
|
|
sample_format: f.sample_format,
|
|
})
|
|
}
|
|
}
|
|
Ok(supported_configs.into_iter())
|
|
}
|
|
|
|
/// Gets the supported output configs.
|
|
/// TODO currently only supports the default.
|
|
/// Need to find all possible configs.
|
|
pub fn supported_output_configs(
|
|
&self,
|
|
) -> Result<SupportedOutputConfigs, SupportedStreamConfigsError> {
|
|
// Retrieve the default config for the total supported channels and supported sample
|
|
// format.
|
|
let f = match self.default_output_config() {
|
|
Err(_) => return Err(SupportedStreamConfigsError::DeviceNotAvailable),
|
|
Ok(f) => f,
|
|
};
|
|
|
|
// Collect a config for every combination of supported sample rate and number of channels.
|
|
let mut supported_configs = vec![];
|
|
for &rate in crate::COMMON_SAMPLE_RATES {
|
|
if !self
|
|
.driver
|
|
.can_sample_rate(rate.0.into())
|
|
.ok()
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
for channels in 1..f.channels + 1 {
|
|
supported_configs.push(SupportedStreamConfigRange {
|
|
channels,
|
|
min_sample_rate: rate,
|
|
max_sample_rate: rate,
|
|
buffer_size: f.buffer_size,
|
|
sample_format: f.sample_format,
|
|
})
|
|
}
|
|
}
|
|
Ok(supported_configs.into_iter())
|
|
}
|
|
|
|
/// Returns the default input config
|
|
pub fn default_input_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
|
let channels = self.driver.channels().map_err(default_config_err)?.ins as u16;
|
|
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
|
|
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
|
|
let buffer_size = SupportedBufferSize::Range {
|
|
min: min as u32,
|
|
max: max as u32,
|
|
};
|
|
// Map th ASIO sample type to a CPAL sample type
|
|
let data_type = self.driver.input_data_type().map_err(default_config_err)?;
|
|
let sample_format = convert_data_type(&data_type)
|
|
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
|
|
Ok(SupportedStreamConfig {
|
|
channels,
|
|
sample_rate,
|
|
buffer_size,
|
|
sample_format,
|
|
})
|
|
}
|
|
|
|
/// Returns the default output config
|
|
pub fn default_output_config(&self) -> Result<SupportedStreamConfig, DefaultStreamConfigError> {
|
|
let channels = self.driver.channels().map_err(default_config_err)?.outs as u16;
|
|
let sample_rate = SampleRate(self.driver.sample_rate().map_err(default_config_err)? as _);
|
|
let (min, max) = self.driver.buffersize_range().map_err(default_config_err)?;
|
|
let buffer_size = SupportedBufferSize::Range {
|
|
min: min as u32,
|
|
max: max as u32,
|
|
};
|
|
let data_type = self.driver.output_data_type().map_err(default_config_err)?;
|
|
let sample_format = convert_data_type(&data_type)
|
|
.ok_or(DefaultStreamConfigError::StreamTypeNotSupported)?;
|
|
Ok(SupportedStreamConfig {
|
|
channels,
|
|
sample_rate,
|
|
buffer_size,
|
|
sample_format,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Devices {
|
|
pub fn new(asio: Arc<sys::Asio>) -> Result<Self, DevicesError> {
|
|
let drivers = asio.driver_names().into_iter();
|
|
Ok(Devices { asio, drivers })
|
|
}
|
|
}
|
|
|
|
impl Iterator for Devices {
|
|
type Item = Device;
|
|
|
|
/// Load drivers and return device
|
|
fn next(&mut self) -> Option<Device> {
|
|
loop {
|
|
match self.drivers.next() {
|
|
Some(name) => match self.asio.load_driver(&name) {
|
|
Ok(driver) => {
|
|
let driver = Arc::new(driver);
|
|
let asio_streams = Arc::new(Mutex::new(sys::AsioStreams {
|
|
input: None,
|
|
output: None,
|
|
}));
|
|
return Some(Device {
|
|
driver,
|
|
asio_streams,
|
|
current_buffer_index: Arc::new(AtomicI32::new(-1)),
|
|
});
|
|
}
|
|
Err(_) => continue,
|
|
},
|
|
None => return None,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn convert_data_type(ty: &sys::AsioSampleType) -> Option<SampleFormat> {
|
|
let fmt = match *ty {
|
|
sys::AsioSampleType::ASIOSTInt16MSB => SampleFormat::I16,
|
|
sys::AsioSampleType::ASIOSTInt16LSB => SampleFormat::I16,
|
|
sys::AsioSampleType::ASIOSTFloat32MSB => SampleFormat::F32,
|
|
sys::AsioSampleType::ASIOSTFloat32LSB => SampleFormat::F32,
|
|
sys::AsioSampleType::ASIOSTInt32MSB => SampleFormat::I32,
|
|
sys::AsioSampleType::ASIOSTInt32LSB => SampleFormat::I32,
|
|
_ => return None,
|
|
};
|
|
Some(fmt)
|
|
}
|
|
|
|
fn default_config_err(e: sys::AsioError) -> DefaultStreamConfigError {
|
|
match e {
|
|
sys::AsioError::NoDrivers | sys::AsioError::HardwareMalfunction => {
|
|
DefaultStreamConfigError::DeviceNotAvailable
|
|
}
|
|
sys::AsioError::NoRate => DefaultStreamConfigError::StreamTypeNotSupported,
|
|
err => {
|
|
let description = format!("{}", err);
|
|
BackendSpecificError { description }.into()
|
|
}
|
|
}
|
|
}
|