# -*- coding: utf-8 -*- """ ap_ds Opus Support Module (Class-based API, aligned with ap_ds) ================================================================ Decode with libopusfile-0.dll + play with winmm waveOut Design: - AudioLibrary class (aligned with ap_ds class-based API) - Function names copied from ap_ds - State checks: is_music_playing / is_music_paused / get_music_fading - Metadata parsing: basic metadata + extended metadata - Fade-in play / fade-out stop - AID management - DLL handling delegated to _opusdll.py (with auto-download) Key implementation points (verified): - WAVEHDR structure: dwUser/reserved use c_void_p (8 bytes, DWORD_PTR) - Multi-buffer (4) to eliminate stuttering - CALLBACK_EVENT + WaitForSingleObject synchronization - create_string_buffer to ensure stable pointers """ import os import sys import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed # ============ Import DLL handling from _opusdll.py ============ # This imports: opusfile, winmm, kernel32, OpusHead, OpusTags, # WAVEFORMATEX, WAVEHDR, all constants, and all function bindings from ._opusdll import ( opusfile, winmm, kernel32, OpusHead, OpusTags, WAVEFORMATEX, WAVEHDR, WAVE_FORMAT_PCM, WAVE_MAPPER, CALLBACK_EVENT, WHDR_DONE, MMSYSERR_NOERROR, WAIT_OBJECT_0, op_open_file, op_free, op_head, op_tags, op_channel_count, op_pcm_total, op_bitrate, op_seekable, op_link_count, op_read_stereo, op_pcm_seek, op_pcm_tell, waveOutOpen, waveOutPrepareHeader, waveOutWrite, waveOutUnprepareHeader, waveOutClose, waveOutSetVolume, waveOutGetVolume, waveOutPause, waveOutRestart, waveOutReset, waveOutGetErrorTextW, CreateEventW, WaitForSingleObject, ResetEvent, CloseHandle, import_opus, check_opus_dll, download_opus_libraries, _opus_dll_error, ) # ============ Paths ============ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) OPUS_FILE = os.path.join(BASE_DIR, "test.opus") # ============ Error codes (aligned with ap_ds) ============ AP_DS_SUCCESS = 0 AP_DS_ERR_FILE_NOT_FOUND = 1001 AP_DS_ERR_INVALID_AID = 1002 AP_DS_ERR_AUDIO_LOAD_FAILED = 1003 AP_DS_ERR_PLAYBACK_FAILED = 1004 AP_DS_ERR_METADATA_PARSE_FAILED = 1011 AP_DS_ERR_AUDIO_NOT_LOADED = 1013 AP_DS_ERR_INVALID_SOURCE = 1014 AP_DS_ERR_INVALID_VOLUME = 1015 AP_DS_ERR_SEEK_NOT_SUPPORTED = 1016 AP_DS_ERR_UNKNOWN = 1999 # ============ Opus-specific error codes (2000+) ============ AP_DS_ERR_OPUS_LIB_LOAD_FAILED = 2001 # libopusfile-0.dll load failed AP_DS_ERR_OPUS_DLL_DEPENDENCY = 2002 # DLL dependency missing (libogg/libopus etc.) AP_DS_ERR_OPUS_OPEN_FAILED = 2003 # op_open_file open failed AP_DS_ERR_OPUS_HEADER_CORRUPT = 2004 # OpusHead header corrupt AP_DS_ERR_OPUS_TAGS_PARSE_FAILED = 2005 # OpusTags tag parse failed AP_DS_ERR_OPUS_DECODE_FAILED = 2006 # op_read_stereo decode failed AP_DS_ERR_OPUS_SEEK_FAILED = 2007 # op_pcm_seek seek failed AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE = 2008 # bitrate unavailable AP_DS_ERR_OPUS_NOT_SEEKABLE = 2009 # stream not seekable AP_DS_ERR_OPUS_CHANNEL_INVALID = 2010 # invalid channel count # Opus error code info table OPUS_ERR_INFO = { AP_DS_ERR_OPUS_LIB_LOAD_FAILED: ("Opus library load failed", "Ensure libopusfile-0.dll exists and is not locked"), AP_DS_ERR_OPUS_DLL_DEPENDENCY: ("Opus DLL dependency missing", "Ensure libopus-0.dll and libogg-0.dll are in the same directory as libopusfile-0.dll"), AP_DS_ERR_OPUS_OPEN_FAILED: ("Opus file open failed", "File may be corrupted or not a valid Opus stream"), AP_DS_ERR_OPUS_HEADER_CORRUPT: ("OpusHead header corrupt", "File header information is invalid or corrupted"), AP_DS_ERR_OPUS_TAGS_PARSE_FAILED: ("OpusTags tag parse failed", "Tag data is corrupted or in an invalid format"), AP_DS_ERR_OPUS_DECODE_FAILED: ("Opus decode failed", "Audio data decode error"), AP_DS_ERR_OPUS_SEEK_FAILED: ("Opus seek failed", "Unable to seek to the specified position"), AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE: ("Bitrate unavailable", "Unable to determine bitrate for this Opus stream"), AP_DS_ERR_OPUS_NOT_SEEKABLE: ("Stream not seekable", "This Opus stream does not support seeking"), AP_DS_ERR_OPUS_CHANNEL_INVALID: ("Invalid channel count", "The channel count of the Opus stream is invalid"), } # Opus error message mapping OP_ERR_MSG = { -128: "Read error", -127: "Internal error", -126: "Not implemented", -125: "Invalid argument", -124: "Not an Opus stream", -123: "Header corrupt", -122: "Version not supported", -121: "Not audio", -120: "Packet corrupt", -119: "Link corrupt", -118: "Not seekable", } # Fade state constants MUS_NO_FADING = 0 MUS_FADING_IN = 1 MUS_FADING_OUT = 2 # Supported audio formats SUPPORTED_AUDIO_EXTS = {'.opus', '.ogg', '.wav', '.mp3', '.flac', '.aac'} def wave_error_str(code): """Get Windows wave error message string.""" buf = __import__('ctypes').create_unicode_buffer(256) waveOutGetErrorTextW(code, buf, 256) return buf.value def opus_error_str(error_code): """Return description based on opusfile error code.""" return OP_ERR_MSG.get(error_code, f"Unknown Opus error ({error_code})") def _require_opus_dll(): """Ensure Opus DLL is loaded, return Opus-specific error tuple on failure. Returns: None or (error_code, msg, suggestion) """ if not import_opus(): # Distinguish dependency missing (2002) vs library missing (2001) if _opus_dll_error and ("dependency" in _opus_dll_error.lower() or "libopus-0.dll" in _opus_dll_error or "libogg-0.dll" in _opus_dll_error): return (AP_DS_ERR_OPUS_DLL_DEPENDENCY, f"Opus dependency DLL missing: {_opus_dll_error}", "Ensure libopus-0.dll and libogg-0.dll are in the same directory as libopusfile-0.dll") return (AP_DS_ERR_OPUS_LIB_LOAD_FAILED, f"Opus library load failed: {_opus_dll_error}", "Ensure libopusfile-0.dll exists and its dependencies (libopus-0.dll, libogg-0.dll) are in the same directory") return None # ============================================================================ # Module-level metadata helper functions # ============================================================================ def _open_opus_readonly(file_path): """Open opus file read-only (for metadata parsing), return handle or None""" if not os.path.exists(file_path): return None if not import_opus(): return None err = __import__('ctypes').c_int(0) of = op_open_file(file_path.encode('utf-8'), __import__('ctypes').byref(err)) return of if of else None def _get_opus_metadata(file_path): """Get basic metadata of Opus file, return dict or error tuple.""" of = _open_opus_readonly(file_path) if of is None: return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}", "File may be corrupted or not a valid Opus stream") try: total = op_pcm_total(of, -1) bitrate = op_bitrate(of, -1) channels = op_channel_count(of, -1) head = op_head(of, -1) if not head: return (AP_DS_ERR_OPUS_HEADER_CORRUPT, f"OpusHead corrupt for: {file_path}", "File header is invalid or corrupted") # Check channel count validity if channels < 1 or channels > 255: return (AP_DS_ERR_OPUS_CHANNEL_INVALID, f"Invalid channel count: {channels} for {file_path}", "Opus stream channel count is invalid") # Check bitrate availability if bitrate <= 0: return (AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE, f"Bitrate unavailable for: {file_path}", "Could not determine bitrate for this Opus stream") sample_rate = head.contents.input_sample_rate if head else 48000 return { "path": file_path, "format": "opus", "duration": total / 48000.0 if total > 0 else 0.0, "length": total / 48000.0 if total > 0 else 0.0, "sample_rate": sample_rate, "channels": channels, "bitrate": bitrate, } finally: op_free(of) def _get_opus_duration(file_path): """Get Opus file duration (seconds), return -1 on failure.""" of = _open_opus_readonly(file_path) if of is None: return -1 try: total = op_pcm_total(of, -1) if total <= 0: return -1 return total / 48000.0 finally: op_free(of) def _get_opus_extended_metadata(file_path): """Get extended metadata of Opus file (title/artist/album etc.), return dict or error tuple.""" of = _open_opus_readonly(file_path) if of is None: return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}", "File may be corrupted or not a valid Opus stream") try: result = {} links = op_link_count(of) for li in range(links): tags = op_tags(of, li) if tags: try: t = tags.contents if t.vendor: result["vendor"] = t.vendor.decode('utf-8', 'replace') for i in range(t.comments): if t.user_comments[i]: comment = t.user_comments[i].decode('utf-8', 'replace') if '=' in comment: k, v = comment.split('=', 1) result[k.lower()] = v else: result[f"comment_{i}"] = comment except Exception as e: return (AP_DS_ERR_OPUS_TAGS_PARSE_FAILED, f"Failed to parse Opus tags for: {file_path} ({e})", "Tag data is corrupted or in an invalid format") return result if result else None finally: op_free(of) def _expand_file_paths(file_paths): """Expand file path list: supports file, directory, or list.""" if isinstance(file_paths, (str, os.PathLike)): p = os.fspath(file_paths) if os.path.isdir(p): result = [] for root, _, files in os.walk(p): for f in files: if os.path.splitext(f)[1].lower() in SUPPORTED_AUDIO_EXTS: result.append(os.path.join(root, f)) return result else: return [p] if os.path.exists(p) else [] elif isinstance(file_paths, (list, tuple)): result = [] for item in file_paths: result.extend(_expand_file_paths(item)) return result return [] class OpusAudio: """ap_ds class-based API (Opus + waveOut implementation) Function names aligned with ap_ds, but underlying implementation uses libopusfile for decoding and winmm waveOut for playback. """ def __init__(self, frequency=48000, channels=2, volume_pct=80): self.sample_rate = frequency self.channels = channels self.volume_pct = volume_pct # Playback state self._hwo = None self._hEvent = None self._of = None self._thread = None self._fade_thread = None self._playing = False self._paused = False self._stop_flag = False self._fade_stop_flag = False self._fading = MUS_NO_FADING self._played = 0 self._total = 0 self._lock = threading.Lock() self._decode_error = None # AID management self._aid_counter = 0 self._aid_to_filepath = {} self._filepath_to_aid = {} self._channel_info = {} # ============================================================ # Playback API (aligned with ap_ds) # ============================================================ def play_from_file(self, file_path, loops=0, start_pos=0.0): """Play a file, return AID. Returns int AID on success, error tuple on failure.""" if not isinstance(file_path, (str, bytes, os.PathLike)): return (AP_DS_ERR_FILE_NOT_FOUND, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike") file_path = os.fspath(file_path) if not os.path.exists(file_path): return (AP_DS_ERR_FILE_NOT_FOUND, f"Audio file not found: {file_path}", "Verify the file path exists and is accessible") # Playback conflict detection: ensure old thread fully stopped if self._playing or self._paused: # Try to stop and wait for the old thread to exit self._stop_flag = True self._fade_stop_flag = True if self._hwo is not None: waveOutReset(self._hwo) if self._thread is not None: self._thread.join(timeout=2) self._thread = None self._playing = False self._paused = False # Small delay to ensure waveOut fully released time.sleep(0.05) # Check Opus DLL dll_err = _require_opus_dll() if dll_err is not None: return dll_err # Open opus file import ctypes err = ctypes.c_int(0) of = op_open_file(file_path.encode('utf-8'), ctypes.byref(err)) if not of: opus_err = opus_error_str(err.value) return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path} ({opus_err})", "File may be corrupted or not a valid Opus stream") # Allocate AID self._aid_counter += 1 aid = self._aid_counter self._aid_to_filepath[aid] = file_path self._filepath_to_aid[file_path] = aid self._channel_info[aid] = { "file_path": file_path, "is_music": True, "paused": False, "loops": loops, "start_time": time.time(), } # Set playback state self._of = of self.channels = op_channel_count(of, -1) self._total = op_pcm_total(of, -1) self._played = 0 self._stop_flag = False self._paused = False self._fading = MUS_NO_FADING self._decode_error = None # Start playback thread self._thread = threading.Thread(target=self._play_worker, daemon=True) self._thread.start() self._playing = True # Seek to start position if start_pos > 0: self.seek_audio(aid, start_pos) return aid def new_aid(self, file_path): """Generate AID for a file (without playing).""" if not isinstance(file_path, (str, bytes, os.PathLike)): return (AP_DS_ERR_FILE_NOT_FOUND, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike") file_path = os.fspath(file_path) if not os.path.exists(file_path): return (AP_DS_ERR_FILE_NOT_FOUND, f"Audio file not found: {file_path}", "Verify the file path exists and is accessible") if file_path in self._filepath_to_aid: aid = self._filepath_to_aid[file_path] # 如果 _channel_info 里没有 (可能被 stop_audio 删除), 重新添加 if aid not in self._channel_info: self._channel_info[aid] = { "file_path": file_path, "is_music": True, "paused": False, "loops": 0, "start_time": time.time(), } return aid self._aid_counter += 1 aid = self._aid_counter self._aid_to_filepath[aid] = file_path self._filepath_to_aid[file_path] = aid self._channel_info[aid] = { "file_path": file_path, "is_music": True, "paused": False, "loops": 0, "start_time": time.time(), } return aid def play_audio(self, aid): """Play/resume audio with the specified AID.""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") # Resume if paused if self._paused and self._hwo is not None: waveOutRestart(self._hwo) self._paused = False self._channel_info[aid]["paused"] = False return (AP_DS_SUCCESS, "", "") def play_from_memory(self, file_path, loops=0, start_pos=0.0): """Play Opus from memory (delegates to play_from_file for Opus).""" return self.play_from_file(file_path, loops, start_pos) # ============================================================ # Playback control API (aligned with ap_ds) # ============================================================ def pause_audio(self, aid): """Pause audio with the specified AID.""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") if self._hwo is not None and self._playing and not self._paused: waveOutPause(self._hwo) self._paused = True self._channel_info[aid]["paused"] = True return (AP_DS_SUCCESS, "", "") def stop_audio(self, aid): """Stop playback, return played duration.""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") played_time = self._played / self.sample_rate # Set stop flags so the playback thread cleans up safely self._stop_flag = True self._fade_stop_flag = True if aid in self._channel_info: del self._channel_info[aid] # Wait for the playback thread to fully exit (it handles waveOutClose/op_free) if self._thread is not None: self._thread.join(timeout=3) self._thread = None return played_time def _stop_playback(self): """Safely stop the currently running playback thread (if any).""" if not (self._playing or self._paused): return self._stop_flag = True self._fade_stop_flag = True if self._hwo is not None: try: waveOutReset(self._hwo) except Exception: pass if self._thread is not None: self._thread.join(timeout=2) self._thread = None self._playing = False self._paused = False time.sleep(0.05) def seek_audio(self, aid, position): """Seek to the specified position (seconds).""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") if not isinstance(position, (int, float)): return (AP_DS_ERR_UNKNOWN, f"Invalid position type: {type(position).__name__}. Expected int or float.", "Position must be a number (seconds)") file_path = self._channel_info[aid]["file_path"] position = max(0.0, float(position)) # Determine if a stream is seekable (open a probe handle if needed) probe_of = self._of needs_probe = probe_of is None if needs_probe: import ctypes as _ct _err = _ct.c_int(0) probe_of = op_open_file(file_path.encode('utf-8'), _ct.byref(_err)) if not probe_of: return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}", "File may be corrupted or not a valid Opus stream") try: if not op_seekable(probe_of): return (AP_DS_ERR_OPUS_NOT_SEEKABLE, "Opus stream is not seekable", "This Opus stream does not support seeking") finally: if needs_probe: op_free(probe_of) # Restart playback from the new position so buffered data is reset. # 1) Stop any current playback thread safely. self._stop_playback() # 2) Open a fresh opus handle (the stopped playback thread freed _of). import ctypes err = ctypes.c_int(0) of = op_open_file(file_path.encode('utf-8'), ctypes.byref(err)) if not of: return (AP_DS_ERR_OPUS_OPEN_FAILED, f"Failed to open Opus file: {file_path}", "File may be corrupted or not a valid Opus stream") target_sample = int(position * self.sample_rate) ret = op_pcm_seek(of, target_sample) if ret != 0: op_free(of) return (AP_DS_ERR_OPUS_SEEK_FAILED, f"Opus seek failed at position {position}", "The Opus stream may not support seeking to this position") self._of = of self._played = target_sample self._total = op_pcm_total(of, -1) self._stop_flag = False self._paused = False self._decode_error = None # 3) Always restart the playback thread so that seek resumes playback # from the new position (even if fade-out had previously stopped it). self._thread = threading.Thread(target=self._play_worker, daemon=True) self._thread.start() self._playing = True return (AP_DS_SUCCESS, "", "") # ============================================================ # Volume API (aligned with ap_ds, 0~128) # ============================================================ def set_volume(self, aid, volume): """Set volume (0~128, aligned with ap_ds).""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") if not isinstance(volume, int): return (AP_DS_ERR_INVALID_VOLUME, f"Invalid volume type: {type(volume).__name__} (must be integer 0-128)", "Volume must be an integer between 0 and 128") if volume < 0 or volume > 128: return (AP_DS_ERR_INVALID_VOLUME, f"Invalid volume: {volume} (must be 0-128)", "Volume range is 0-128") # Convert to 0~100 (for waveOut) pct = int(volume / 128 * 100) self.volume_pct = pct if self._hwo is not None: vol = int(0xFFFF * pct / 100) vol_dw = (vol << 16) | vol err = waveOutSetVolume(self._hwo, vol_dw) if err != MMSYSERR_NOERROR: return (AP_DS_ERR_PLAYBACK_FAILED, f"Set volume failed: {wave_error_str(err)}", "Check audio device") return (AP_DS_SUCCESS, "", "") def get_volume(self, aid): """Get volume (0~128, aligned with ap_ds).""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") if self._hwo is None: return int(self.volume_pct / 100 * 128) import ctypes cur = __import__('ctypes.wintypes', fromlist=['DWORD']).DWORD() err = waveOutGetVolume(self._hwo, ctypes.byref(cur)) if err != MMSYSERR_NOERROR: return int(self.volume_pct / 100 * 128) lv = cur.value & 0xFFFF rv = (cur.value >> 16) & 0xFFFF avg = (lv + rv) // 2 pct = int(avg / 65535 * 100) return int(pct / 100 * 128) # ============================================================ # State check API (aligned with ap_ds) # ============================================================ def is_music_playing(self): """Check if music is currently playing.""" return self._playing and self._thread is not None and self._thread.is_alive() def is_music_paused(self): """Check if music is paused.""" return self._paused def get_music_fading(self): """Get fade state: 0=none, 1=fading in, 2=fading out.""" return self._fading # ============================================================ # Fade API (fade-in play / fade-out stop) # ============================================================ def fadein_music(self, aid, loops=-1, ms=0): """Fade-in play: start from silence, gradually increase volume to target.""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") # Fade-in must STOP any current playback first, then start a fresh # playback (new AID) from silence and ramp the volume up. self._stop_playback() target = self.volume_pct old_volume = self.volume_pct self.volume_pct = 0 file_path = self._aid_to_filepath.get(aid, OPUS_FILE) result = self.play_from_file(file_path) if isinstance(result, tuple): self.volume_pct = old_volume return result self._fading = MUS_FADING_IN self._fade_stop_flag = False self._fade_thread = threading.Thread( target=self._fade_worker, args=(0, target, (2000 if not isinstance(ms, (int, float)) or ms <= 0 else ms), MUS_FADING_IN), daemon=True) self._fade_thread.start() return (AP_DS_SUCCESS, "", "") def fadein_music_pos(self, aid, loops=-1, ms=0, position=0.0): """Fade-in play from a specified position.""" if aid not in self._channel_info: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") if not isinstance(position, (int, float)): return (AP_DS_ERR_UNKNOWN, "Invalid position type for fadein_music_pos", "position must be a number (seconds)") result = self.fadein_music(aid, loops, ms) if isinstance(result, tuple) and result[0] != AP_DS_SUCCESS: return result if position > 0: self.seek_audio(aid, position) return (AP_DS_SUCCESS, "", "") def fadeout_music(self, ms=0): """Fade-out stop: gradually decrease volume to 0, then stop playback.""" if not self._playing: return (AP_DS_ERR_PLAYBACK_FAILED, "No music playing", "Ensure music is playing") if self._fading != MUS_NO_FADING: return (AP_DS_ERR_PLAYBACK_FAILED, "Fade already in progress", "Wait for fade to finish") start = self.get_volume(list(self._channel_info.keys())[0]) if self._channel_info else self.volume_pct self._fading = MUS_FADING_OUT self._fade_stop_flag = False self._fade_thread = threading.Thread( target=self._fade_worker, args=(start, 0, (2000 if not isinstance(ms, (int, float)) or ms <= 0 else ms), MUS_FADING_OUT), daemon=True) self._fade_thread.start() return (AP_DS_SUCCESS, "", "") # ============================================================ # Metadata API (aligned with ap_ds) # ============================================================ def get_audio_duration(self, source, is_file=False): """Get duration (seconds). Supports file path or AID.""" file_path = self._resolve_source(source, is_file) if isinstance(file_path, tuple): return file_path duration = _get_opus_duration(file_path) if duration < 0: return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse duration for: {file_path}", "File may be corrupted or unsupported") return int(duration) def get_audio_metadata_by_path(self, file_path): """Get complete metadata by file path.""" if not os.path.exists(file_path): return (AP_DS_ERR_FILE_NOT_FOUND, f"File not found: {file_path}", "Verify the file path exists") meta = _get_opus_metadata(file_path) if isinstance(meta, tuple): return meta # Already an error tuple (Opus-specific error code) if meta is None: return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}", "File may be corrupted or unsupported") return meta def get_audio_metadata_by_aid(self, aid): """Get complete metadata by AID.""" if aid not in self._aid_to_filepath: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid") return self.get_audio_metadata_by_path(self._aid_to_filepath[aid]) def get_audio_metadata(self, source, is_file=False): """Get metadata. Supports file path or AID.""" if is_file or isinstance(source, str): return self.get_audio_metadata_by_path(source) elif isinstance(source, int): return self.get_audio_metadata_by_aid(source) return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source).__name__}. Expected str or int.", "Use file path (str) or AID (int)") def get_audio_extended_metadata(self, file_path): """Get extended metadata (artist/title/album etc.).""" return _get_opus_extended_metadata(file_path) def _get_sample_rate(self, source): """Get sample rate (default 48000).""" meta = self.get_audio_metadata(source, is_file=isinstance(source, str)) if isinstance(meta, dict) and 'sample_rate' in meta: return meta['sample_rate'] return 48000 def _get_channels(self, source): """Get channel count (default 2).""" meta = self.get_audio_metadata(source, is_file=isinstance(source, str)) if isinstance(meta, dict) and 'channels' in meta: return meta['channels'] return 2 # ============================================================ # Batch API (aligned with ap_ds) # ============================================================ def batch_get_metadata(self, file_paths, max_workers=None, show_progress=False): """Batch parse metadata.""" files = _expand_file_paths(file_paths) if not files: return [] if max_workers is None: max_workers = min(os.cpu_count() or 4, len(files)) results = [] total = len(files) completed = 0 with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_path = {executor.submit(_get_opus_metadata, p): p for p in files} for future in as_completed(future_to_path): completed += 1 if show_progress and completed % 10 == 0: print(f"Progress: {completed}/{total} files parsed") try: meta = future.result() if isinstance(meta, dict): results.append(meta) elif show_progress: print(f"⚠️ Parse failed: {os.path.basename(future_to_path[future])}") except Exception as e: if show_progress: print(f"❌ Parse error: {e}") if show_progress: print(f"✅ Batch parse complete: {len(results)}/{total} files successful") return results def batch_get_duration(self, file_paths, max_workers=None): """Batch get durations.""" metadata_list = self.batch_get_metadata(file_paths, max_workers=max_workers) return {item["path"]: item["duration"] for item in metadata_list} def batch_get_metadata_by_type(self, file_paths, file_type, max_workers=None): """Batch parse by format.""" file_type = file_type.lower().lstrip(".") all_results = self.batch_get_metadata(file_paths, max_workers=max_workers) return [r for r in all_results if r.get("format", "").lower() == file_type] # ============================================================ # Helper methods (aligned with ap_ds) # ============================================================ def _find_channel_by_aid(self, aid): """Find channel by AID (returns aid itself).""" return aid if aid in self._channel_info else None def _get_file_path_by_aid(self, aid): """Get file path by AID.""" if aid not in self._aid_to_filepath: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid") return self._aid_to_filepath[aid] def _is_music_file(self, file_path): """Check if file is a music file (Opus/OGG/FLAC/MP3 are True).""" ext = os.path.splitext(file_path)[1].lower() return ext in ('.opus', '.ogg', '.flac', '.mp3') def _resolve_source(self, source, is_file): """Resolve source to a file path.""" if is_file or isinstance(source, str): return os.fspath(source) elif isinstance(source, int): if source not in self._aid_to_filepath: return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {source}", "Check that the AID is valid") return self._aid_to_filepath[source] return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source).__name__}", "Use file path (str) or AID (int)") # ============================================================ # Resource management (aligned with ap_ds) # ============================================================ def cleanup_function(self): """Release all resources.""" self._stop_flag = True self._fade_stop_flag = True if self._hwo is not None: waveOutReset(self._hwo) if self._thread is not None: self._thread.join(timeout=2) self._thread = None if self._fade_thread is not None: self._fade_thread.join(timeout=2) self._fade_thread = None if self._of is not None: op_free(self._of) self._of = None self._hwo = None self._hEvent = None self._playing = False self._paused = False # ============================================================ # Internal implementation # ============================================================ def _play_worker(self): """Playback thread (non-blocking) - cross-platform dispatch""" if sys.platform == "win32": self._play_worker_windows() elif sys.platform.startswith("linux"): self._play_worker_linux() elif sys.platform == "darwin": self._play_worker_macos() else: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"Unsupported platform: {sys.platform}", "Opus playback is supported on Windows, Linux, and macOS") def _play_worker_windows(self): """Playback thread (Windows) - libopusfile + winmm waveOut""" import ctypes NUM_BUFFERS = 4 BLOCK_SAMPLES = self.sample_rate // 20 # 50ms channels = self.channels of = self._of self._hEvent = CreateEventW(None, False, False, None) fmt = WAVEFORMATEX() fmt.wFormatTag = WAVE_FORMAT_PCM fmt.nChannels = channels fmt.nSamplesPerSec = self.sample_rate fmt.wBitsPerSample = 16 fmt.nBlockAlign = channels * 2 fmt.nAvgBytesPerSec = self.sample_rate * fmt.nBlockAlign fmt.cbSize = 0 self._hwo = __import__('ctypes.wintypes', fromlist=['HANDLE']).HANDLE() err = waveOutOpen(ctypes.byref(self._hwo), WAVE_MAPPER, ctypes.byref(fmt), self._hEvent, 0, CALLBACK_EVENT) if err != MMSYSERR_NOERROR: self._playing = False return # Set initial volume (0~100) pct = self.volume_pct vol = int(0xFFFF * pct / 100) waveOutSetVolume(self._hwo, (vol << 16) | vol) bufs = [ctypes.create_string_buffer(BLOCK_SAMPLES * channels * 2) for _ in range(NUM_BUFFERS)] hdrs = [WAVEHDR() for _ in range(NUM_BUFFERS)] for i in range(NUM_BUFFERS): hdrs[i].lpData = ctypes.cast(bufs[i], __import__('ctypes.wintypes', fromlist=['LPSTR']).LPSTR) hdrs[i].dwBufferLength = BLOCK_SAMPLES * channels * 2 hdrs[i].dwFlags = 0 valid = [0] * NUM_BUFFERS in_queue = [False] * NUM_BUFFERS queued = 0 finished = False def decode_to(idx): nonlocal finished pcm = (ctypes.c_int16 * (BLOCK_SAMPLES * channels))() n = op_read_stereo(of, pcm, BLOCK_SAMPLES) if n < 0: # Decode error self._decode_error = (AP_DS_ERR_OPUS_DECODE_FAILED, f"Opus decode failed: {opus_error_str(n)}", "Audio data is corrupted or the Opus stream is invalid") finished = True return 0 if n <= 0: finished = True return 0 nbytes = n * channels * 2 ctypes.memmove(bufs[idx], pcm, nbytes) hdrs[idx].dwBufferLength = nbytes valid[idx] = n return n def submit(idx): nonlocal queued hdrs[idx].dwFlags = 0 err = waveOutPrepareHeader(self._hwo, ctypes.byref(hdrs[idx]), ctypes.sizeof(WAVEHDR)) if err != MMSYSERR_NOERROR: return False err = waveOutWrite(self._hwo, ctypes.byref(hdrs[idx]), ctypes.sizeof(WAVEHDR)) if err != MMSYSERR_NOERROR: return False in_queue[idx] = True queued += 1 with self._lock: self._played += valid[idx] return True # Initial submit for i in range(NUM_BUFFERS): if decode_to(i) > 0: if not submit(i): break # Playback loop try: while queued > 0 and not self._stop_flag: ret = WaitForSingleObject(self._hEvent, 100) for i in range(NUM_BUFFERS): if in_queue[i] and (hdrs[i].dwFlags & WHDR_DONE): waveOutUnprepareHeader(self._hwo, ctypes.byref(hdrs[i]), ctypes.sizeof(WAVEHDR)) hdrs[i].dwFlags = 0 in_queue[i] = False queued -= 1 if not finished and not self._stop_flag: if decode_to(i) > 0: submit(i) finally: # Unprepare any remaining queued buffers for i in range(NUM_BUFFERS): if in_queue[i]: waveOutUnprepareHeader(self._hwo, ctypes.byref(hdrs[i]), ctypes.sizeof(WAVEHDR)) # Reset the device first (stop playback) then close try: waveOutReset(self._hwo) except Exception: pass try: waveOutClose(self._hwo) except Exception: pass try: CloseHandle(self._hEvent) except Exception: pass self._hwo = None self._hEvent = None self._playing = False if of is not None: try: op_free(of) except Exception: pass self._of = None def _play_worker_linux(self): """Playback thread (Linux) - direct ALSA via libasound.so (verified)""" import ctypes as _ct NUM_BUFFERS = 4 BLOCK_SAMPLES = self.sample_rate // 20 # 50ms channels = self.channels of = self._of # Load ALSA library try: alsa = _ct.CDLL("libasound.so.2") except Exception as e: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to load ALSA: {e}", "Install libasound2: sudo apt-get install libasound2") return # ALSA constants SND_PCM_STREAM_PLAYBACK = 0 SND_PCM_FORMAT_S16_LE = 2 SND_PCM_ACCESS_RW_INTERLEAVED = 3 # Configure ALSA functions alsa.snd_pcm_open.restype = _ct.c_int alsa.snd_pcm_open.argtypes = [_ct.POINTER(_ct.c_void_p), _ct.c_char_p, _ct.c_int, _ct.c_int] alsa.snd_pcm_set_params.restype = _ct.c_int alsa.snd_pcm_set_params.argtypes = [ _ct.c_void_p, _ct.c_int, _ct.c_int, _ct.c_uint, _ct.c_uint, _ct.c_int, _ct.c_uint] alsa.snd_pcm_writei.restype = _ct.c_long alsa.snd_pcm_writei.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_ulong] alsa.snd_pcm_drain.restype = _ct.c_int alsa.snd_pcm_drain.argtypes = [_ct.c_void_p] alsa.snd_pcm_close.restype = _ct.c_int alsa.snd_pcm_close.argtypes = [_ct.c_void_p] alsa.snd_pcm_recover.restype = _ct.c_int alsa.snd_pcm_recover.argtypes = [_ct.c_void_p, _ct.c_int, _ct.c_int] # Open PCM device pcm = _ct.c_void_p() ret = alsa.snd_pcm_open(_ct.byref(pcm), b"default", SND_PCM_STREAM_PLAYBACK, 0) if ret != 0: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"snd_pcm_open failed: {ret}", "No audio device available or no permission") return # Set parameters ret = alsa.snd_pcm_set_params( pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED, channels, self.sample_rate, 1, 500000 ) if ret != 0: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"snd_pcm_set_params failed: {ret}", "Audio device does not support these parameters") alsa.snd_pcm_close(pcm) return # Decode and play via ALSA pcm_buf = (_ct.c_int16 * (BLOCK_SAMPLES * channels))() total_played = 0 try: while not self._stop_flag: n = op_read_stereo(of, pcm_buf, BLOCK_SAMPLES) if n < 0: self._decode_error = (AP_DS_ERR_OPUS_DECODE_FAILED, f"Opus decode failed: {opus_error_str(n)}", "Audio data is corrupted") break if n <= 0: break # Write to ALSA frames_written = 0 while frames_written < n: ret = alsa.snd_pcm_writei( pcm, _ct.byref(pcm_buf, frames_written * channels * 2), n - frames_written) if ret < 0: if ret == -32: # EPIPE (underrun) alsa.snd_pcm_recover(pcm, ret, 1) continue break frames_written += ret with self._lock: self._played += n total_played += n except Exception as e: pass finally: try: alsa.snd_pcm_drain(pcm) except Exception: pass try: alsa.snd_pcm_close(pcm) except Exception: pass self._playing = False if of is not None: op_free(of) self._of = None def _play_worker_macos(self): """Playback thread (macOS) - Core Audio AudioQueue (Apple official C impl)""" import ctypes as _ct import time as _time NUM_BUFFERS = 4 BLOCK_SAMPLES = self.sample_rate // 20 # 50ms channels = self.channels of = self._of # Load AudioToolbox framework (system built-in on macOS) try: at = _ct.CDLL('/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox') except Exception as e: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to load AudioToolbox: {e}", "macOS AudioToolbox should be system built-in") return # --- AudioStreamBasicDescription (C struct) --- class AudioStreamBasicDescription(_ct.Structure): _fields_ = [ ("mSampleRate", _ct.c_double), ("mFormatID", _ct.c_uint32), ("mFormatFlags", _ct.c_uint32), ("mBytesPerPacket", _ct.c_uint32), ("mFramesPerPacket", _ct.c_uint32), ("mBytesPerFrame", _ct.c_uint32), ("mChannelsPerFrame", _ct.c_uint32), ("mBitsPerChannel", _ct.c_uint32), ("mReserved", _ct.c_uint32), ] # --- AudioQueueBuffer (Apple known layout) --- class AudioQueueBuffer(_ct.Structure): _fields_ = [ ("mAudioDataBytesCapacity", _ct.c_uint32), ("mAudioDataByteSize", _ct.c_uint32), ("mAudioData", _ct.c_void_p), ("mPacketDescriptionCapacity", _ct.c_uint32), ("mPacketDescriptionCount", _ct.c_uint32), ("mPacketDescriptions", _ct.c_void_p), ] AudioQueueRef = _ct.c_void_p AudioQueueBufferRef = _ct.POINTER(AudioQueueBuffer) # --- Constants (from CoreAudioTypes.h / AudioQueue.h) --- kAudioFormatLinearPCM = 0x6C70636D # 'lpcm' kAudioFormatFlagIsSignedInteger = 0x00000001 kAudioFormatFlagIsPacked = 0x00000002 kAudioQueueParam_Volume = 1 # AudioQueueParameterID for volume # --- Configure AudioQueue functions (Apple official) --- at.AudioQueueNewOutput.restype = _ct.c_int at.AudioQueueNewOutput.argtypes = [ _ct.POINTER(AudioStreamBasicDescription), # inFormat _ct.c_void_p, # inCallbackProc _ct.c_void_p, # inUserData _ct.c_void_p, # inCallbackRunLoop _ct.c_void_p, # inCallbackRunLoopMode _ct.c_uint32, # inFlags _ct.POINTER(AudioQueueRef), # outAQ ] at.AudioQueueAllocateBuffer.restype = _ct.c_int at.AudioQueueAllocateBuffer.argtypes = [ AudioQueueRef, _ct.c_uint32, _ct.POINTER(AudioQueueBufferRef)] at.AudioQueueEnqueueBuffer.restype = _ct.c_int at.AudioQueueEnqueueBuffer.argtypes = [ AudioQueueRef, AudioQueueBufferRef, _ct.c_uint32, _ct.c_void_p] at.AudioQueueStart.restype = _ct.c_int at.AudioQueueStart.argtypes = [AudioQueueRef, _ct.POINTER(_ct.c_uint32)] at.AudioQueueStop.restype = _ct.c_int at.AudioQueueStop.argtypes = [AudioQueueRef, _ct.c_int] at.AudioQueueDispose.restype = _ct.c_int at.AudioQueueDispose.argtypes = [AudioQueueRef, _ct.c_int] at.AudioQueueSetParameter.restype = _ct.c_int at.AudioQueueSetParameter.argtypes = [AudioQueueRef, _ct.c_uint32, _ct.c_float] # --- Set up PCM format (like C: AudioStreamBasicDescription) --- fmt = AudioStreamBasicDescription() fmt.mSampleRate = self.sample_rate fmt.mFormatID = kAudioFormatLinearPCM fmt.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked fmt.mBytesPerPacket = channels * 2 fmt.mFramesPerPacket = 1 fmt.mBytesPerFrame = channels * 2 fmt.mChannelsPerFrame = channels fmt.mBitsPerChannel = 16 fmt.mReserved = 0 # --- Shared state (like C: AQPlayerState) --- buffer_size = BLOCK_SAMPLES * channels * 2 state = { 'of': of, 'block_samples': BLOCK_SAMPLES, 'channels': channels, 'buffer_size': buffer_size, 'mIsRunning': True, # like C: aqData.mIsRunning 'mCurrentPacket': 0, # like C: packet index 'error': None, 'player': self, } # --- Callback type (like C: AudioQueueOutputCallback) --- CALLBACK_TYPE = _ct.CFUNCTYPE(None, AudioQueueRef, AudioQueueBufferRef) @CALLBACK_TYPE def HandleOutputBuffer(inAQ, inBuffer): """AudioQueue output callback (like C: HandleOutputBuffer). Decodes Opus and fills the buffer with PCM data. """ if not state['mIsRunning']: return # Decode next block (like C: AudioFileReadPackets) pcm = (_ct.c_int16 * (state['block_samples'] * state['channels']))() n = op_read_stereo(state['of'], pcm, state['block_samples']) if n < 0: state['error'] = f"decode error {n}" state['mIsRunning'] = False return if n <= 0: # No more data -> stop (like C: AudioQueueStop) at.AudioQueueStop(inAQ, False) state['mIsRunning'] = False return # Copy PCM to buffer's mAudioData (like C: inBuffer->mAudioData) data_bytes = n * state['channels'] * 2 _ct.memmove(inBuffer.contents.mAudioData, pcm, data_bytes) inBuffer.contents.mAudioDataByteSize = data_bytes # Enqueue buffer (like C: AudioQueueEnqueueBuffer) at.AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, None) state['mCurrentPacket'] += n with state['player']._lock: state['player']._played += n # --- Create AudioQueue (like C: AudioQueueNewOutput) --- queue = AudioQueueRef() ret = at.AudioQueueNewOutput( _ct.byref(fmt), HandleOutputBuffer, None, None, None, 0, _ct.byref(queue)) if ret != 0: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"AudioQueueNewOutput failed: {ret}", "Could not create audio output queue") return # --- Set volume (like C: AudioQueueSetParameter) --- gain = self.volume_pct / 100.0 at.AudioQueueSetParameter(queue, kAudioQueueParam_Volume, gain) # --- Allocate buffers and prime (like C: loop + HandleOutputBuffer) --- buffers = [] for _ in range(NUM_BUFFERS): buf = AudioQueueBufferRef() ret = at.AudioQueueAllocateBuffer(queue, buffer_size, _ct.byref(buf)) if ret != 0: break buffers.append(buf) # Prime: fill all buffers (like C: HandleOutputBuffer pre-fill) for buf in buffers: HandleOutputBuffer(queue, buf) # --- Start playback (like C: AudioQueueStart) --- ret = at.AudioQueueStart(queue, None) if ret != 0: self._playing = False self._decode_error = (AP_DS_ERR_PLAYBACK_FAILED, f"AudioQueueStart failed: {ret}", "Could not start audio queue") at.AudioQueueDispose(queue, 1) return # --- Wait for playback to complete (like C: CFRunLoopRunInMode) --- try: while state['mIsRunning'] and not self._stop_flag: _time.sleep(0.05) # Wait for remaining buffers to drain if not self._stop_flag: _time.sleep(0.3) except Exception: pass finally: # --- Cleanup (like C: AudioQueueDispose) --- at.AudioQueueStop(queue, 1) at.AudioQueueDispose(queue, 1) self._playing = False if of is not None: op_free(of) self._of = None def _fade_worker(self, start_vol, end_vol, ms, fade_type): """Fade in/out worker thread (volume gradient)""" steps = 50 step_ms = ms / steps step_delta = (end_vol - start_vol) / steps try: for i in range(1, steps + 1): if self._fade_stop_flag: break current = start_vol + step_delta * i # Directly set waveOut volume (0~100) pct = max(0, min(100, int(current))) if self._hwo is not None: vol = int(0xFFFF * pct / 100) waveOutSetVolume(self._hwo, (vol << 16) | vol) time.sleep(step_ms / 1000.0) finally: self._fading = MUS_NO_FADING if fade_type == MUS_FADING_OUT: # Fade-out finished: stop playback. Restore volume_pct to the # pre-fade level so that any later play/seek/fadein has audible # volume (otherwise it would stay at 0 and be silent). self.volume_pct = max(0, min(100, int(start_vol))) if not self._fade_stop_flag: self._stop_flag = True if self._hwo is not None: waveOutReset(self._hwo) else: # Fade-in finished: volume_pct reaches the target (end_vol). self.volume_pct = max(0, min(100, int(end_vol))) # ============================================================================ # TUI main program test # ============================================================================