mirror of
https://github.com/Yonokid/PyTaiko.git
synced 2026-02-04 11:40:13 +01:00
941 lines
34 KiB
Python
941 lines
34 KiB
Python
import os
|
|
import queue
|
|
import time
|
|
from pathlib import Path
|
|
from threading import Lock, Thread
|
|
from typing import Optional
|
|
|
|
import soundfile as sf
|
|
from numpy import (
|
|
arange,
|
|
clip,
|
|
column_stack,
|
|
float32,
|
|
frombuffer,
|
|
int16,
|
|
int32,
|
|
interp,
|
|
mean,
|
|
multiply,
|
|
ndarray,
|
|
sqrt,
|
|
uint8,
|
|
zeros,
|
|
)
|
|
|
|
os.environ["SD_ENABLE_ASIO"] = "1"
|
|
import sounddevice as sd
|
|
|
|
from libs.utils import get_config, rounded
|
|
|
|
|
|
def resample(data, orig_sr, target_sr):
|
|
# Return original data if no resampling needed
|
|
ratio = target_sr / orig_sr
|
|
if ratio == 1.0:
|
|
return data
|
|
|
|
# Handle both mono and multi-channel audio
|
|
if len(data.shape) == 1: # Mono audio
|
|
return _resample_channel(data, orig_sr, target_sr)
|
|
else: # Multi-channel audio
|
|
num_channels = data.shape[1]
|
|
resampled_channels = []
|
|
|
|
for ch in range(num_channels):
|
|
channel_data = data[:, ch]
|
|
resampled_channel = _resample_channel(channel_data, orig_sr, target_sr)
|
|
resampled_channels.append(resampled_channel)
|
|
|
|
return column_stack(resampled_channels)
|
|
|
|
def _resample_channel(channel_data, orig_sr, target_sr):
|
|
# Calculate number of samples in resampled audio
|
|
orig_length = len(channel_data)
|
|
new_length = int(orig_length * target_sr / orig_sr)
|
|
|
|
# Create time points for original and new sample rates
|
|
orig_time = arange(orig_length) / orig_sr
|
|
new_time = arange(new_length) / target_sr
|
|
|
|
# Perform linear interpolation
|
|
resampled_data = interp(new_time, orig_time, channel_data)
|
|
|
|
return resampled_data
|
|
|
|
def get_np_array(sample_width, raw_data):
|
|
if sample_width == 1:
|
|
# 8-bit samples are unsigned
|
|
data = frombuffer(raw_data, dtype=uint8)
|
|
return (data.astype(float32) - 128) / 128.0
|
|
elif sample_width == 2:
|
|
# 16-bit samples are signed
|
|
data = frombuffer(raw_data, dtype=int16)
|
|
return data.astype(float32) / 32768.0
|
|
elif sample_width == 3:
|
|
# 24-bit samples handling
|
|
data = zeros(len(raw_data) // 3, dtype=int32)
|
|
for i in range(len(data)):
|
|
data[i] = int.from_bytes(raw_data[i*3:i*3+3], byteorder='little', signed=True)
|
|
return data.astype(float32) / (2**23)
|
|
elif sample_width == 4:
|
|
# 32-bit samples are signed
|
|
data = frombuffer(raw_data, dtype=int32)
|
|
return data.astype(float32) / (2**31)
|
|
else:
|
|
raise ValueError(f"Unsupported sample width: {sample_width}")
|
|
|
|
def get_average_volume_rms(data):
|
|
"""Calculate average volume using RMS method"""
|
|
rms = sqrt(mean(data ** 2))
|
|
return rms
|
|
|
|
class Sound:
|
|
def __init__(self, file_path: Path, data: Optional[ndarray]=None, target_sample_rate: int=44100):
|
|
self.file_path = file_path
|
|
self.data = data
|
|
self.channels = 0
|
|
self.sample_rate = target_sample_rate
|
|
self.position = 0
|
|
self.is_playing = False
|
|
self.is_paused = False
|
|
self.volume = 1.0
|
|
self.pan = 0.5 # 0.0 = left, 0.5 = center, 1.0 = right
|
|
self.normalize: Optional[float] = None
|
|
|
|
if file_path.exists():
|
|
self.load()
|
|
|
|
def load(self) -> None:
|
|
"""Load and prepare the sound file data"""
|
|
data, original_sample_rate = sf.read(str(self.file_path))
|
|
|
|
if data.ndim == 1:
|
|
self.channels = 1
|
|
data = data.reshape(-1, 1)
|
|
else:
|
|
self.channels = data.shape[1]
|
|
|
|
if original_sample_rate != self.sample_rate:
|
|
print(f"Resampling {self.file_path} from {original_sample_rate}Hz to {self.sample_rate}Hz")
|
|
data = resample(data, original_sample_rate, self.sample_rate)
|
|
|
|
if self.normalize is not None:
|
|
current_rms = get_average_volume_rms(data)
|
|
if current_rms > 0: # Avoid division by zero
|
|
target_rms = self.normalize
|
|
rms_scale_factor = target_rms / current_rms
|
|
data *= rms_scale_factor
|
|
|
|
self.data = data
|
|
|
|
def play(self) -> None:
|
|
self.position = 0
|
|
self.is_playing = True
|
|
self.is_paused = False
|
|
|
|
def stop(self) -> None:
|
|
self.is_playing = False
|
|
self.is_paused = False
|
|
self.position = 0
|
|
|
|
def pause(self) -> None:
|
|
if self.is_playing:
|
|
self.is_paused = True
|
|
self.is_playing = False
|
|
|
|
def resume(self) -> None:
|
|
if self.is_paused:
|
|
self.is_playing = True
|
|
self.is_paused = False
|
|
|
|
def normalize_vol(self, rms: float) -> None:
|
|
self.normalize = rms
|
|
if self.data is not None:
|
|
self.data = None
|
|
self.load()
|
|
|
|
def get_frames(self, num_frames: int) -> Optional[ndarray]:
|
|
"""Get the next num_frames of audio data, applying volume, pitch, and pan"""
|
|
if self.data is None:
|
|
return
|
|
if not self.is_playing:
|
|
# Return silence if not playing
|
|
if self.channels == 1:
|
|
return zeros(num_frames, dtype=float32)
|
|
else:
|
|
return zeros((num_frames, self.channels), dtype=float32)
|
|
|
|
# Calculate how many frames we have left
|
|
frames_left = len(self.data) - self.position
|
|
if self.channels > 1:
|
|
frames_left = self.data.shape[0] - self.position
|
|
|
|
if frames_left <= 0:
|
|
# We've reached the end of the sound
|
|
self.is_playing = False
|
|
if self.channels == 1:
|
|
return zeros(num_frames, dtype=float32)
|
|
else:
|
|
return zeros((num_frames, self.channels), dtype=float32)
|
|
|
|
# Get the actual frames to return
|
|
frames_to_get = min(num_frames, frames_left)
|
|
|
|
if self.channels == 1:
|
|
output = zeros(num_frames, dtype=float32)
|
|
output[:frames_to_get] = self.data[self.position:self.position+frames_to_get].flatten()
|
|
else:
|
|
output = zeros((num_frames, self.channels), dtype=float32)
|
|
output[:frames_to_get] = self.data[self.position:self.position+frames_to_get]
|
|
|
|
self.position += frames_to_get
|
|
|
|
output *= self.volume
|
|
|
|
# Apply pan for stereo output
|
|
if self.channels == 2 and self.pan != 0.5:
|
|
# pan=0: full left, pan=0.5: center, pan=1: full right
|
|
left_vol = min(1.0, 2.0 * (1.0 - self.pan))
|
|
right_vol = min(1.0, 2.0 * self.pan)
|
|
output[:, 0] *= left_vol
|
|
output[:, 1] *= right_vol
|
|
return output
|
|
|
|
class Music:
|
|
def __init__(self, file_path: Path, data: Optional[ndarray]=None, target_sample_rate: int=44100, sample_rate: int =44100, preview: Optional[float]=None, normalize: Optional[float]=None):
|
|
self.file_path = file_path
|
|
self.data = data
|
|
self.target_sample_rate = target_sample_rate
|
|
self.sample_rate = sample_rate
|
|
self.channels = 0
|
|
self.position = 0 # In frames (original sample rate)
|
|
self.is_playing = False
|
|
self.is_paused = False
|
|
self.volume = 0.75
|
|
self.pan = 0.5 # Center
|
|
self.total_frames = 0
|
|
self.valid = False
|
|
self.normalize = normalize
|
|
self.preview = preview # Preview start time in seconds
|
|
self.is_preview_mode = preview is not None
|
|
|
|
self.preview_start_frame = 0
|
|
self.preview_end_frame = 0
|
|
self.uses_file_streaming = False
|
|
|
|
self.file_buffer_size = int(target_sample_rate * 5) # 5 seconds buffer
|
|
self.buffer = None
|
|
self.buffer_position = 0
|
|
|
|
# Thread-safe updates
|
|
self.lock = Lock()
|
|
self.sound_file = None
|
|
|
|
if self.file_path.exists():
|
|
self.load_from_file()
|
|
else:
|
|
self.load_from_memory()
|
|
|
|
def load_from_memory(self) -> None:
|
|
"""Load music from in-memory numpy array"""
|
|
try:
|
|
if self.data is None:
|
|
raise Exception("No data provided for memory loading")
|
|
|
|
if self.data.dtype != float32:
|
|
self.data = self.data.astype(float32)
|
|
|
|
if self.sample_rate != self.target_sample_rate:
|
|
print(f"Resampling {self.file_path} from {self.sample_rate}Hz to {self.target_sample_rate}Hz")
|
|
self.data = resample(self.data, self.sample_rate, self.target_sample_rate)
|
|
|
|
if self.normalize is not None:
|
|
current_rms = get_average_volume_rms(self.data)
|
|
if current_rms > 0: # Avoid division by zero
|
|
target_rms = self.normalize
|
|
rms_scale_factor = target_rms / current_rms
|
|
self.data *= rms_scale_factor
|
|
|
|
if self.data.ndim == 1:
|
|
self.channels = 1
|
|
self.total_frames = len(self.data)
|
|
self.data = self.data.reshape(-1, 1)
|
|
else:
|
|
self.channels = self.data.shape[1]
|
|
self.total_frames = self.data.shape[0]
|
|
|
|
self.sample_width = 4 # float32
|
|
self.uses_file_streaming = False
|
|
self._fill_buffer()
|
|
self.valid = True
|
|
print(f"Music loaded from memory: {self.channels} channels, {self.sample_rate}Hz, {self.total_frames} frames")
|
|
|
|
except Exception as e:
|
|
print(f"Error loading music from memory: {e}")
|
|
self.valid = False
|
|
|
|
def load_from_file(self) -> None:
|
|
"""Load music from file"""
|
|
try:
|
|
self.sound_file = sf.SoundFile(str(self.file_path))
|
|
|
|
# Get file properties
|
|
self.channels = self.sound_file.channels
|
|
self.sample_width = 2 if self.sound_file.subtype in ['PCM_16', 'VORBIS'] else 4
|
|
self.sample_rate = self.sound_file.samplerate
|
|
original_total_frames = self.sound_file.frames
|
|
|
|
if self.is_preview_mode:
|
|
self.preview_start_frame = int(self.preview * self.sample_rate)
|
|
available_frames = original_total_frames - self.preview_start_frame
|
|
self.preview_end_frame = min(self.preview_start_frame + available_frames, original_total_frames)
|
|
if self.preview_start_frame >= original_total_frames:
|
|
self.preview_start_frame = max(0, original_total_frames - available_frames)
|
|
self.preview_end_frame = original_total_frames
|
|
self.total_frames = self.preview_end_frame - self.preview_start_frame
|
|
self.sound_file.seek(self.preview_start_frame)
|
|
self.uses_file_streaming = True
|
|
self.data = None # Don't store full data in memory
|
|
|
|
print(f"Preview mode: Streaming {self.total_frames} frames ({self.total_frames/self.sample_rate:.2f}s) starting at {self.preview:.2f}s")
|
|
|
|
else:
|
|
self.data = self.sound_file.read()
|
|
self.total_frames = original_total_frames
|
|
self.uses_file_streaming = False
|
|
|
|
self.load_from_memory()
|
|
return # Early return to avoid duplicate processing
|
|
|
|
self._fill_buffer()
|
|
self.valid = True
|
|
|
|
if self.is_preview_mode:
|
|
print(f"Music preview streaming: {self.channels} channels, {self.sample_rate}Hz, {self.total_frames} frames ({self.get_time_length():.2f}s)")
|
|
else:
|
|
print(f"Music loaded: {self.channels} channels, {self.sample_rate}Hz, {self.total_frames} frames")
|
|
|
|
except Exception as e:
|
|
print(f"Error loading music file: {e}")
|
|
if hasattr(self, 'sound_file') and self.sound_file:
|
|
self.sound_file.close()
|
|
self.sound_file = None
|
|
self.valid = False
|
|
|
|
def _fill_buffer(self) -> bool:
|
|
"""Fill buffer from either memory or file stream"""
|
|
try:
|
|
if self.uses_file_streaming:
|
|
return self._fill_buffer_from_file()
|
|
else:
|
|
return self._fill_buffer_from_memory()
|
|
except Exception as e:
|
|
print(f"Error filling buffer: {e}")
|
|
return False
|
|
|
|
def _fill_buffer_from_memory(self) -> bool:
|
|
"""Fill buffer from in-memory data"""
|
|
if self.data is None:
|
|
return False
|
|
|
|
start_frame = self.position + self.buffer_position
|
|
end_frame = min(start_frame + self.file_buffer_size, self.total_frames)
|
|
|
|
if start_frame >= self.total_frames:
|
|
return False
|
|
|
|
data_chunk = self.data[start_frame:end_frame]
|
|
|
|
self.buffer = data_chunk
|
|
self.position += self.buffer_position
|
|
self.buffer_position = 0
|
|
return True
|
|
|
|
def _fill_buffer_from_file(self) -> bool:
|
|
"""Fill buffer by streaming from file"""
|
|
if not self.sound_file:
|
|
return False
|
|
|
|
current_absolute_position = self.preview_start_frame + self.position
|
|
if current_absolute_position >= self.preview_end_frame:
|
|
return False
|
|
|
|
frames_to_read = min(self.file_buffer_size, self.preview_end_frame - current_absolute_position)
|
|
|
|
if frames_to_read <= 0:
|
|
return False
|
|
|
|
self.sound_file.seek(current_absolute_position)
|
|
|
|
data_chunk = self.sound_file.read(frames_to_read)
|
|
|
|
if len(data_chunk) == 0:
|
|
return False
|
|
|
|
original_frames = len(data_chunk) if data_chunk.ndim == 1 else data_chunk.shape[0]
|
|
if self.sample_rate != self.target_sample_rate:
|
|
data_chunk = resample(data_chunk, self.sample_rate, self.target_sample_rate)
|
|
|
|
if self.normalize is not None:
|
|
current_rms = get_average_volume_rms(data_chunk)
|
|
if current_rms > 0:
|
|
target_rms = self.normalize
|
|
rms_scale_factor = target_rms / current_rms
|
|
data_chunk *= rms_scale_factor
|
|
|
|
if data_chunk.ndim == 1 and self.channels > 1:
|
|
data_chunk = data_chunk.reshape(-1, self.channels)
|
|
elif data_chunk.ndim == 2 and self.channels == 1:
|
|
data_chunk = data_chunk.flatten().reshape(-1, 1)
|
|
|
|
self.buffer = data_chunk
|
|
self.position += original_frames
|
|
self.buffer_position = 0
|
|
return True
|
|
|
|
def update(self) -> None:
|
|
"""Update music stream buffers"""
|
|
if not self.is_playing or self.is_paused:
|
|
return
|
|
|
|
with self.lock:
|
|
if self.buffer is None:
|
|
return
|
|
if self.buffer_position >= len(self.buffer):
|
|
self.is_playing = self._fill_buffer()
|
|
|
|
def play(self) -> None:
|
|
"""Start playing the music stream"""
|
|
with self.lock:
|
|
if self.position >= self.total_frames:
|
|
self.position = 0
|
|
self.buffer_position = 0
|
|
if self.sound_file and self.uses_file_streaming:
|
|
self.sound_file.seek(self.preview_start_frame)
|
|
self._fill_buffer()
|
|
|
|
self.is_playing = True
|
|
self.is_paused = False
|
|
|
|
def stop(self) -> None:
|
|
"""Stop playing the music stream"""
|
|
with self.lock:
|
|
self.is_playing = False
|
|
self.is_paused = False
|
|
self.position = 0
|
|
self.buffer_position = 0
|
|
if self.sound_file and self.uses_file_streaming:
|
|
self.sound_file.seek(self.preview_start_frame)
|
|
self._fill_buffer()
|
|
|
|
def pause(self) -> None:
|
|
"""Pause the music playback"""
|
|
with self.lock:
|
|
if self.is_playing:
|
|
self.is_paused = True
|
|
self.is_playing = False
|
|
|
|
def resume(self) -> None:
|
|
"""Resume the music playback"""
|
|
with self.lock:
|
|
if self.is_paused:
|
|
self.is_playing = True
|
|
self.is_paused = False
|
|
|
|
def seek(self, position_seconds) -> None:
|
|
"""Seek to a specific position in seconds (relative to preview start if in preview mode)"""
|
|
with self.lock:
|
|
frame_position = int(position_seconds * self.target_sample_rate)
|
|
frame_position = max(0, min(frame_position, self.total_frames - 1))
|
|
|
|
self.position = frame_position
|
|
self.buffer_position = 0
|
|
|
|
self._fill_buffer()
|
|
|
|
def get_time_length(self) -> float:
|
|
"""Get the total length of the music in seconds (preview length if in preview mode)"""
|
|
return self.total_frames / self.target_sample_rate
|
|
|
|
def get_time_played(self) -> float:
|
|
"""Get the current playback position in seconds (relative to preview start if in preview mode)"""
|
|
return (self.position + self.buffer_position) / self.target_sample_rate
|
|
|
|
def get_actual_time_played(self) -> float:
|
|
"""Get the actual playback position in the original file (including preview offset)"""
|
|
base_time = (self.position + self.buffer_position) / self.target_sample_rate
|
|
if self.is_preview_mode and self.preview is not None:
|
|
return base_time + self.preview
|
|
return base_time
|
|
|
|
def get_frames(self, num_frames) -> ndarray:
|
|
"""Get the next num_frames of music data, applying volume, pitch, and pan"""
|
|
if not self.is_playing:
|
|
# Return silence if not playing
|
|
if self.channels == 1:
|
|
return zeros(num_frames, dtype=float32)
|
|
else:
|
|
return zeros((num_frames, self.channels), dtype=float32)
|
|
|
|
with self.lock:
|
|
if self.buffer is None:
|
|
return zeros(num_frames, dtype=float32)
|
|
|
|
if self.buffer_position >= len(self.buffer):
|
|
if not self._fill_buffer():
|
|
self.is_playing = False
|
|
if self.channels == 1:
|
|
return zeros(num_frames, dtype=float32)
|
|
else:
|
|
return zeros((num_frames, self.channels), dtype=float32)
|
|
|
|
frames_left_in_buffer = len(self.buffer) - self.buffer_position
|
|
if self.channels > 1:
|
|
frames_left_in_buffer = self.buffer.shape[0] - self.buffer_position
|
|
|
|
frames_to_get = min(num_frames, frames_left_in_buffer)
|
|
|
|
if self.channels == 1:
|
|
output = zeros(num_frames, dtype=float32)
|
|
output[:frames_to_get] = self.buffer[self.buffer_position:self.buffer_position+frames_to_get].flatten()
|
|
else:
|
|
output = zeros((num_frames, self.channels), dtype=float32)
|
|
output[:frames_to_get] = self.buffer[self.buffer_position:self.buffer_position+frames_to_get]
|
|
|
|
self.buffer_position += frames_to_get
|
|
|
|
output *= self.volume
|
|
|
|
if self.channels == 2 and self.pan != 0.5:
|
|
# pan=0: full left, pan=0.5: center, pan=1: full right
|
|
left_vol = min(1.0, 2.0 * (1.0 - self.pan))
|
|
right_vol = min(1.0, 2.0 * self.pan)
|
|
|
|
output[:, 0] *= left_vol
|
|
output[:, 1] *= right_vol
|
|
|
|
return output
|
|
|
|
def __del__(self) -> None:
|
|
"""Cleanup when the music object is deleted"""
|
|
if hasattr(self, 'sound_file') and self.sound_file:
|
|
try:
|
|
self.sound_file.close()
|
|
except Exception:
|
|
raise Exception("unable to close music stream")
|
|
|
|
class AudioEngine:
|
|
def __init__(self, type: str) -> None:
|
|
self.target_sample_rate = 44100
|
|
self.buffer_size = 10
|
|
self.sounds: dict[str, Sound] = {}
|
|
self.music_streams = {}
|
|
self.stream = None
|
|
self.device_id = None
|
|
self.running = False
|
|
self.sound_queue: queue.Queue[str] = queue.Queue()
|
|
self.music_queue = queue.Queue()
|
|
self.master_volume = 0.70
|
|
self.output_channels = 2 # Default to stereo
|
|
self.audio_device_ready = False
|
|
|
|
# Threading for music stream updates
|
|
self.update_thread = None
|
|
self.update_thread_running = False
|
|
self.type = type
|
|
|
|
self._output_buffer = None
|
|
self._channel_conversion_buffer = None
|
|
self._expected_frames = None
|
|
|
|
def _initialize_api(self) -> bool:
|
|
"""Set up API device"""
|
|
# Find API and use its default device
|
|
hostapis = sd.query_hostapis()
|
|
api_index = -1
|
|
for i, api in enumerate(hostapis):
|
|
if isinstance(api, dict) and 'name' in api and api['name'] == self.type:
|
|
api_index = i
|
|
break
|
|
|
|
if isinstance(hostapis, tuple):
|
|
api = hostapis[api_index]
|
|
if isinstance(api, dict) and 'default_output_device' in api:
|
|
default_asio_device = api['default_output_device']
|
|
else:
|
|
raise Exception("Warning: 'default_output_device' key not found in ASIO API info.")
|
|
if default_asio_device >= 0:
|
|
self.device_id = default_asio_device
|
|
device_info = sd.query_devices(self.device_id)
|
|
if isinstance(device_info, sd.DeviceList):
|
|
raise Exception("Invalid ASIO Device")
|
|
print(f"Using default ASIO device: {device_info['name']}")
|
|
self.buffer_size = rounded(device_info['default_low_output_latency']*1000)
|
|
if 'buffer_size' in get_config()['audio']:
|
|
self.buffer_size = get_config()['audio']['buffer_size']
|
|
self.target_sample_rate = device_info['default_samplerate']
|
|
if 'sample_rate' in get_config()['audio']:
|
|
self.target_sample_rate = get_config()['audio']['sample_rate']
|
|
if self.target_sample_rate == -1:
|
|
self.target_sample_rate = device_info['default_samplerate']
|
|
# Set output channels based on device capabilities
|
|
self.output_channels = device_info['max_output_channels']
|
|
if self.output_channels > 2:
|
|
# Limit to stereo for simplicity
|
|
self.output_channels = 2
|
|
return True
|
|
else:
|
|
print("ASIO API not found, using system default device.")
|
|
|
|
# If we get here, use default system device
|
|
self.device_id = None
|
|
device_info = sd.query_devices(sd.default.device[1])
|
|
if isinstance(device_info, sd.DeviceList):
|
|
raise Exception("Invalid ASIO Device")
|
|
self.output_channels = min(2, device_info['max_output_channels'])
|
|
return True
|
|
|
|
def _audio_callback(self, outdata: ndarray, frames: int, time: int, status: str) -> None:
|
|
"""callback function for the sounddevice stream"""
|
|
|
|
if self._output_buffer is None:
|
|
raise Exception("output buffer was not allocated")
|
|
if status:
|
|
print(f"Status: {status}")
|
|
|
|
self._process_sound_queue()
|
|
self._process_music_queue()
|
|
|
|
self._output_buffer.fill(0.0)
|
|
|
|
self._mix_sounds(self._output_buffer, frames)
|
|
|
|
self._mix_music(self._output_buffer, frames)
|
|
|
|
if self.master_volume != 1.0:
|
|
multiply(self._output_buffer, self.master_volume, out=self._output_buffer)
|
|
|
|
clip(self._output_buffer, -0.95, 0.95, out=self._output_buffer)
|
|
|
|
outdata[:] = self._output_buffer
|
|
|
|
def _process_sound_queue(self) -> None:
|
|
"""Process sound queue"""
|
|
sounds_to_play = []
|
|
try:
|
|
while True:
|
|
sounds_to_play.append(self.sound_queue.get_nowait())
|
|
except queue.Empty:
|
|
pass
|
|
|
|
for sound_name in sounds_to_play:
|
|
if sound_name in self.sounds:
|
|
self.sounds[sound_name].play()
|
|
|
|
def _process_music_queue(self) -> None:
|
|
"""Process music queue"""
|
|
music_commands = []
|
|
try:
|
|
while True:
|
|
music_commands.append(self.music_queue.get_nowait())
|
|
except queue.Empty:
|
|
pass
|
|
|
|
for command in music_commands:
|
|
music_name, action, *args = command
|
|
if music_name in self.music_streams:
|
|
music = self.music_streams[music_name]
|
|
if action == 'play':
|
|
music.play()
|
|
elif action == 'stop':
|
|
music.stop()
|
|
elif action == 'pause':
|
|
music.pause()
|
|
elif action == 'resume':
|
|
music.resume()
|
|
elif action == 'seek' and args:
|
|
music.seek(args[0])
|
|
|
|
def _mix_sounds(self, output: ndarray, frames: int) -> None:
|
|
"""sound mixing"""
|
|
for sound in self.sounds.values():
|
|
if not sound.is_playing:
|
|
continue
|
|
|
|
sound_data = sound.get_frames(frames)
|
|
if sound_data is None or not isinstance(sound_data, ndarray):
|
|
continue
|
|
|
|
# Handle channel mismatch
|
|
if sound.channels != self.output_channels:
|
|
sound_data = self._convert_channels(sound_data, sound.channels)
|
|
|
|
output += sound_data
|
|
|
|
def _mix_music(self, output: ndarray, frames: int) -> None:
|
|
"""music mixing"""
|
|
for music in self.music_streams.values():
|
|
if not music.is_playing:
|
|
continue
|
|
|
|
music_data = music.get_frames(frames)
|
|
if music_data is None:
|
|
continue
|
|
|
|
if music.channels != self.output_channels:
|
|
music_data = self._convert_channels(music_data, music.channels)
|
|
|
|
output += music_data
|
|
|
|
def _convert_channels(self, data: ndarray, input_channels: int) -> ndarray:
|
|
"""Channel conversion using single pre-allocated buffer"""
|
|
if data.ndim == 1:
|
|
data = data.reshape(-1, 1)
|
|
input_channels = 1
|
|
|
|
frames = data.shape[0]
|
|
|
|
if input_channels == self.output_channels:
|
|
return data
|
|
|
|
if self._channel_conversion_buffer is None:
|
|
raise Exception("channel conversion buffer was not allocated")
|
|
|
|
self._channel_conversion_buffer[:frames, :self.output_channels].fill(0.0)
|
|
|
|
if input_channels == 1 and self.output_channels > 1:
|
|
# Mono to stereo/multi: broadcast to all channels
|
|
for ch in range(self.output_channels):
|
|
self._channel_conversion_buffer[:frames, ch] = data[:frames, 0]
|
|
|
|
elif input_channels > self.output_channels:
|
|
if self.output_channels == 1:
|
|
# Multi to mono: average channels
|
|
self._channel_conversion_buffer[:frames, 0] = mean(data[:frames, :input_channels], axis=1)
|
|
else:
|
|
# Multi to fewer channels: take first N channels
|
|
self._channel_conversion_buffer[:frames, :self.output_channels] = data[:frames, :self.output_channels]
|
|
|
|
# Return a view of the converted data
|
|
return self._channel_conversion_buffer[:frames, :self.output_channels]
|
|
|
|
def _start_update_thread(self) -> None:
|
|
"""Start a thread to update music streams"""
|
|
self.update_thread_running = True
|
|
self.update_thread = Thread(target=self._update_music_thread)
|
|
self.update_thread.daemon = True
|
|
self.update_thread.start()
|
|
|
|
def _update_music_thread(self) -> None:
|
|
"""Thread function to update all music streams"""
|
|
while self.update_thread_running:
|
|
active_streams = [music for music in self.music_streams.values() if music.is_playing]
|
|
|
|
if not active_streams:
|
|
time.sleep(0.5)
|
|
continue
|
|
|
|
for music in active_streams:
|
|
music.update()
|
|
|
|
time.sleep(0.1)
|
|
|
|
def init_audio_device(self):
|
|
if self.audio_device_ready:
|
|
return True
|
|
|
|
try:
|
|
self._initialize_api()
|
|
|
|
self._expected_frames = self.buffer_size
|
|
self._output_buffer = zeros((self._expected_frames, self.output_channels), dtype=float32)
|
|
self._channel_conversion_buffer = zeros((self._expected_frames, max(8, self.output_channels)), dtype=float32)
|
|
# Set up and start the stream
|
|
extra_settings = None
|
|
buffer_size = self.buffer_size
|
|
self.stream = sd.OutputStream(
|
|
samplerate=self.target_sample_rate,
|
|
channels=self.output_channels,
|
|
callback=self._audio_callback,
|
|
blocksize=buffer_size,
|
|
device=self.device_id,
|
|
latency='low',
|
|
extra_settings=extra_settings
|
|
)
|
|
self.stream.start()
|
|
self.running = True
|
|
self.audio_device_ready = True
|
|
|
|
# Start update thread for music streams
|
|
self._start_update_thread()
|
|
|
|
print(f"Audio device initialized with {self.output_channels} channels at {self.target_sample_rate}Hz")
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error initializing audio device: {e}")
|
|
self.audio_device_ready = False
|
|
return False
|
|
|
|
def close_audio_device(self) -> None:
|
|
self.update_thread_running = False
|
|
if self.update_thread:
|
|
self.update_thread.join(timeout=1.0)
|
|
|
|
if self.stream:
|
|
self.stream.stop()
|
|
self.stream.close()
|
|
self.stream = None
|
|
|
|
self.running = False
|
|
self.audio_device_ready = False
|
|
self.sounds = {}
|
|
self.music_streams = {}
|
|
self.sound_queue = queue.Queue()
|
|
self.music_queue = queue.Queue()
|
|
self._output_buffer = None
|
|
self._channel_conversion_buffer = None
|
|
print("Audio device closed")
|
|
return
|
|
|
|
def is_audio_device_ready(self) -> bool:
|
|
return self.audio_device_ready
|
|
|
|
def set_master_volume(self, volume: float):
|
|
self.master_volume = max(0.0, min(1.0, volume))
|
|
|
|
def get_master_volume(self) -> float:
|
|
return self.master_volume
|
|
|
|
def load_sound(self, fileName: Path) -> str:
|
|
sound = Sound(fileName, target_sample_rate=self.target_sample_rate)
|
|
sound_id = f"sound_{len(self.sounds)}"
|
|
self.sounds[sound_id] = sound
|
|
print(f"Loaded sound from {fileName} as {sound_id}")
|
|
return sound_id
|
|
|
|
def play_sound(self, sound) -> None:
|
|
if sound in self.sounds:
|
|
self.sound_queue.put(sound)
|
|
|
|
def stop_sound(self, sound) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].stop()
|
|
|
|
def pause_sound(self, sound: str) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].pause()
|
|
|
|
def resume_sound(self, sound: str) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].resume()
|
|
|
|
def unload_sound(self, sound: str) -> None:
|
|
if sound in self.sounds:
|
|
del self.sounds[sound]
|
|
|
|
def unload_all_sounds(self) -> None:
|
|
sounds_to_clear = list(self.sounds.keys())
|
|
for key in sounds_to_clear:
|
|
if key in self.sounds:
|
|
del self.sounds[key]
|
|
|
|
def normalize_sound(self, sound: str, rms: float) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].normalize_vol(rms)
|
|
|
|
def is_sound_valid(self, sound: str) -> bool:
|
|
return sound in self.music_streams
|
|
|
|
def is_sound_playing(self, sound: str) -> bool:
|
|
if sound in self.sounds:
|
|
return self.sounds[sound].is_playing
|
|
return False
|
|
|
|
def set_sound_volume(self, sound: str, volume: float) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].volume = max(0.0, volume)
|
|
|
|
def set_sound_pan(self, sound: str, pan: float) -> None:
|
|
if sound in self.sounds:
|
|
self.sounds[sound].pan = max(0.0, min(1.0, pan))
|
|
|
|
def load_music_stream(self, fileName: Path, preview: float=0, normalize: Optional[float] = None) -> str:
|
|
music = Music(file_path=fileName, target_sample_rate=self.target_sample_rate, preview=preview, normalize=normalize)
|
|
music_id = f"music_{len(self.music_streams)}"
|
|
self.music_streams[music_id] = music
|
|
print(f"Loaded music stream from {fileName} as {music_id}")
|
|
return music_id
|
|
|
|
def load_music_stream_from_data(self, audio_array: ndarray, sample_rate: int=44100) -> str:
|
|
"""Load music stream from numpy array data"""
|
|
# Create a dummy path since Music class expects one
|
|
dummy_path = Path("memory_audio")
|
|
music = Music(file_path=dummy_path, data=audio_array, target_sample_rate=self.target_sample_rate, sample_rate=sample_rate)
|
|
music_id = f"music_{len(self.music_streams)}"
|
|
self.music_streams[music_id] = music
|
|
print(f"Loaded music stream from memory data as {music_id}")
|
|
return music_id
|
|
|
|
def is_music_valid(self, music: str) -> bool:
|
|
if music in self.music_streams:
|
|
return self.music_streams[music].valid
|
|
return False
|
|
|
|
def unload_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
del self.music_streams[music]
|
|
|
|
def play_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
self.music_queue.put((music, 'play'))
|
|
|
|
def is_music_stream_playing(self, music: str) -> bool:
|
|
if music in self.music_streams:
|
|
return self.music_streams[music].is_playing
|
|
return False
|
|
|
|
def update_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
self.music_streams[music].update()
|
|
|
|
def stop_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
self.music_queue.put((music, 'stop'))
|
|
|
|
def pause_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
self.music_queue.put((music, 'pause'))
|
|
|
|
def resume_music_stream(self, music: str) -> None:
|
|
if music in self.music_streams:
|
|
self.music_queue.put((music, 'resume'))
|
|
|
|
def seek_music_stream(self, music: str, position: float) -> None:
|
|
if music in self.music_streams:
|
|
self.music_queue.put((music, 'seek', position))
|
|
|
|
def set_music_volume(self, music: str, volume: float) -> None:
|
|
if music in self.music_streams:
|
|
self.music_streams[music].volume = max(0.0, min(1.0, volume))
|
|
|
|
def set_music_pan(self, music: str, pan: float) -> None:
|
|
if music in self.music_streams:
|
|
self.music_streams[music].pan = max(0.0, min(1.0, pan))
|
|
|
|
def normalize_music_stream(self, music: str, rms: float) -> None:
|
|
if music in self.music_streams:
|
|
self.music_streams[music].normalize = rms
|
|
|
|
def get_music_time_length(self, music: str) -> float:
|
|
if music in self.music_streams:
|
|
return self.music_streams[music].get_time_length()
|
|
raise ValueError(f"Music stream {music} not initialized")
|
|
|
|
def get_music_time_played(self, music: str) -> float:
|
|
if music in self.music_streams:
|
|
return self.music_streams[music].get_time_played()
|
|
raise ValueError(f"Music stream {music} not initialized")
|
|
|
|
audio = AudioEngine(get_config()["audio"]["device_type"])
|