commit 646c1b11c8c640b9eebd9df9c004ede4dd2705c3 Author: dvs Date: Thu Aug 27 19:13:25 2026 +0800 Initial commit: ap_ds 音频播放库 (Audio Player By DVS 原版) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2a8660 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python 缓存 +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +dist/ +build/ +.eggs/ + +# 版本/环境 +.venv/ +venv/ +env/ +.idea/ +.vscode/ +*.log + +# 系统文件 +Thumbs.db +.DS_Store +desktop.ini diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7881c1c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,159 @@ +# DVS Audio Library (ap_ds) Open Source License Version 2.0 + +**Version: 2.0** +**Effective Date: March 22, 2026** +**Applies to: ap_ds version 2.4.1 and above (except for subsequent license updates)** +**Project Homepage: https://apds.top** + +--- + +## 1. Definitions + +1.1. **"Software"** means the DVS Audio Library (ap_ds) project and all its components, source code, object code, and related documentation. The official name of this project is "ap_ds", and the following names are also granted as officially recognized brand identifiers: + - AP_DS + - Audio Library By DVS + - DVS Audio Player + (All of the above names are case-insensitive and are considered officially recognized brand names.) + +1.2. **"Source Code"** means the human-readable form of the Software, which is the basis for modification, study, and distribution. + +1.3. **"Modified Version"** means any derivative work created by modifying, supplementing, translating, or otherwise altering the Software, in whole or in part. + +1.4. **"Distribute"** means making the Software or a Modified Version available to any third party by any means or medium. + +1.5. **"You"** means any individual or legal entity exercising the rights granted under this License. + +1.6. **"Independent Brand"** means a completely new project name, logo, and brand identity that has no confusing association with the official names of the Software (including but not limited to "ap_ds", "AP_DS", "Audio Library By DVS", "DVS Audio Player", and any variants thereof). + +--- + +## 2. Grant of License + +Subject to the terms and conditions of this License, the Author hereby grants You a perpetual, worldwide, royalty-free, non-exclusive, irrevocable right to: + +2.1. **Use and Run**: Run the Software on any computer system for any lawful purpose. + +2.2. **Copy and Distribute**: Make any number of copies of the Software and Distribute them. + +2.3. **Study and Modify**: Study the Software's Source Code and make any modifications to meet Your needs. + +2.4. **Integrate and Commercially Use**: Integrate the Software into Your products or projects, and use it in any commercial context, including but not limited to commercial product integration, cloud service deployment, selling solutions incorporating the Software, and internal corporate use. + +--- + +## 3. Obligations and Restrictions + +### 3.1. Attribution and Source Identification + +Any time the Software or a Modified Version is used, Distributed, or integrated, You must: + + a) **Retain Original Copyright Notices**: Keep intact all original copyright, patent, and trademark notices in all copies of the Software. + + b) **Provide Prominent Source Attribution**: Clearly and conspicuously state the following information in the software documentation, official website, user interface, or related materials: + ``` + Based on DVS Audio Library (ap_ds) v[version number] + Original Author: Dvs (DvsXT) + Project Homepage: https://apds.top + ``` + + c) **Add Notice for Modified Versions**: If You Distribute a Modified Version, in addition to the attribution above, You must add the following notice: + ``` + This is a modified version maintained by [Your Name/Organization]. + Support: [Your Contact Information]. + This version is not the official version and is not affiliated with the original author. + ``` + +### 3.2. Brand Protection + +To prevent brand confusion and project fragmentation, Modified Versions must comply with the following strict rules: + + a) **Prohibition on Using Original Brand Names**: You must not name a Modified Version "ap_ds", "AP_DS", "Audio Library By DVS", "DVS Audio Player", or any variant, combination, or derivative that could cause confusion. + + b) **Requirement for Independent Brand**: Modified Versions must use a completely independent project name and establish their own independent project identity, documentation, and community. + + c) **Maintainer Responsibility Statement**: The distributor of a Modified Version must state prominently on their project homepage or in a conspicuous location: + ``` + This project is based on DVS Audio Library (ap_ds) but has evolved independently and is fully maintained by [Your Name]. + For the original version, please visit: https://apds.top. + The maintainer is solely responsible for any issues related to this project. + ``` + +### 3.3. Quality Commitment for Modified Versions + +If You Distribute a Modified Version, You must: + + a) **Clearly State the Nature of Modifications**: Clearly indicate that this is a modified version and list the key modifications and compatibility notes compared to the original version. + + b) **Provide Technical Support**: Provide a valid means of technical support contact for the Modified Version You distribute, and define the scope of support. + + c) **Not Mislead Users**: You must not imply in any way that Your Modified Version is officially endorsed, supported, or is a continuation of the original project. + +### 3.4. Prohibited Uses + +You must not use the Software for any illegal activities, malicious purposes, or actions that violate local laws or regulations, including but not limited to: + a) Disrupting computer systems or network security. + b) Distributing malware or viruses. + c) Infringing on the intellectual property or privacy rights of others. + +--- + +## 4. Patent Grant + +4.1. **Patent License**: The Author hereby grants You a worldwide, royalty-free, non-exclusive, non-transferable patent license to make, use, sell, offer for sale, import, or otherwise transfer the Software. + +4.2. **Patent Defense Termination**: If You or Your affiliates file a patent infringement lawsuit against the Author regarding the Software, all rights granted to You under this License will automatically and immediately terminate. + +--- + +## 5. Technical Transparency and Security + +5.1. **Right to Security Review**: Any user has the right to conduct a security audit of the Software's Source Code. Commercial users may engage third-party professionals for this purpose. + +5.2. **Security Reporting**: Reporting discovered security issues to the original Author (me@dvsyun.top) is encouraged, and public disclosure after resolution is supported. + +5.3. **No Backdoors Commitment**: The officially released version commits to containing no malicious code, backdoors, or user-data collection features without explicit user consent. + +--- + +## 6. Disclaimer of Warranty and Limitation of Liability + +6.1. **Disclaimer of Warranty**: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND ABSENCE OF ERRORS. + +6.2. **Limitation of Liability**: TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES (INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA LOSS, OR BUSINESS INTERRUPTION) ARISING OUT OF THE USE OF OR INABILITY TO USE THE SOFTWARE. + +--- + +## 7. License Management and Termination + +7.1. **Version Control**: This License is version 2.0. Subsequent versions will be published on the project homepage. You may choose to follow the terms of this version or any later version. + +7.2. **Compatibility**: This License is compatible with the MIT, BSD 3-Clause, and Apache 2.0 licenses. + +7.3. **Automatic Termination**: Your rights under this License will terminate automatically if You fail to comply with its terms. However, if You cease all non-compliance and cure all violations within 30 days of receiving notice from the copyright holder, and the copyright holder has not terminated Your rights within that period, Your rights will be reinstated. + +--- + +## 8. Governing Law and Dispute Resolution + +8.1. **Governing Law**: This License shall be governed by the laws of the People's Republic of China, without regard to its conflict of law provisions. + +8.2. **Dispute Resolution**: Any dispute arising out of or in connection with this License shall first be resolved through friendly negotiation. If negotiation fails, either party may submit the dispute to the competent people's court located in the project author's domicile. + +--- + +## 9. Contact Information + +9.1. **Licensing and Inquiries**: + - Email: me@dvsyun.top or dvs6666@163.com + - Project Homepage: https://apds.top + - Response Time: Within 7 business days + +9.2. **Technical Support**: + - Priority should be given to submitting issues via GitCode Issues. + - Urgent matters can be directed to the emails above. + +--- + +**BY USING, COPYING, MODIFYING, OR DISTRIBUTING THE SOFTWARE, YOU ACCEPT ALL TERMS AND CONDITIONS OF THIS LICENSE.** + +--- \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..69f1bb0 --- /dev/null +++ b/README.md @@ -0,0 +1,2989 @@ +# 🎉 ap_ds 4.1.0 RC Prerelease — Opus Support, But Please Help Us Test! + +> **⚠️ Important: 4.1.0 is an RC (Release Candidate) prerelease version for collecting feedback and bug reports.** +> +> **Opus support currently exists in ONLY this one mainline version (4.1.0). Future mainline versions (4.2.0+) will REMOVE Opus support. Opus will migrate to the AFS branch (ap-ds-afs) for independent development.** +> +> **If you find any issues, please contact us immediately:** +> - 📧 Primary email: me@dvsyun.top +> - 📧 Backup email: dvs6666@163.com +> - ⏱️ We promise: **Fix within 3 business days** +> +> **Your feedback is crucial to us!** Let's polish Opus support to perfection together. + + +> **"We're not just supporting a new format — we're opening a door to higher quality, smaller size, and a freer future."** +> — DVS Development Team, August 19, 2026 + + +# ap_ds: Lightweight Python Audio Library + +**Current Version: v4.1.0 RC – Opus Support Preview** + +**Release Date: August 19, 2026** + +**Version Status: 🚀 Release Candidate — Testing Feedback Welcome** + + +## 📢 To All Audio Developers + +Friends, colleagues, music lovers, and all who have ever wrestled with audio formats late into the night: + +**Today, we announce — ap_ds 4.1.0 RC is officially released, supporting the Opus audio format in the mainline branch for the very first time!** + +But before the excitement takes over, we must be honest with you: + +**This is an RC (Release Candidate) prerelease version.** + +What does that mean? It means: + +- ✅ All core features are implemented and tested +- ✅ 229 Opus-specific tests all pass +- ✅ Cross-platform (Windows/Linux/macOS) playback verified +- ⚠️ There may still be edge-case bugs we haven't discovered +- ⚠️ We need real users to verify it in different environments + +**So we're handing this version to you — our users — to help us test.** + +> **"4.1.0 is the only appearance of Opus in the mainline. After this, it will move to the AFS branch."** + +This is part of ap_ds's dual-track strategy. We'll explain in detail in the following sections. + + +## 🚨 Important Notes on 4.1.0's Version Positioning + +### 1. This Is an RC Prerelease + +4.1.0 is **NOT** an LTS (Long-Term Support) version, nor is it the final stable release. It is a **Release Candidate**, intended for: + +- Collecting real-world usage feedback +- Discovering bugs that we couldn't cover in testing +- Verifying cross-platform compatibility +- Gathering data for the final release + +### 2. Opus Support Exists ONLY in 4.1.0 Mainline + +**This is a very important statement:** + +> **Opus support currently exists in ONLY version 4.1.0 of the ap_ds mainline branch.** +> +> **Future mainline versions (4.2.0, 4.3.0, etc.) will REMOVE Opus support.** +> +> **Opus and all future new formats will migrate to the AFS branch (ap-ds-afs), developing independently with 1.x version numbers.** + +Here's what this means: + +| Version | Opus Included? | Description | +|---------|---------------|-------------| +| **4.1.0 RC** | ✅ **Yes** | The ONLY mainline version with Opus | +| **4.2.0+ (mainline)** | ❌ **No** | Opus removed, back to lightweight positioning | +| **ap-ds-afs 1.0.0+** | ✅ **Yes** | Permanent home for Opus and all new formats | + +**Why?** + +Because ap_ds mainline's core promise is **2.5MB lightweight**. Opus support (including DLLs) increased the size to 3.87MB. 4.2.0 will directly remove Opus, returning to 2.5MB — more thorough than any optimization. + +**So, if you need Opus support:** + +- Short-term testing: Use 4.1.0 RC +- Long-term use: Switch to the AFS branch (`pip install ap-ds-afs`) + +Both packages use the exact same import method (`from ap_ds import AudioLibrary`), so migration cost is zero. + +### 3. Issue Reporting and Fix Commitment + +**If you discover any issues while using 4.1.0 RC:** + +1. **Contact the author immediately:** + - Primary email: me@dvsyun.top + - Backup email: dvs6666@163.com + - Please CC both emails to ensure delivery + +2. **We promise:** + - **Fix within 3 business days** + - Patch version (4.1.1) will be released as soon as possible + +3. **When reporting, please provide:** + - Operating system and version + - Python version (`python --version`) + - Full error message (if any) + - Steps to reproduce + - Audio file sample (if possible) + +**Every piece of feedback helps us build a better ap_ds.** 🙏 + + +## 🤔 Why Opus? — The Inevitability of a Technical Choice + +### 1. The "Ceiling" of Quality and Compression + +Opus is an open audio codec jointly developed by the **Xiph.Org Foundation** and **IETF**, combining **SILK** (for speech) and **CELT** (for general audio) algorithms, with adaptive bitrates from **6 kbps to 510 kbps**. This means: + +- **At low bitrates (< 32 kbps)**, Opus voice clarity far exceeds MP3 and AAC +- **At medium-high bitrates (64–128 kbps)**, Opus quality rivals or even surpasses MP3 at 320kbps +- **Ultra-low latency (as low as 5ms)**, ideal for real-time communication and gaming + +### 2. Open Source and Freedom — A Philosophical Fit + +Opus uses a **BSD-style license** with no patent restrictions, completely free. This aligns perfectly with ap_ds's commitment to **openness, freedom, and zero burden**. + +### 3. Mature Ecosystem and Clear Demand + +Today, Opus is widely used in: + +- **WebRTC** (real-time audio/video communication) +- **Discord, WhatsApp, Signal** and other instant messaging apps +- **Game engines** (Unity, Unreal both support Opus) +- **Audio streaming** (Radio, Podcasts) +- **Embedded devices** (low power, high compression) + +As more audio content is published in Opus format, ap_ds as a general-purpose audio library must respond to this trend. + +### 4. Paving the Way for the AFS Branch + +Opus is the first member of the AFS (All-Format Support) branch. Through the 4.1.0 RC practice, we will validate the technical solution for cross-platform Opus playback, accumulating experience for the official release of the AFS branch. + + +## 😅 Why Wasn't Opus Supported Before? — The Story of Upstream Dependencies + +That's a great question. To be honest, **we always wanted to support Opus**, since ap_ds 1.0. But the reality was: + +**ap_ds's audio playback has always been built on SDL2 and SDL2_mixer.** + +SDL2 is an excellent cross-platform multimedia library that handles low-level details like audio device abstraction, mixing, and buffering across Windows, macOS, and Linux. SDL2_mixer provides out-of-the-box support for MP3, WAV, OGG, FLAC, and other formats. + +**But here's the problem — SDL2_mixer's Opus support has never been stable enough.** + +Specifically: + +| Issue | Description | +|-------|-------------| +| **Windows** | Official SDL2_mixer Windows binaries often omit Opus support or have incomplete compilation flags, causing `Mix_LoadMUS` to return NULL for `.opus` files | +| **macOS** | SDL2_mixer's Framework builds often have version mismatches with `libopusfile` and `libogg` dependencies, leading to runtime crashes | +| **Linux** | SDL2_mixer depends on system-installed `libopusfile-dev`, but package names and versions vary wildly across distributions | +| **API Inconsistency** | Even when Opus loads, SDL2_mixer's metadata extraction (duration, bitrate, etc.) often returns 0 or incorrect values | + +**We tried various approaches:** + +1. Compiling our own Opus-enabled SDL2_mixer binaries → size ballooned, maintenance cost too high +2. Forcing users to install `libopusfile-dev` on Linux → poor user experience, and Windows/macOS couldn't be solved +3. Waiting for upstream SDL2_mixer fixes → waited through multiple versions, issues persist + +**Conclusion: SDL2_mixer's upstream support was insufficient for us to provide stable Opus playback.** + +So, before 4.1.0, we made a difficult decision — **to temporarily not provide Opus support**, to avoid giving users an unstable experience. + +### How Did 4.1.0 Solve It? + +Since SDL2 wasn't working, we decided **not to use SDL2 at all**! + +In 4.1.0, we completely bypass SDL2 and SDL2_mixer, building a dedicated playback pipeline for Opus: + +``` +User passes .opus file + ↓ + Detects .opus extension + ↓ + libopusfile decodes + ↓ + Cross-platform audio output engine + ├── Windows → winmm waveOut + ├── Linux → ALSA (libasound.so) + └── macOS → Core Audio AudioQueue + ↓ + Audio output to speakers +``` + +This pipeline is completely independent of SDL2,不受 any limitations from SDL2_mixer. + +**Meanwhile, Windows users don't need to manually find DLLs** — ap_ds will automatically download the required four DLLs (`libopusfile-0.dll`, `libopus-0.dll`, `libogg-0.dll`, `libopusurl-0.dll`) from `https://dvsyun.top/ap_ds/download/` on first run, with SHA256 hash verification to ensure file integrity. + +**This is why 4.1.0 can support Opus, and why it couldn't before.** It's not that we didn't want to — it's that we had to find a solution that was reliable, stable, and truly cross-platform. Now, we have. + + +## 🧬 4.1.0 Technical Deep Dive — What Did We Actually Do? + +### 1. New Modules and Architecture Refactoring + +To integrate Opus support without affecting mainline stability, we carefully designed the project structure: + +| File | Responsibility | Description | +|------|----------------|-------------| +| `opusplayer.py` | **Opus playback core** | Contains `OpusAudio` class, encapsulating all Opus playback, control, metadata, and batch processing APIs, completely independent of the SDL2 engine | +| `_opusdll.py` | **Opus library loader** | Handles cross-platform loading of `libopusfile` (Windows auto-download, Linux/macOS system detection + install guidance), provides unified `opusfile` handle | +| `player.py` (modified) | **Opus routing integration** | Auto-detects `.opus` files in `AudioLibrary` and forwards them to the `OpusAudio` sub-player, enabling completely transparent Opus playback | +| `__init__.py` (modified) | **Exports OpusAudio** | Users can directly `from ap_ds import OpusAudio` for standalone player, or use seamlessly via `AudioLibrary` | + +**Key Design: AID 1:1 Mapping** + +When a user plays via `AudioLibrary.play_from_file("song.opus")`, the library internally creates an `OpusAudio` instance and generates a sub-AID, then uses a dictionary `_aid_to_opus_aid` to map the main library AID to the sub-library AID. This way, all subsequent controls (pause, resume, volume, seek) are correctly routed to the Opus engine, while user code remains completely unaware of this forwarding logic. + +```python +# User code — exactly the same as before! +from ap_ds import AudioLibrary + +lib = AudioLibrary() +aid = lib.play_from_file("song.opus") # Auto-routed to Opus engine +lib.pause_audio(aid) # Auto-routed to Opus engine +lib.seek_audio(aid, 30.0) # Auto-routed to Opus engine +lib.stop_audio(aid) # Auto-routed to Opus engine +``` + +**Completely transparent, zero learning curve.** + +### 2. Cross-Platform Playback Backends — Three Platform Engines Rewritten for Opus + +Opus playback cannot depend on SDL2 (as detailed in the previous section), so we decided to **bypass SDL2 entirely and use native OS audio APIs** directly, paired with `libopusfile` decoding. + +| Platform | Playback Solution | Tech Stack | +|----------|-------------------|------------| +| **Windows** | `winmm waveOut` | `libopusfile` decode → `waveOutWrite` multi-buffer output (4 buffers, 50ms/block) | +| **Linux** | **ALSA** | `libasound.so.2` → `snd_pcm_open` → `snd_pcm_writei` (direct PCM output) | +| **macOS** | **Core Audio AudioQueue** | Apple official C API → callback fills `AudioQueueBuffer` (consistent with official examples) | + +**Platform Implementation Details:** + +**Windows (`_play_worker_windows`):** +- Uses `waveOutOpen` to open the default audio device +- Uses `CreateEventW` + `WaitForSingleObject` to synchronize buffer completion events +- 4 buffers rotating to eliminate stuttering +- `WAVEHDR` struct uses `c_void_p` for 64-bit compatibility + +**Linux (`_play_worker_linux`):** +- Uses `snd_pcm_open` to open `"default"` device +- Uses `snd_pcm_set_params` to set PCM parameters (S16_LE, interleaved mode) +- Uses `snd_pcm_writei` to write PCM data +- On `-EPIPE` (buffer underrun), calls `snd_pcm_recover` for automatic recovery + +**macOS (`_play_worker_macos`):** +- Uses `AudioQueueNewOutput` to create output queue +- Uses `AudioQueueAllocateBuffer` to allocate buffers +- Callback `HandleOutputBuffer` decodes Opus and fills `mAudioData` +- Uses `AudioQueueStart` to start playback, `AudioQueueStop` to stop + +This means that regardless of the user's operating system, Opus playback achieves native-level performance and stability. + +### 3. Opus-Specific Error Code System (2001-2010) + +To make Opus-related errors clearer and more traceable, we added 10 dedicated error codes: + +| Code | Constant | Meaning | Suggested Action | +|------|----------|---------|------------------| +| 2001 | `AP_DS_ERR_OPUS_LIB_LOAD_FAILED` | `libopusfile` load failed | Check if DLL exists or is locked | +| 2002 | `AP_DS_ERR_OPUS_DLL_DEPENDENCY` | DLL dependency missing | Ensure `libopus-0.dll` and `libogg-0.dll` exist | +| 2003 | `AP_DS_ERR_OPUS_OPEN_FAILED` | Opus file open failed | File may be corrupted or not a valid Opus stream | +| 2004 | `AP_DS_ERR_OPUS_HEADER_CORRUPT` | OpusHead header corrupted | Header info invalid or corrupted | +| 2005 | `AP_DS_ERR_OPUS_TAGS_PARSE_FAILED` | Tags parse failed | Tag data corrupted or invalid format | +| 2006 | `AP_DS_ERR_OPUS_DECODE_FAILED` | Opus decode failed | Audio data corrupted | +| 2007 | `AP_DS_ERR_OPUS_SEEK_FAILED` | Opus seek failed | Stream may not support seeking to that position | +| 2008 | `AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE` | Bitrate unavailable | Unable to determine bitrate for this Opus stream | +| 2009 | `AP_DS_ERR_OPUS_NOT_SEEKABLE` | Stream not seekable | This Opus stream does not support seeking | +| 2010 | `AP_DS_ERR_OPUS_CHANNEL_INVALID` | Invalid channel count | Opus stream has invalid channel count | + +All Opus errors include: +- **Machine-readable error codes** (for programmatic handling) +- **Human-readable error messages** (for developer understanding) +- **Actionable suggestions** (for user problem-solving) + +### 4. Automatic DLL Download and Hash Verification (Windows) + +Windows users don't need to manually find DLLs. When ap_ds first detects that Opus support is needed, it will: + +1. Check if the required four DLLs exist in the package directory +2. If missing or hash verification fails, download from `https://dvsyun.top/ap_ds/download/` +3. After download, perform SHA256 hash verification to ensure file integrity +4. If hash mismatches, automatically retry + +**DLL File List and Hashes:** + +| Filename | Size | SHA256 | +|----------|------|--------| +| `libopusfile-0.dll` | 55,884 bytes | `fc8ff75c5e0180e73b0528dc78c51ed0fb493741375cdc227f50c2a33cabf727` | +| `libopus-0.dll` | 500,112 bytes | `90aa25a0a6525d7da48a7ae8dd3306e45b0c28ce09a73d2a02b56cd95418d5be` | +| `libogg-0.dll` | 40,580 bytes | `3038ce8d161324a6349bf7c83b78493857ff6a3501e3adb3d541c6a07bd94a57` | +| `libopusurl-0.dll` | 76,772 bytes | `a6cde968a23f2d0067332a13718c52e265653a2c35d65862e8dff4cf2a0346d9` | + +### 5. Cross-Platform Opus Library Loading Explained — Why Can't macOS Auto-Download Like SDL2? + +#### Background: How Does SDL2's Auto-Download Work? + +In ap_ds, the SDL2 loader (`_sdl2.py`) supports automatic download on Windows and macOS: + +- **Windows**: Downloads `SDL2.dll` and `SDL2_mixer.dll` from CDN +- **macOS**: Downloads `SDL2.dmg` and `SDL2_mixer.dmg` from CDN, automatically mounts the DMG, extracts `SDL2.framework` and `SDL2_mixer.framework` to the package directory + +This mechanism works well because **SDL2 officially provides precompiled macOS Framework installers** that can be directly downloaded, mounted, and extracted. + +#### So Why Can't Opus Follow the Same Path? + +**Because Opus officially does not provide macOS precompiled Framework packages.** + +SDL2 has an official macOS download page with `.dmg` files containing complete `.framework` directory structures. But the Opus ecosystem (Xiph.Org Foundation) only provides source tarballs (`.tar.gz`), **no precompiled macOS binaries whatsoever**. + +We searched carefully: + +- Xiph.Org official website → source only +- Opus Codec official website → source only +- libopusfile official website → source only +- Major open-source mirror sites → source only +- Even third-party maintained precompiled Frameworks — no reliable, verifiable source found + +**This is not an oversight on ap_ds's part; it's an objective gap in the Opus ecosystem.** + +#### What About macOS Users Then? + +In 4.1.0, we designed a **four-layer fallback mechanism** for macOS users: + +``` +Layer 1: Detect system-installed Opus libraries + ├── ctypes.util.find_library("opusfile") + ├── /opt/homebrew/lib/libopusfile.dylib (Apple Silicon Homebrew) + ├── /usr/local/lib/libopusfile.dylib (Intel Homebrew) + └── /opt/local/lib/libopusfile.dylib (MacPorts) + ↓ If not found +Layer 2: Attempt MacPorts auto-installation + └── sudo port install opus opusfile libogg + ↓ If MacPorts doesn't exist or install fails +Layer 3: Attempt Homebrew auto-installation + └── brew install opus opusfile libogg + ↓ If Homebrew doesn't exist or install fails +Layer 4: Show manual installation guide (with complete commands and steps) +``` + +**In other words: ap_ds will do everything it can to automatically install Opus for the user, and only when all automatic options fail will it guide the user to manual operations.** + +#### What About Linux? — Consistent with SDL2 + +Linux Opus loading strategy is fully consistent with SDL2: + +``` +Layer 1: User config file (~/.config/ap_ds/opus_paths.conf) +Layer 2: System libraries (ctypes.util.find_library("opusfile")) +Layer 3: Auto-installation (apt-get / dnf / pacman, interactive sudo password) +Layer 4: Interactive setup (guide user to manually specify .so path) +``` + +**Linux users will feel no surprises**, as this flow is almost identical to `_sdl2.py`'s Linux loading logic. + +#### Why Do macOS Users See an "Apology" Rather Than a "Normal Prompt"? + +Because macOS users are accustomed to SDL2's smooth "auto-download → extract → ready-to-use" experience. When they see that Opus requires extra installation steps, their first reaction may be "has ap_ds regressed?" + +So in `_macos_apology()`, we candidly state: + +> "We sincerely apologize. On macOS, SDL2 libraries can be downloaded automatically, but we could NOT find any precompiled Opus framework packages for macOS. This is a limitation of the Opus ecosystem, not of ap_ds." + +**This is honesty, not excuse-making.** We don't want users to mistakenly think we "got lazy" and didn't implement auto-download — we're telling the truth: **Opus officially provides no macOS precompiled packages; we cannot download something that doesn't exist.** + +#### Technical Comparison Summary + +| Dimension | SDL2 (Windows/macOS) | Opus (Windows) | Opus (macOS) | Opus (Linux) | +|-----------|----------------------|----------------|--------------|--------------| +| **Official precompiled packages** | ✅ Yes (.dll / .dmg) | ✅ Yes (.dll) | ❌ **None** | ❌ None (but system repos have them) | +| **Auto-download solution** | ✅ Direct download | ✅ Direct download | ❌ **Cannot download** | ❌ N/A (uses system libs) | +| **Auto-installation solution** | ❌ N/A | ❌ N/A | ✅ MacPorts/Homebrew | ✅ apt/dnf/pacman | +| **User experience** | Out-of-box | Out-of-box | Extra steps (auto + guided) | Extra steps (auto + guided) | + +#### What Have We Done to Compensate? + +1. **Prioritize auto-installation**: On macOS, we try MacPorts first, then Homebrew, minimizing manual steps +2. **Clear error messages**: If auto-install fails, we provide complete, copy-paste-ready commands +3. **Humble and honest**: Directly state this is a limitation of the Opus ecosystem, not ap_ds's fault +4. **SDL2-consistent Linux experience**: Linux users see no difference + +#### Quick Guide for macOS Users + +If you're using ap_ds 4.1.0 to play Opus files on macOS, the easiest approach is: + +```bash +# Using Homebrew (recommended) +brew install opus opusfile libogg + +# Or using MacPorts +sudo port install opus opusfile libogg +``` + +After installation, ap_ds will automatically detect the system libraries without any extra configuration. + +**We understand this isn't as convenient as "download and use," but until Opus officially provides macOS precompiled packages, this is the best solution available.** + +### 6. OpusAudio Class — Complete API + +The `OpusAudio` class provides an API almost identical to `AudioLibrary`, but entirely based on `libopusfile` and native audio output: + +| Method | Function | +|--------|----------| +| `play_from_file(file_path, loops=0, start_pos=0.0)` | Play Opus file | +| `play_from_memory(file_path, loops=0, start_pos=0.0)` | Play from cache | +| `new_aid(file_path)` | Preload Opus file | +| `play_audio(aid)` | Resume playback | +| `pause_audio(aid)` | Pause playback | +| `stop_audio(aid)` | Stop playback, return elapsed time | +| `seek_audio(aid, position)` | Seek to position (seconds) | +| `set_volume(aid, volume)` | Set volume (0-128) | +| `get_volume(aid)` | Get volume | +| `fadein_music(aid, loops=-1, ms=0)` | Fade in playback | +| `fadein_music_pos(aid, loops=-1, ms=0, position=0.0)` | Fade in from position | +| `fadeout_music(ms=0)` | Fade out and stop | +| `is_music_playing()` | Whether currently playing | +| `is_music_paused()` | Whether currently paused | +| `get_music_fading()` | Get fade in/out status | +| `get_audio_metadata(file_path)` | Get Opus metadata | +| `get_audio_duration(file_path)` | Get Opus duration | +| `get_audio_extended_metadata(file_path)` | Get extended tags (title/artist/album etc.) | +| `batch_get_metadata(file_paths, max_workers=None)` | Batch parse Opus metadata | +| `batch_get_duration(file_paths, max_workers=None)` | Batch get Opus durations | +| `cleanup_function()` | Release all resources | + +### 7. `_opusdll.py` — Cross-Platform Library Loader + +Following the same design pattern as `_sdl2.py`, `_opusdll.py` provides: + +**Windows Loading Flow:** +1. Check DLL files in package directory +2. If missing → auto-download +3. If exists but hash verification fails → re-download +4. Load into `ctypes.CDLL` and set function prototypes + +**Linux Loading Flow:** +1. Check user config (`~/.config/ap_ds/opus_paths.conf`) +2. Check system libraries (`ctypes.util.find_library("opusfile")`) +3. Auto-installation (`apt-get`/`dnf`/`pacman`, interactive sudo password) +4. Interactive setup (guide user to manually specify `.so` path) + +**macOS Loading Flow:** +1. Detect system libraries (`find_library` + common Homebrew/MacPorts paths) +2. Attempt MacPorts installation (`sudo port install opus opusfile libogg`) +3. Attempt Homebrew installation (`brew install opus opusfile libogg`) +4. Manual installation guidance + +### 8. Library Size Changes — 4.1.0's "Temporary Weight Gain" and 4.2.0's "Slim-Down Return" + +Due to the addition of `opusplayer.py` (~45KB) and `_opusdll.py` (~25KB), plus Windows DLLs (total ~673KB), **this 4.1.0 RC release temporarily grows to 3.87 MB (4,059,251 bytes).** + +**But — please note — this size increase is "one-time," existing ONLY in this single 4.1.0 release.** + +Why? + +Because our strategy is very clear: + +| Version | Opus Support | Size | Description | +|---------|--------------|------|-------------| +| **4.0.x** | ❌ | ~2.5MB | Stable release, no Opus | +| **4.1.0 RC** | ✅ | ~3.87MB | **Only mainline with Opus**, for feedback collection | +| **4.2.0+ (mainline)** | ❌ | **~2.5MB** | **Remove Opus, return to lightweight** | +| **ap-ds-afs 1.0.0+** | ✅ | ~2.8-3.5MB | Opus's permanent home | + +**That is to say:** + +- 4.1.0 RC's size increase is **temporary, intentional, and one-release-only** +- 4.2.0 mainline will **completely remove Opus code and DLLs**, naturally returning to 2.5MB +- All users needing Opus should switch to the **AFS branch (ap-ds-afs)** + +**Our goal remains unchanged: keep ap_ds mainline extremely lightweight. 2.5MB is our brand promise, and we won't abandon it.** + +No need for 4.1.1 to "optimize size" — because 4.2.0 will simply cut Opus out directly, more thoroughly than any optimization. + + +--- + +## ⚠️ IMPORTANT: Windows Multiprocessing Warning + +### If you're using ap_ds 4.1.0 RC on Windows… + +**Please read this carefully. Your program's ability to run depends on it.** + +--- + +### What's the problem? + +On Windows, Python's `multiprocessing` module uses the `spawn` method to create new processes. This means each child process will **re-import your main module**. + +If you call `batch_get_metadata()` or any other batch parsing function that uses `ProcessPoolExecutor` directly at the top level of your script, child processes will **execute those calls again** when re-importing the main module, causing **infinite recursion** and ultimately a `BrokenProcessPool` error. + +**Your program will crash. Directly.** + +--- + +### Which APIs are affected? + +All batch parsing functions that use `ProcessPoolExecutor`: + +- `batch_get_metadata()` +- `batch_get_duration()` +- `batch_get_metadata_by_type()` + +**The new Opus batch parsing in 4.1.0 RC is affected as well.** + +--- + +### How to fix it? + +**It's simple — wrap your batch parsing code inside `if __name__ == "__main__":`.** + +#### ❌ Wrong (Crashes on Windows): + +```python +from ap_ds import batch_get_metadata + +# This will crash directly on Windows! +results = batch_get_metadata("/music/", max_workers=4) +print(f"Parsed {len(results)} files") +``` + +#### ✅ Correct: + +```python +from ap_ds import batch_get_metadata + +def main(): + results = batch_get_metadata("/music/", max_workers=4) + print(f"Parsed {len(results)} files") + +if __name__ == "__main__": + main() +``` + +#### ✅ Works with config functions too: + +```python +from ap_ds import batch_get_metadata + +def load_config(): + return {"audio_dir": "/music/"} + +def main(): + config = load_config() + results = batch_get_metadata(config["audio_dir"], max_workers=4) + print(f"Parsed {len(results)} files") + +if __name__ == "__main__": + main() +``` + +#### ✅ Jupyter Notebook Users: + +Put the batch call inside a function, then execute it in a cell: + +```python +def run_batch(): + from ap_ds import batch_get_metadata + return batch_get_metadata("/music/", max_workers=4) + +results = run_batch() +``` + +--- + +### Why don't Linux and macOS have this problem? + +Linux and macOS use `fork` by default to create child processes, which **copy** the parent process's memory space without re-executing the main module code. + +**However, we still recommend using `if __name__ == "__main__"` entry point protection on all platforms.** It's good programming practice and ensures cross-platform compatibility. + +--- + +### Frequently Asked Questions + +**Q: I only called `batch_get_metadata()` once in my script. Why does it recurse?** + +A: Because on Windows, each child process re-imports your script. Without `if __name__ == "__main__"` protection, the import itself executes the top-level code again, causing infinite recursion. + +**Q: What should I set `max_workers` to?** + +A: We recommend `os.cpu_count()` or `None` (auto). On Windows, `spawn` process overhead is relatively high, so it's not recommended to exceed the number of CPU cores. + +**Q: Does this issue exist in 4.0.x as well?** + +A: Yes. All `batch_*` functions in all versions have the same issue on Windows. This is an inherent limitation of Python's multiprocessing on Windows, not a bug in ap_ds. + +**Q: Can ap_ds fix this automatically for me?** + +A: No. This is behavior at the Python runtime level that ap_ds cannot intervene in. We can only remind you in documentation and try to provide clear error messages in the code. + +--- + + + +> **When using `batch_get_metadata()` or any batch parsing API on Windows, you MUST protect your entry point with `if __name__ == "__main__":`. Otherwise, your program will crash when child processes start.** + + +## 🌿 AFS Branch: ap_ds's "Dual-Track" Future + +### Why "Split the Family"? + +With Opus joining, the mainline version size grew from 2.5MB to 3.87MB. This sparked a deeper consideration: + +**In the future, we'll add more formats. Each new format increases size. If we cram all formats into mainline, ap_ds will eventually become a bloated monster,背离 the original "lightweight" vision.** + +So we decided: **split the family.** + +### Mainline 4.1.0's Special Status + +> **4.1.0 is the ONLY mainline version that includes Opus support.** + +This is an intentional design decision: + +- **4.1.0 RC**: Opus's "swan song" in mainline — collect feedback, validate solutions +- **4.2.0+ (mainline)**: Remove Opus, return to lightweight positioning +- **ap-ds-afs 1.0.0+**: Opus's permanent home, independent development + +**This gives users a clear choice:** + +| Your Need | Recommended Version | +|-----------|---------------------| +| Need Opus and willing to test | **4.1.0 RC (mainline)** | +| Need Opus and追求 long-term stability | **ap-ds-afs 1.0.0 (AFS branch)** | +| Don't need Opus,追求 extreme lightweight | **4.2.0+ (mainline)** | + +### AFS Branch (All-Format Support) + +**AFS (All-Format Support)** is a brand-new independent branch that will carry all new format support "beyond the 2.5MB limit." + +| Dimension | Mainline (ap-ds) | AFS Branch (ap-ds-afs) | +|-----------|------------------|----------------------| +| **PyPI package name** | `ap-ds` | `ap-ds-afs` | +| **Import name** | `ap_ds` | `ap_ds` (**identical!**) | +| **Version number** | 4.x (continuing) | 1.0.0 (fresh start) | +| **Opus support** | ❌ (except 4.1.0) | ✅ **Permanent** | +| **Core formats** | MP3 / WAV / FLAC / OGG / AAC | **Above + Opus + all future new formats** | +| **Size** | ~2.5MB | ~2.8-3.5MB | +| **Update strategy** | Security fixes only | **Syncs all mainline updates + own new formats** | +| **Target users** | Minimalist developers | Developers needing special formats | + +**Key Design: Import Name Identical** + +```python +# Regardless of whether user installed ap-ds or ap-ds-afs +# The import method is exactly the same! +from ap_ds import AudioLibrary +``` + +This means users can switch seamlessly between the two packages by simply changing the package name in `requirements.txt` or `pip install`. + +### Which Should Users Choose? + +| Your Need | Recommendation | +|-----------|----------------| +| Only need MP3/WAV/FLAC/OGG/AAC | **Mainline (ap-ds 4.2.0+)** — lightweight, stable | +| Need Opus and willing to test RC | **Mainline (ap-ds 4.1.0 RC)** — early adopter, feedback | +| Need Opus and追求 long-term stability | **AFS (ap-ds-afs 1.0.0)** — full-featured, long-term support | +| Unsure if you'll need new formats later | Install mainline first, switch to AFS when needed (import name identical, painless switching) | + +### Foolproof Design: Conflict Detection + +If a user installs both `ap-ds` and `ap-ds-afs` simultaneously, ap_ds's `__init__.py` will detect the conflict and hard-exit: + +```python +# When both packages are installed, import triggers hard exit +>>> import ap_ds +Checking for package conflicts... +WARNING: Package conflict detected! +The following packages exist simultaneously: + - ap-ds and ap-ds-afs +Please uninstall one of them. +# Python process exits directly 💀 +``` + +This is not a bug — it's "foolproof design" — ensuring users don't encounter hard-to-debug import issues from package conflicts. + + +## 📖 Technical Manual Update + +`show_tech_manual()` has been updated to 4.1.0, adding the following sections: + +### 2.1 OPUS Support (New Section) + +- Opus format introduction (IETF standard, WebRTC, low-bitrate high quality) +- Opus playback backend architecture (Windows waveOut / Linux ALSA / macOS Core Audio) +- Opus auto-DLL download (Windows) and system library detection (Linux/macOS) +- OpusAudio class complete API reference +- AID 1:1 mapping mechanism explanation +- Cross-platform Opus library loading deep dive (including macOS special notes) +- **4.1.0 version positioning notes (RC prerelease + Opus uniqueness statement)** + +### 10.4 Opus Error Codes (New Section) + +- Complete 10 Opus error codes list (2001-2010) +- Meaning and suggested action for each error code +- Error trigger scenario examples + +### Version History + +- Added 4.1.0 RC version entry +- Recorded Opus support, cross-platform playback backends, AFS branch establishment +- **Marked 4.1.0 as RC prerelease** +- **Clearly stated Opus uniqueness in mainline** + + +## 🚀 Getting Started + +### Installation + +```bash +# Mainline 4.1.0 RC (includes Opus, for testing feedback) +pip install ap-ds==4.1.0rc1 + +# Mainline 4.2.0+ (no Opus, extreme lightweight) +pip install ap-ds>=4.2.0 + +# AFS branch (includes Opus + all future new formats, permanent support) +pip install ap-ds-afs==1.0.0 +``` + +### 💡 Why Python 3.15t / 3.14t? + +Python's **Free-Threading versions** (filenames with `t`) **remove the GIL (Global Interpreter Lock)**, enabling true multi-core parallelism. Combined with ap_ds's batch parsing, **120 MP3 files can be parsed in just 0.33 seconds**. + +> ⚠️ **Version Selection Note**: Python 3.15t is currently beta (b4) with potential unknown issues. For a more stable environment, we recommend Python 3.14t (stable). Both support GIL-free free-threading mode. + +#### Windows Users + +**Python 3.14t (Stable) Downloads:** + +| Architecture | Download Link | +|--------------|---------------| +| Windows 64-bit | [python-3.14.4t-amd64.zip](https://mirrors.huaweicloud.com/python/3.14.4/python-3.14.4t-amd64.zip) | +| Windows 32-bit | [python-3.14.4t-win32.zip](https://mirrors.huaweicloud.com/python/3.14.4/python-3.14.4t-win32.zip) | +| ARM64 | [python-3.14.4t-arm64.zip](https://mirrors.huaweicloud.com/python/3.14.4/python-3.14.4t-arm64.zip) | + +**Python 3.15t (Beta) Downloads:** + +| Architecture | Download Link | +|--------------|---------------| +| Windows 64-bit | [python-3.15.0b4t-amd64.zip](https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip) | +| Windows 32-bit | [python-3.15.0b4t-win32.zip](https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-win32.zip) | +| ARM64 | [python-3.15.0b4t-arm64.zip](https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-arm64.zip) | + +> 📦 **ZIP — Extract and Use**: Download and extract to any directory, add `python.exe` path to system PATH, and you're ready. No EXE installer needed — deployment in seconds. + +#### Linux Users + +**Option 1: Use Package Manager** + +**Fedora:** + +```bash +sudo dnf install python3.14-freethreading +``` + +After installation, interpreter is at `/usr/bin/python3.14t`. + +**Ubuntu/Debian (using deadsnakes PPA):** + +```bash +sudo add-apt-repository ppa:deadsnakes +sudo apt-get update +sudo apt-get install python3.14-nogil +``` + +> This PPA provides the `-nogil` version, also a GIL-disabled build. + +**Option 2: Use Conda (Cross-Platform)** + +Install from `conda-forge` channel: + +```bash +conda create -n nogil -c conda-forge python-freethreading +mamba create -n nogil -c conda-forge python-freethreading +``` + +**Option 3: Build from Source (Universal)** + +```bash +# Download Python 3.14 source +wget https://www.python.org/ftp/python/3.14.0/Python-3.14.0.tgz +tar -xzf Python-3.14.0.tgz +cd Python-3.14.0 + +# Configure: --disable-gil is the key parameter +./configure --disable-gil + +# Compile and install +make -j$(nproc) +sudo make install +``` + +#### macOS Users + +**Option 1: Official Installer (GUI)** + +1. Download the macOS installer from [python.org](https://www.python.org/downloads/) +2. Run the installer, click the **"Customize"** button in the "Installation Type" screen +3. In the component list that appears, **check the "Free-threaded Python"** option, continue installation + +**Option 2: Using Homebrew** + +```bash +brew install python-freethreading +``` + +After installation, interpreter is at `$(brew --prefix)/bin/python3.14t`. + +#### Verify Installation + +Run the following commands to verify free-threading is working properly: + +```bash +# Check version info (should include "free-threading build") +python3.14t --version + +# Check GIL status (output False means GIL is disabled) +python3.14t -c "import sys; print(sys._is_gil_enabled())" +``` + +#### Create Virtual Environment + +```bash +python3.14t -m venv my_env +source my_env/bin/activate # Linux/macOS +my_env\Scripts\activate # Windows +``` + +> 💡 **Tip**: Using `python3.14t -m venv` creates a GIL-free isolated environment. + +### Quick Start + +```python +from ap_ds import AudioLibrary + +# Initialize library +lib = AudioLibrary() + +# Play audio file +aid = lib.play_from_file("music/song.mp3") + +# Control playback +lib.pause_audio(aid) # Pause +lib.play_audio(aid) # Resume +lib.seek_audio(aid, 30.5) # Seek to 30.5 seconds + +# Stop and get elapsed time +duration = lib.stop_audio(aid) +print(f"Played {duration:.2f} seconds") +``` + +### Playing Opus Files (via AudioLibrary, Recommended) + +```python +from ap_ds import AudioLibrary + +lib = AudioLibrary() +aid = lib.play_from_file("song.opus") # Auto-routed to Opus engine + +# All control methods are identical +lib.pause_audio(aid) +lib.seek_audio(aid, 30.0) +lib.set_volume(aid, 80) +lib.stop_audio(aid) +``` + +### Playing Opus Files (Directly Using OpusAudio) + +```python +from ap_ds import OpusAudio + +opus = OpusAudio() +aid = opus.play_from_file("song.opus") +opus.pause_audio(aid) +opus.seek_audio(aid, 30.0) +opus.stop_audio(aid) +``` + +### Getting Opus Metadata + +```python +from ap_ds import get_audio_metadata + +meta = get_audio_metadata("song.opus") +print(meta["duration"]) # 265 +print(meta["sample_rate"]) # 48000 +print(meta["channels"]) # 2 +print(meta["bitrate"]) # 105351 +``` + +### Batch Parsing + +```python +from ap_ds import batch_get_metadata + +# Batch parse entire folder (120 MP3s in just 0.33 seconds!) +results = batch_get_metadata("/music/playlist/", max_workers=8) + +for meta in results: + print(f"{meta['path']}: {meta['duration']}s, {meta['bitrate']}bps") +``` + +**Batch Parsing API Overview:** + +| API | Description | +|-----|-------------| +| `batch_get_metadata()` | Batch parse, returns full metadata list | +| `batch_get_duration()` | Batch get durations, returns `{path: duration}` | +| `batch_get_metadata_by_type()` | Batch parse filtered by format | + +### ⚠️ CRITICAL: Windows Batch Parsing and BrokenProcessPool + +If you're using **batch parsing APIs** on **Windows**, you **MUST** protect your entry point with `if __name__ == "__main__"`. + +**Why?** + +On Windows, `ProcessPoolExecutor` uses `spawn` to create new processes. This means each subprocess will re-import your main module. Without entry point protection, this creates an **infinite recursion loop**, crashing your program with a `BrokenProcessPool` error. + +**This is not optional. This is mandatory.** + +#### ❌ Wrong (Will Crash on Windows): + +```python +from ap_ds import batch_get_metadata + +# This will crash with BrokenProcessPool on Windows! +results = batch_get_metadata("/music/", max_workers=4) +``` + +#### ✅ Correct (Always Works): + +```python +from ap_ds import batch_get_metadata + +def main(): + results = batch_get_metadata("/music/", max_workers=4) + print(f"Parsed {len(results)} files") + +if __name__ == "__main__": + main() +``` + +#### ✅ Correct (Script with Configuration): + +```python +from ap_ds import batch_get_metadata + +def get_config(): + # ... config logic ... + return config + +def main(): + config = get_config() + results = batch_get_metadata(config["audio_dir"], max_workers=4) + print(f"Parsed {len(results)} files") + +if __name__ == "__main__": + main() +``` + +**This applies to:** + +- ✅ Any script that imports ap_ds and uses batch parsing on Windows +- ✅ Jupyter notebooks (if running on Windows, wrap batch calls in a function with `if __name__ == "__main__"`) +- ✅ Any test scripts (e.g., `CI-CD-TEST.py`) + +**What about Linux/macOS?** + +Not required. Linux and macOS use `fork` by default and don't have this issue. However, for cross-platform compatibility, using entry point protection is still **good practice**. + +### DAP Playlist System + +Audio files are automatically recorded to DAP (Dvs Audio Playlist) when played: + +```python +# Files are recorded automatically +aid1 = lib.play_from_file("song1.mp3") +aid2 = lib.play_from_file("song2.ogg") + +# Get all recordings +recordings = lib.get_dap_recordings() +print(f"Recorded {len(recordings)} files") + +# Save as JSON +success = lib.save_dap_to_json("my_playlist.ap-ds-dap") +``` + +DAP stores only metadata (path, duration, bitrate, channels), not audio data. Each record takes approximately 150 bytes of memory. + +### Platform Support + +#### Windows + +- Automatically downloads SDL2.dll and SDL2_mixer.dll with hash verification +- Opus support auto-downloads four DLLs (libopusfile-0.dll, libopus-0.dll, libogg-0.dll, libopusurl-0.dll) +- No manual configuration needed +- Supports Windows 7 and above + +#### macOS + +- Automatically downloads SDL2.framework and SDL2_mixer.framework with hash verification +- Opus support via Homebrew or MacPorts system library installation (auto-detection + auto-install attempt) +- Supports macOS 10.9 and above + +#### Linux + +**Intelligent Multi-Layer Import System:** + +1. **System library check**: Uses system-installed SDL2 libraries +2. **User configuration**: Checks paths saved from previous runs +3. **Auto-installation**: Detects package manager and installs required packages +4. **Interactive guidance**: Provides manual options if all above fail + +**Opus Package Manager Support:** + +```bash +# Ubuntu/Debian +sudo apt-get install libopusfile-dev libopus-dev libogg-dev + +# Fedora +sudo dnf install opusfile-devel opus-devel libogg-devel + +# Arch +sudo pacman -S opusfile opus libogg +``` + +**SDL2 Package Manager Support:** + +```bash +# Ubuntu/Debian +sudo apt-get install libsdl2-dev libsdl2-mixer-dev + +# Fedora +sudo dnf install SDL2-devel SDL2_mixer-devel + +# Arch +sudo pacman -S sdl2 sdl2_mixer +``` + +#### Embedded ARM64 + +Tested on the following platforms: + +- **Orange Pi 4 Pro** (Allwinner A733, 2xA76 + 6xA55 @ 2.0GHz) +- **Raspberry Pi 5** (BCM2712, 4xA76 @ 2.4GHz) + +Both running Ubuntu 22.04 with full audio functionality via 3.5mm output. Memory growth ~4MB after extensive testing. + + +## 🧪 CI/CD Testing — Comprehensive Coverage, All Passed + +### Opus Test Suite (OPUS_TEST.py) + +4.1.0 adds a complete Opus test suite covering 16 test categories: + +| Test Category | Test Items | Description | +|---------------|------------|-------------| +| OP-1 | Module import/constants/error codes | Verify all Opus modules, constants, error codes correctly exported | +| OP-2 | DLL loading and auto-download | Verify `_opusdll.py` loading, DLL file existence, hash verification | +| OP-3 | Metadata parsing | Verify Opus duration, sample rate, channels, bitrate, extended tags | +| OP-4 | Playback functionality | Verify `play_from_file`, `play_from_memory`, `new_aid` | +| OP-5 | Playback control | Verify `pause_audio`, `play_audio`, `stop_audio` | +| OP-6 | Volume control | Verify `set_volume` (0-128 boundary values) and `get_volume` | +| OP-7 | Seeking functionality | Verify `seek_audio` for various positions and boundary values | +| OP-8 | Fade in/out | Verify `fadein_music`, `fadein_music_pos`, `fadeout_music` | +| OP-9 | Opus vs native format distinction | Verify `_is_opus_file` and AudioLibrary auto-routing | +| OP-10 | Batch parsing | Verify `batch_get_metadata`, `batch_get_duration`, `batch_by_type` | +| OP-11 | Error code triggering | Verify all 10 Opus error codes trigger correctly | +| OP-12 | AID 1:1 mapping | Verify main AID ↔ Opus sub-AID full lifecycle | +| OP-13 | Resource management | Verify `cleanup_function` correctly releases resources | +| OP-14 | Boundary values and error testing | Verify invalid parameter types, out-of-range values handled correctly | +| OP-15 | DLL-specific testing | Verify DLL file sizes, hashes, load idempotency | +| OP-16 | Error code trigger specific testing | Verify each Opus error code triggers in real scenarios | + +### Test Results + +``` +================================================================== + Opus Test Summary +================================================================== + Passed : 229 + Failed : 0 + Skipped: 0 +================================================================== +``` + +**All 229 tests passed, 0 failed, 0 skipped.** + +### CI/CD Integration + +`OPUS_TEST.py` is integrated into the CI/CD pipeline, automatically running before each release. Test coverage includes: + +- **Automated tests** (no human intervention): OP-1 ~ OP-16 +- **Listening tests** (requires human confirmation): OP-L (5 listening tests) + +Listening tests include: +1. Normal playback is audible +2. Fade-in effect is smooth +3. Fade-out effect is smooth +4. Volume 0 is silent +5. Seek position is correct + +### Running Tests + +```bash +# Full test (including listening tests) +python OPUS_TEST.py + +# Automated tests only +python OPUS_TEST.py --auto + +# Listening tests only +python OPUS_TEST.py --listen +``` + + +## 🧪 CI/CD Comprehensive Testing — All Passed (Green) + +Below is the complete output summary of the **full CICD test suite** (`CI,CD_TEST.py`) run on a Windows machine with Python 3.13.4. It covers **all public APIs, error handling, edge cases, boundary values, and interactive listening tests** — **650+ tests passed, 0 failed, 0 skipped**. The same tests have been verified on **macOS** and **Ubuntu** with identical results. + +``` +================================================================== + Opus Test Summary +================================================================== + Passed : 229 + Failed : 0 + Skipped: 0 +================================================================== + +================================================================== + CICD Test Summary +================================================================== + Passed : 650+ + Failed : 0 + Skipped: 0 +================================================================== +``` + +**You can run the test suite yourself** by executing `CI,CD_TEST.py` (included in the package): + +```bash +python CI,CD_TEST.py --auto # Automated tests only +python CI,CD_TEST.py --listen # Interactive listening tests only +python CI,CD_TEST.py --full # Everything (default) +``` + + +## 🧪 API Import Verification Test + +We provide a comprehensive test script `IMPORT_TEST.py` to verify that all documented APIs exist and are correctly exported. + +### Test Results + +``` +============================================================ +🧪 Testing All ap_ds API Exports +============================================================ + +📦 Testing top-level imports: +---------------------------------------- + ✅ AudioLibrary + ✅ OpusAudio + ✅ batch_get_metadata + ✅ batch_get_duration + ✅ batch_get_metadata_by_type + ✅ get_audio_duration + ✅ get_audio_metadata + ✅ auto_check_runtime + ✅ check_runtime_mode + ✅ show_tech_manual + +---------------------------------------- +🎯 Testing AudioLibrary methods: +---------------------------------------- + ✅ AudioLibrary.__init__ + ✅ AudioLibrary.play_from_file + ✅ AudioLibrary.play_from_memory + ✅ AudioLibrary.new_aid + ✅ AudioLibrary.play_audio + ✅ AudioLibrary.pause_audio + ✅ AudioLibrary.stop_audio + ✅ AudioLibrary.seek_audio + ✅ AudioLibrary.set_volume + ✅ AudioLibrary.get_volume + ✅ AudioLibrary.fadein_music + ✅ AudioLibrary.fadein_music_pos + ✅ AudioLibrary.fadeout_music + ✅ AudioLibrary.is_music_playing + ✅ AudioLibrary.is_music_paused + ✅ AudioLibrary.get_music_fading + ✅ AudioLibrary.get_audio_duration + ✅ AudioLibrary.get_audio_metadata + ✅ AudioLibrary.get_audio_metadata_by_path + ✅ AudioLibrary.get_audio_metadata_by_aid + ✅ AudioLibrary.batch_get_metadata + ✅ AudioLibrary.batch_get_duration + ✅ AudioLibrary.batch_get_metadata_by_type + ✅ AudioLibrary.save_dap_to_json + ✅ AudioLibrary.get_dap_recordings + ✅ AudioLibrary.clear_dap_recordings + ✅ AudioLibrary.clear_memory_cache + ✅ AudioLibrary.cleanup_function + ✅ AudioLibrary._find_channel_by_aid + ✅ AudioLibrary._get_file_path_by_aid + ✅ AudioLibrary._is_music_file + ✅ AudioLibrary._seek_audio + ✅ AudioLibrary._get_duration_by_filepath + ✅ AudioLibrary._get_file_duration + +---------------------------------------- +🔍 Checking extra APIs: +---------------------------------------- + ✅ is_full_performance + ✅ get_runtime_info + +============================================================ +📊 FINAL SUMMARY +============================================================ +🎉 ALL APIs EXIST! Documentation is accurate. +============================================================ +✅ Passed: 47 +❌ Failed: 0 +``` + +**All 47 API tests passed!** + +| Category | Count | Status | +|----------|-------|--------| +| Top-level imports | 10 | ✅ All passed | +| AudioLibrary methods | 34 | ✅ All passed | +| Extra APIs | 3 | ✅ All passed | +| **Total** | **47** | **✅ 47/47 passed** | + + +## 📊 Version Comparison Overview + +| Dimension | 4.0.1 | 4.1.0 RC | 4.2.0+ (planned) | ap-ds-afs 1.0.0 | +|-----------|-------|----------|------------------|-----------------| +| **Opus support** | ❌ | ✅ | ❌ | ✅ | +| **Playback formats** | MP3/WAV/FLAC/OGG/AAC | +Opus | MP3/WAV/FLAC/OGG/AAC | +Opus + future formats | +| **Cross-platform playback engine** | SDL2 only | SDL2 + Opus native | SDL2 only | SDL2 + Opus native | +| **Opus error codes** | ❌ | ✅ 2001-2010 | ❌ | ✅ 2001-2010 | +| **Auto-DLL download** | SDL2 | SDL2 + Opus DLL | SDL2 | SDL2 + Opus DLL | +| **AFS branch** | ❌ | ✅ | ❌ | ✅ (AFS itself) | +| **Test coverage** | 421 tests | 650+ tests | 421 tests | 650+ tests | +| **Library size** | ~2.5MB | **3.87MB** | **~2.5MB** | ~2.8-3.5MB | +| **Version status** | Stable | **RC prerelease** | Planned | Planned | + + +## 📦 Version Relationships + +| Version | Type | Support Period | Use Case | +|---------|------|----------------|----------| +| **v3.0.0 LTS** | Long-Term Support | Until March 2031 | Production environments | +| **v3.1.x** | LFV (Latest Feature Version) | ~6 months | Early adopters (superseded) | +| **v4.0.x** | LFV (Latest Feature Version) | ~6 months | Early adopters, new features | +| **v4.1.0 RC** | **RC prerelease** | ~2 months | **Opus testing and feedback collection** | +| **v4.2.0+ (mainline)** | LFV (Latest Feature Version) | ~6 months | **Remove Opus, return to lightweight** | +| **ap-ds-afs 1.0.0+** | Long-Term Support | TBD | **Permanent home for Opus and new formats** | + + +## 📌 Version Upgrade Recommendations + +| User Type | Recommendation | +|-----------|----------------| +| Production environments | Continue using **v3.0.0 LTS**, wait for v4.0.0 LTS | +| Development/testing | Upgrade to **v4.1.0 RC** to try Opus, help test feedback | +| Need Opus and willing to test | **Use 4.1.0 RC**, report bugs | +| Need Opus and追求 long-term stability | Wait for **ap-ds-afs 1.0.0** | +| Don't need Opus,追求 extreme lightweight | Wait for **4.2.0+** or continue with 4.0.x | +| Previously affected by v3.1.x metadata bug | **Must upgrade** to v4.0.0+ | + +```bash +# 4.1.0 RC installation +pip install ap-ds==4.1.0rc1 + +# AFS branch (after release) +pip install ap-ds-afs==1.0.0 +``` + + +## 🌐 apds.top Is Now Live! + +**The ap_ds official project homepage is now live with TLS encryption!** + +🎉 Visit: **https://apds.top** + +### Website Features + +- 📄 **Full documentation**: API reference, user guide, FAQ +- 📦 **Version distribution**: All version download links and changelogs +- 🔗 **Repository navigation**: GitCode (primary), Gitee (China mirror) +- ✉️ **Feedback system**: Users can submit feedback directly via the website +- 🔒 **Full-site TLS encryption**: All pages served via HTTPS + + +## ⚠️ Repository Migration Notice + +### GitHub Deprecated, GitLab Abandoned, Migrated to GitCode — apds.top Is the Permanent Home + +#### 1. Why GitHub Was Deprecated + +The developer's GitHub account was locked due to loss of two-factor authentication (2FA) device. After multiple attempts to contact GitHub support, only automated bot responses were received. Due to the complete lack of human assistance, the developer decided to permanently abandon that GitHub account and will not create a new one in the foreseeable future. The old `dvs-web/ap_ds` repository is now officially deprecated and no longer receives any updates. + +#### 2. Why GitLab (JiHu) Was Abandoned + +After the GitHub issues, the project migrated the primary repository to Gitee and GitLab (JiHu). However, due to platform policy changes where basic account login became a paid feature, GitLab was recently abandoned. Since the project relies on free open-source collaboration, this change created an unacceptable barrier for contributors and users. The developer attempted to find alternatives but found none within the free tier. Therefore, the GitLab repository is no longer actively maintained. + +#### 3. Why Gitee Is Now a Backup (Not Primary) + +Gitee is an excellent platform, particularly for Chinese developers, offering fast and stable access. It remains a strongly recommended choice. However, its role has been adjusted to a backup or China-facing mirror for two main reasons: + +- **International accessibility**: Gitee's servers are primarily located in China. For developers outside mainland China, access can be slow, unstable, and in some cases, completely blocked due to international network policies. This creates a poor experience for a large portion of the user base. + +- **UI and workflow**: While feature-rich, Gitee's UI and workflows are often considered outdated and not as aligned with modern Git workflows that many international developers are accustomed to. + +For these reasons, while Gitee is by no means "bad" and will continue to be fully supported as a China-facing mirror, it is no longer suitable as the sole primary repository for a globally-oriented project. + +#### 4. Solution: GitCode Becomes the New Primary Repository + +After surveying the landscape of free Git hosting platforms, **GitCode** emerged as the ideal solution. GitCode offers a modern interface, strong feature set, and, most importantly, excellent accessibility for developers both within China and abroad. It has become the new official primary repository for the ap_ds project. + +**New primary repository located at:** [https://gitcode.com/dvsxt/ap_ds](https://gitcode.com/dvsxt/ap_ds) + +#### 5. Permanent Home: apds.top + +**apds.top is now officially live and fully operational!** + +This is not just another repository mirror — it is the **permanent official home** of ap_ds. It solves all platform dependency issues: + +- ✅ **Full independent control**: No longer affected by third-party platform policy changes +- ✅ **TLS encryption**: Full-site HTTPS secure access +- ✅ **Permanent stability**: Even if all third-party platforms fail, apds.top remains available +- ✅ **One-stop service**: Documentation, downloads, feedback, and repository navigation all integrated + +**Official Project Homepage:** [https://apds.top](https://apds.top) + + +## 🔄 NEW: GitHub Repository (Compatibility Mirror) + +### A Word from the Developer + +Recently, US-based developer **Clint Shepherd** raised a legitimate concern: + +> *"Why isn't there a GitHub repository? GitCode is in China, I'm not used to it. apds.top doesn't have issues, pull requests, forks, or other collaboration features..."* + +After careful consideration, we realized he was absolutely right. + +While we have no intention of making GitHub the **primary** platform — their customer support was terrible when our account was locked, and we're still bitter about it — the reality is undeniable: + +- **GitHub has a massive user base** +- **The ecosystem is very mature** +- **Many developers are simply more comfortable with it** +- **Collaboration features (issues, PRs, forks) are standard expectations** + +So we made a pragmatic decision: **We created a new GitHub account** (`dvs-dvsxt`) and set up a compatibility mirror at: + +🔗 **[https://github.com/dvs-dvsxt/ap_ds](https://github.com/dvs-dvsxt/ap_ds)** + +### Important: This Is a Compatibility Mirror, Not the Primary Repository + +| Aspect | Details | +|--------|---------| +| **Purpose** | Compatibility mirror for GitHub developers | +| **Permanent Home** | **[apds.top](https://apds.top)** — permanent official source | +| **Primary Mirror** | **[GitCode](https://gitcode.com/dvsxt/ap_ds)** — global access | +| **China Mirror** | **[Gitee](https://gitee.com/dssxt/ap_ds)** — for Chinese users | +| **GitHub Status** | ⚠️ **Compatibility mirror** — updated periodically, may lag | +| **Best For** | ✅ GitHub users who prefer familiar workflows to star, watch, or clone | + +### What This Means for You + +- **If you're a GitHub user**: You can now clone, fork, and file issues on GitHub. We will respond to GitHub issues, but **please be patient** — response times may be slower than on GitCode or apds.top. + +- **If you're a developer in China**: **Gitee** remains your fastest and most stable choice. Use it with confidence. + +- **If you want the latest updates**: Always check **[apds.top](https://apds.top)** first. It has the latest releases, changelogs, and announcements. + +- **If you want to contribute**: We welcome contributions on **any** platform — GitCode, Gitee, or GitHub. PRs are reviewed regardless of origin. + +### Final Repository Strategy (Updated) + +| Platform | Status | Use | +|----------|--------|-----| +| **apds.top** | ✅ **Permanent home** | Official source, documentation, downloads | +| **GitCode** | ✅ Primary mirror | Code hosting for global users | +| **GitHub** | ✅ Compatibility mirror | For GitHub developers **(NEW!)** | +| **Gitee** | ✅ China mirror | Fast access for Chinese users | +| **GitLab (JiHu)** | ❌ Abandoned | No longer maintained | + +> **Special thanks to Clint Shepherd** for asking the hard questions and pushing us to make ap_ds more accessible to the global Python community. Your feedback made this better. 🙏 + + +## Overview + +ap_ds is a lightweight (2.5MB) Python audio library for playing and high-precision metadata parsing of MP3, FLAC, OGG, and WAV files. It has no external Python dependencies, uses only the Python standard library, and provides non-blocking playback suitable for GUI applications. + +**Core Features:** + +- **Extremely lightweight:** 2.5MB on Windows / 3.36MB full solution on macOS +- **Zero Python dependencies:** Standard library only +- **High-precision metadata:** WAV/FLAC 100%, OGG 99.99%, MP3 >98% +- **Batch parsing:** Process hundreds of files in parallel using `batch_get_metadata()` +- **Non-blocking playback:** Ideal for GUI applications +- **Cross-platform:** Windows, macOS, Linux, embedded ARM64 +- **DAP recording system:** Metadata-only automatic playback history +- **Python 3.15t support:** GIL-free true parallelism, full multi-core performance +- **LTS support:** First long-term support version with 5-year maintenance commitment + + +## Contact and Support + +### 📧 Licensing Inquiries + +me@dvsyun.top or dvs6666@163.com · Response within 7 business days + +### 🛠️ Technical Support + +apds.top Issues · GitCode Issues · GitHub Issues · Gitee Issues · Email (completely free) + + +## Official Repositories and Project Sources + +**✅ Official Primary Repository (Preferred):** [https://apds.top](https://apds.top) — ap_ds's permanent official home, fully independent control, TLS encrypted,不受 third-party platform policy changes. + +**✅ Primary Mirror (Global Access):** [GitCode](https://gitcode.com/dvsxt/ap_ds) — Globally accessible, modern UI, actively maintained as a public mirror. + +**✅ Compatibility Mirror (GitHub Users):** [GitHub](https://github.com/dvs-dvsxt/ap_ds) — For GitHub developers. May lag behind primary sources. Issues and PRs welcome, but response times may be slower. + +**✅ China Mirror (Fast and Stable):** [Gitee](https://gitee.com/dssxt/ap_ds) — Full mirror for Chinese developers, fast access, classic stable UI. + +**❌ Deprecated and Abandoned:** + +- **[GitHub (dvs-web/ap_ds)](https://github.com/dvs-web/ap_ds)** — Deprecated due to permanent account lockout, no longer maintained. +- **[GitLab (JiHu)](https://jihulab.com/dvs/ap_ds)** — Abandoned due to platform policy changes (basic account login became a paid feature), no longer maintained. + +> **ℹ️ ap_ds v3.0.0 LTS – First Long-Term Support Version** +> This release consolidates all previous improvements, adds deterministic resource cleanup, hash-verified downloads, and a 5-year support commitment. The old GitHub repository (`dvs-web/ap_ds`) is deprecated and no longer updated. The GitLab repository has been abandoned. For future updates and contributions, please use the official primary repository **[apds.top](https://apds.top)** or the public mirrors **GitCode**, **GitHub (dvs-dvsxt/ap_ds)**, and **Gitee**. +> +> **Developer Personal Homepage and Blog:** [https://dvsx.top](https://dvsx.top) — Blog is currently under maintenance and upgrades. Stay tuned. +> **ap_ds Project Homepage:** [https://apds.top](https://apds.top) (Official documentation, releases, and licensing center). + +**🔗 Canonical URL:** [https://apds.top/](https://apds.top/) +**📖 Blog and Author:** [dvsx.top](https://dvsx.top) — Blog is currently under maintenance and upgrades. Stay tuned. + + +## About the Author + +**Developer:** Dvs (DvsXT) +**Personal Homepage and Blog:** [https://dvsx.top](https://dvsx.top) — Blog is currently under maintenance and upgrades. Stay tuned. +**Author Bio:** [https://dvsyun.top/me/dvs](https://dvsyun.top/me/dvs) +**Email:** me@dvsyun.top · dvs6666@163.com + + +## ap_ds Official Portal + +**🎵 ap_ds Official Website (Primary):** [https://apds.top](https://apds.top) — Permanent official home, full documentation, version releases, licensing center +**📦 PyPI Project Page:** [https://pypi.org/project/ap_ds/](https://pypi.org/project/ap_ds/) — Installable via pip +**🌐 Mirror Documentation Site:** [https://www.dvsyun.top/ap_ds](https://www.dvsyun.top/ap_ds) — Backup documentation access + +> 👉 The ap_ds project homepage ([apds.top](https://apds.top)) is the **preferred recommendation** for official sources, hosting full documentation, license details, version changelogs, and official releases. The author's personal blog ([dvsx.top](https://dvsx.top)) is currently under maintenance and upgrades. Stay tuned. + + +# ap_ds Audio Library — Complete API Reference + +**Version: v4.1.0 RC** +**Document Date: August 2026** +**Project Homepage: https://apds.top** + +## Table of Contents + +1. AudioLibrary Class — Complete API + - Initialization + - Playback Methods + - Control Methods + - Volume Methods + - Fade and Transition Methods + - Metadata Methods + - Batch Parsing Methods + - DAP System Methods + - Resource Management + - Internal Helper Methods + +2. Top-Level Convenience Functions + +3. AudioParser Module — Metadata API + +4. SDL2 Integration Layer + +5. Constants Reference + +6. Unified Error Handling and Error Code Reference + +7. Environment Variables Reference + +8. Technical Manual (show_tech_manual()) + +## AudioLibrary Class — Complete API + +The `AudioLibrary` class is the main interface for audio playback, control, and metadata management. Every method that can fail returns a **unified error tuple**: + +``` +(code: int, message: str, suggestion: str) +``` + +On success, methods return their documented success value. On failure, they **never raise exceptions** (`__init__` excepted, as it is a constructor) — they return the error tuple above. See the *Unified Error Handling and Error Code Reference* section for details. + +### Initialization + +#### `__init__(frequency: int = 44100, format: int = MIX_DEFAULT_FORMAT, channels: int = 2, chunksize: int = 2048) -> None` + +**Description:** + +Initializes the SDL2 audio subsystem and SDL2_mixer library. Must be called before any playback operations. Sets up the audio device with the specified parameters and registers an exit handler for automatic resource cleanup. + +> **Note:** `__init__` is a **constructor** — it cannot return error tuples. If SDL2 or mixer initialization fails, it raises `RuntimeError` (the only intentional exception in the library). + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `frequency` | `int` | `44100` | Audio sample rate (Hz). Common values: 44100 (CD quality), 48000 (DVD/video), 22050 (voice). | +| `format` | `int` | `MIX_DEFAULT_FORMAT` | Audio sample format. Typically `AUDIO_S16SYS` (16-bit signed, system endianness). | +| `channels` | `int` | `2` | Number of audio channels. `1` = mono, `2` = stereo. | +| `chunksize` | `int` | `2048` | Buffer size (in samples). Larger values reduce CPU but increase latency. | + +**Raises (constructor only):** + +- `RuntimeError`: If SDL2 initialization fails (e.g., no audio device available). +- `RuntimeError`: If mixer initialization fails (e.g., unsupported format). + +**Examples:** + +```python +from ap_ds import AudioLibrary + +lib = AudioLibrary() # Default (CD quality, stereo) +lib_voice = AudioLibrary(frequency=22050, channels=1, chunksize=1024) +``` + +--- + +### Playback Methods + +#### `play_from_file(file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]` + +**Description:** + +Loads and plays an audio file directly from disk. `.ap-ds-dap` files are DAP **export records** (output format) and are **not** supported as playback input — passing one returns `(1003, msg, suggestion)`. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `file_path` | `str` / `bytes` / `os.PathLike` | Required | Full path to the audio file. Supported formats: MP3, WAV, FLAC, OGG, **Opus (4.1.0 RC)**. | +| `loops` | `int` | `0` | Number of loops after the first playback. `0` = once, `-1` = infinite, `>0` = count. | +| `start_pos` | `float` | `0.0` | Start position in seconds. Not supported for sound effects. | + +**Returns:** + +- **Success:** `int` — Unique Audio ID (AID) identifying this playback instance. +- **Failure:** `Tuple[int, str, str]` — `(error_code, error_message, suggestion)`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1001` | `file_path` does not exist, or `file_path` type is invalid (`None`, `int`, `list`, `dict`, `tuple`, etc.). | +| `1999` | `loops` type is invalid (must be `int`). | +| `1003` | Audio loading failed (e.g., `.ap-ds-dap` file, corrupted file, or unsupported format). | +| `1004` | Playback failed (e.g., no available channel or audio device issue). | +| `2003` | Opus file open failed (Opus-specific). | + +**Behavior by File Type:** + +| File Type | Mode | Seek Support | Fade Support | +|-----------|------|--------------|--------------| +| MP3, OGG, FLAC | Music (Mix_PlayMusic) | Yes | Yes | +| **Opus (4.1.0 RC)** | **OpusAudio (libopusfile + native API)** | **Yes** | **Yes** | +| WAV (duration ≥ threshold) | Music (Mix_PlayMusic) | Yes | Yes | +| WAV (duration < threshold) | Sound effect (Mix_PlayChannel) | No | No | +| Other formats | Sound effect (Mix_PlayChannel) | No | No | + +**Examples:** + +```python +aid = lib.play_from_file("song.mp3") # Play once +aid = lib.play_from_file("beep.wav", loops=5) # Loop 5 times +aid = lib.play_from_file("podcast.mp3", start_pos=30.0) +aid = lib.play_from_file("ambient.ogg", loops=-1) # Infinite loop +aid = lib.play_from_file("song.opus") # Opus auto-routed (4.1.0 RC) + +# Invalid input returns error tuple (no exception): +result = lib.play_from_file(None) # (1001, 'Invalid file path type: NoneType', ...) +result = lib.play_from_file("x.mp3", loops="2") # (1999, 'Invalid loops type: str...', ...) +``` + +--- + +#### `play_from_memory(file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]` + +**Description:** + +Plays an audio file that has been preloaded into memory via `new_aid()`. Faster than `play_from_file()` because the file is already cached. + +**Parameters:** Same as `play_from_file()`. + +**Returns:** + +- **Success:** `int` (AID). +- **Failure:** `Tuple[int, str, str]` — `(error_code, error_message, suggestion)`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1013` | `file_path` not loaded into memory, or `file_path` type invalid (`None`, `list`, `dict`, etc.). | +| `1999` | `loops` type invalid (must be `int`). | +| `1004` | Playback from cache failed. | + +**Example:** + +```python +lib.new_aid("gunshot.wav") +lib.new_aid("explosion.wav") +aid = lib.play_from_memory("gunshot.wav") # Instant, no disk I/O +``` + +--- + +#### `new_aid(file_path: str) -> Union[int, Tuple[int, str, str]]` + +**Description:** + +Preloads an audio file into memory without playing it. Useful for caching sounds or tracks that will be played multiple times. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `file_path` | `str` / `bytes` / `os.PathLike` | Path to the audio file to cache. | + +**Returns:** + +- **Success:** `int` (AID). +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1001` | `file_path` does not exist or type is invalid. | +| `1003` | Audio loading failed. | + +**Example:** + +```python +sounds = { + 'hit': lib.new_aid("hit.wav"), + 'jump': lib.new_aid("jump.wav"), + 'coin': lib.new_aid("coin.wav"), +} +lib.play_from_memory(sounds['hit']) +``` + +--- + +### Control Methods + +#### `play_audio(aid: int) -> Tuple[int, str, str]` + +**Description:** + +Resumes a paused audio instance. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | + +**Example:** + +```python +lib.pause_audio(aid) +lib.play_audio(aid) # Resume +result = lib.play_audio(99999) # (1002, 'Invalid AID: 99999', ...) +``` + +--- + +#### `pause_audio(aid: int) -> Tuple[int, str, str]` + +**Description:** + +Pauses an audio instance. Can be resumed with `play_audio()`. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | + +--- + +#### `stop_audio(aid: int) -> Union[float, Tuple[int, str, str]]` + +**Description:** + +Stops playback and returns the elapsed playback time in seconds. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | + +**Returns:** + +- **Success:** `float` — Elapsed playback time in seconds. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | + +**Example:** + +```python +played = lib.stop_audio(aid) # e.g., 3.42 +result = lib.stop_audio(99999) # (1002, 'Invalid AID: 99999', ...) +``` + +--- + +#### `seek_audio(aid: int, position: float) -> Tuple[int, str, str]` + +**Description:** + +Seeks to the specified position in seconds. Only supported for music-mode files (MP3, OGG, FLAC, long WAV, **Opus**). + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | +| `position` | `int` / `float` | Position in seconds. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | +| `1999` | `position` type is invalid. | +| `2007` | Opus seek failed (Opus-specific). | +| `2009` | Opus stream not seekable (Opus-specific). | + +**Example:** + +```python +lib.seek_audio(aid, 30.5) +result = lib.seek_audio(aid, None) # (1999, 'Invalid position type: NoneType...', ...) +``` + +--- + +### Volume Methods + +#### `set_volume(aid: int, volume: int) -> Tuple[int, str, str]` + +**Description:** + +Sets the volume. Range **0–128**. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | +| `volume` | `int` | Volume value 0–128. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | +| `1015` | `volume` is not `int`, or out of 0–128 range. | +| `1004` | Applying volume failed. | + +**Example:** + +```python +lib.set_volume(aid, 64) +result = lib.set_volume(aid, "loud") # (1015, 'Invalid volume type: str...', ...) +result = lib.set_volume(aid, 200) # (1015, 'Invalid volume: 200 (must be 0-128)', ...) +``` + +--- + +#### `get_volume(aid: int) -> Union[int, Tuple[int, str, str]]` + +**Description:** + +Returns the current volume (0–128). + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | + +**Returns:** + +- **Success:** `int` — Current volume (0–128). +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid. | + +--- + +### Fade and Transition Methods + +#### `fadein_music(aid: int, loops: int = -1, ms: int = 0) -> Tuple[int, str, str]` + +**Description:** + +Fades music in from silence to full volume over `ms` milliseconds. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `aid` | `int` | — | AID of the music file. | +| `loops` | `int` | `-1` | `-1` = infinite, `0` = once, `>0` = count. | +| `ms` | `int` | `0` | Fade duration in milliseconds. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid or not a music file. | +| `1999` | `ms` / `loops` type is invalid. | +| `1003` | Music loading failed. | +| `1004` | SDL_mixer fade failed. | + +--- + +#### `fadein_music_pos(aid: int, loops: int = -1, ms: int = 0, position: float = 0.0) -> Tuple[int, str, str]` + +**Description:** + +Fades music in from the specified position. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `aid` | `int` | — | AID of the music file. | +| `loops` | `int` | `-1` | Loop count. | +| `ms` | `int` | `0` | Fade duration in milliseconds. | +| `position` | `int` / `float` | `0.0` | Start position in seconds. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1002` | `aid` is invalid or not a music file. | +| `1999` | `ms` / `loops` / `position` type is invalid. | +| `1012` | `Mix_FadeInMusicPos` not supported by current SDL_mixer. | +| `1003` | Music loading failed. | +| `1004` | SDL_mixer fade failed. | + +--- + +#### `fadeout_music(ms: int = 0) -> Tuple[int, str, str]` + +**Description:** + +Fades out the currently playing music over `ms` milliseconds. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `ms` | `int` | `0` | Fade duration in milliseconds. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1004` | No music is playing, or fade failed. | + +--- + +#### `is_music_playing() -> bool` + +**Description:** + +Returns whether music is currently playing. + +**Returns:** `bool` — `True` if playing, `False` otherwise. + +--- + +#### `is_music_paused() -> bool` + +**Description:** + +Returns whether music is currently paused. + +**Returns:** `bool` — `True` if paused, `False` otherwise. + +--- + +#### `get_music_fading() -> int` + +**Description:** + +Returns the current fade status. + +**Returns:** `int`: + +- `0` (`MUS_NO_FADING`): No fade in progress +- `1` (`MUS_FADING_IN`): Fading in +- `2` (`MUS_FADING_OUT`): Fading out + +--- + +### Metadata Methods + +#### `get_audio_duration(source: Union[str, int], is_file: bool = False) -> Union[int, Tuple[int, str, str]]` + +**Description:** + +Returns the duration of an audio file in seconds. Accepts a file path (`str`) or an AID (`int`). + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | `str` or `int` | — | File path or AID. | +| `is_file` | `bool` | `False` | If `True`, treats `source` as a file path. | + +**Returns:** + +- **Success:** `int` — Duration in seconds (floored). +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1001` | File does not exist. | +| `1002` | Invalid AID. | +| `1011` | Metadata parsing failed. | +| `1999` | Unknown error. | + +--- + +#### `get_audio_metadata(source: Union[str, int], is_file: bool = False) -> Union[Dict, Tuple[int, str, str]]` + +**Description:** + +Returns full metadata for an audio file. Accepts a file path (`str`) or an AID (`int`). + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | `str` or `int` | — | File path or AID. | +| `is_file` | `bool` | `False` | If `True`, treats `source` as a file path. | + +**Returns:** + +- **Success:** `Dict` — Contains `path`, `format`, `duration`, `length`, `sample_rate`, `channels`, `bitrate`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1001` | File does not exist. | +| `1002` | Invalid AID. | +| `1011` | Metadata parsing failed. | +| `1014` | `source` type is invalid. | +| `2003` | Opus file open failed (Opus-specific). | +| `2004` | OpusHead corrupted (Opus-specific). | +| `2008` | Opus bitrate unavailable (Opus-specific). | + +--- + +#### `get_audio_metadata_by_path(file_path: str) -> Union[Dict, Tuple[int, str, str]]` + +**Description:** + +Returns full metadata for an audio file by file path. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `file_path` | `str` | Audio file path. | + +**Returns:** + +- **Success:** `Dict`. +- **Failure:** `Tuple[int, str, str]`. + +--- + +#### `get_audio_metadata_by_aid(aid: int) -> Union[Dict, Tuple[int, str, str]]` + +**Description:** + +Returns full metadata for an audio file by AID. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `aid` | `int` | Audio ID. | + +**Returns:** + +- **Success:** `Dict`. +- **Failure:** `Tuple[int, str, str]`. + +--- + +### Batch Parsing Methods + +> **Windows Note:** When using batch APIs, protect your entry point with `if __name__ == "__main__":`. + +#### `batch_get_metadata(file_paths: Union[List[str], str], max_workers: Optional[int] = None, show_progress: bool = False) -> List[Dict]` + +**Description:** + +Parses multiple audio files in parallel using `ProcessPoolExecutor`. Accepts a list of file paths or a directory path (recursively scans supported formats). + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `file_paths` | `List[str]` or `str` | — | List of files or directory path. | +| `max_workers` | `int` | `None` | Number of worker processes. Defaults to CPU core count. Must be a positive integer. | +| `show_progress` | `bool` | `False` | Prints progress to stdout. | + +**Returns:** + +- **Success:** `List[Dict]` — Metadata dicts for successfully parsed files (failed files are omitted). +- **Failure:** `Tuple[int, str, str]` — When `max_workers` is invalid. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1999` | `max_workers` is invalid (0, negative, non-integer, or exceeds platform limits). | + +--- + +#### `batch_get_duration(file_paths: Union[List[str], str], max_workers: Optional[int] = None) -> Dict[str, int]` + +**Description:** + +Returns durations for multiple files in parallel. + +**Returns:** + +- **Success:** `Dict[str, int]` — `{path: duration_seconds}`. +- **Failure:** `Tuple[int, str, str]` — Invalid `max_workers`. + +--- + +#### `batch_get_metadata_by_type(file_paths: Union[List[str], str], file_type: str, max_workers: Optional[int] = None) -> List[Dict]` + +**Description:** + +Parses multiple files but only returns results matching a specific format (e.g., `"mp3"`, `"opus"`). + +**Returns:** + +- **Success:** `List[Dict]` — Metadata for matching files. +- **Failure:** `Tuple[int, str, str]` — Invalid `max_workers`. + +--- + +### DAP System Methods + +#### `save_dap_to_json(save_path: str) -> Tuple[int, str, str]` + +**Description:** + +Saves DAP records to a `.ap-ds-dap` JSON file. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `save_path` | `str` | Output path. Must end with `.ap-ds-dap`. | + +**Returns:** + +- **Success:** `(0, "", "")`. +- **Failure:** `Tuple[int, str, str]`. + +**Error Codes:** + +| Code | Condition | +|------|-----------| +| `1009` | Extension is not `.ap-ds-dap`. | +| `1010` | Write failed (bad path, permission denied, disk full). | + +--- + +#### `get_dap_recordings() -> List[Dict]` + +**Description:** + +Returns the current DAP records (a copy). + +**Returns:** `List[Dict]` — Each record contains `path`, `duration`, `bitrate`, `channels`. + +--- + +#### `clear_dap_recordings() -> None` + +**Description:** + +Clears all DAP records from memory. + +**Returns:** `None`. + +--- + +### Resource Management + +#### `clear_memory_cache() -> None` + +**Description:** + +Releases all cached `Mix_Chunk` and `Mix_Music` objects and clears the cache. + +**Returns:** `None`. + +--- + +#### `cleanup_function() -> None` + +**Description:** + +Cleans up all resources: clears cache, closes mixer, quits SDL. Automatically registered with `atexit`. + +**Returns:** `None`. + +--- + +## Top-Level Convenience Functions + +The following functions are exported at the package top level (`from ap_ds import ...`). + +#### `batch_get_metadata(file_paths, max_workers=None, show_progress=False) -> List[Dict]` + +**Description:** Parses multiple audio files in parallel. + +#### `batch_get_duration(file_paths, max_workers=None) -> Dict[str, int]` + +**Description:** Returns durations for multiple files in parallel. + +#### `batch_get_metadata_by_type(file_paths, file_type, max_workers=None) -> List[Dict]` + +**Description:** Parses in parallel and filters by format. + +#### `get_audio_duration(file_path: str) -> int` + +**Description:** Returns the duration of a single audio file in seconds. + +#### `get_audio_metadata(file_path: str) -> Optional[Dict]` + +**Description:** Returns full metadata for a single audio file. + +#### `auto_check_runtime() -> Optional[Dict]` + +**Description:** Runs a runtime self-check. + +#### `check_runtime_mode() -> bool` + +**Description:** Checks whether the GIL is enabled. + +#### `show_tech_manual() -> None` + +**Description:** Prints the complete built-in technical manual to stdout. + +#### `is_full_performance() -> bool` + +**Description:** Returns whether running in full-performance mode. + +#### `get_runtime_info() -> Dict` + +**Description:** Returns a runtime information dictionary. + + +## AudioParser Module — Metadata API + +The `audio_parser` module provides pure-Python metadata parsers with zero external dependencies. + +### Format-Specific Parser Classes + +| Class | Format | Accuracy | Parsing Method | +|-------|--------|----------|----------------| +| `WAVFile` | WAV | 100% | RIFF chunk structure | +| `FLACFile` | FLAC | 100% | STREAMINFO metadata block | +| `MP3File` | MP3 | >98% | Per-frame sync word scanning | +| `AACFile` | AAC (ADTS) | >99% | ADTS frame parsing | +| `OGGFile` | OGG Vorbis | 99.99% | Granule position + Vorbis headers | + +All parser classes inherit from `FileType`, exposing properties `length`, `sample_rate`, `channels`, and `bitrate`. + +#### `open_audio(filename: str) -> FileType` + +**Description:** + +Factory function that returns the appropriate parser instance for the file. + +**Raises:** + +- `ValueError`: If the file format is unsupported. + + +## SDL2 Integration Layer + +The `_sdl2` module provides the cross-platform SDL2 loader, constants, structures, and ctypes bindings. This is an internal module; users interact with it indirectly through `AudioLibrary`. + +### Global SDL2 Functions + +| Function | Description | +|----------|-------------| +| `SDL_Init(flags)` | Initializes SDL subsystems. Returns 0 on success. | +| `SDL_Quit()` | Shuts down SDL. | +| `SDL_GetError()` | Returns the last SDL error message. | +| `SDL_RWFromFile(file, mode)` | Opens a file as an SDL RWops handle. | +| `SDL_Delay(ms)` | Suspends the calling thread for `ms` milliseconds. | + +### Global SDL2_mixer Functions + +| Function | Description | +|----------|-------------| +| `Mix_OpenAudio(freq, format, channels, chunksize)` | Opens the audio mixer. Returns 0 on success. | +| `Mix_CloseAudio()` | Closes the mixer. | +| `Mix_LoadWAV(file)` | Loads a WAV sound effect into a `Mix_Chunk`. | +| `Mix_LoadMUS(file)` | Loads a music file into a `Mix_Music`. | +| `Mix_FreeChunk(chunk)` | Frees a sound effect chunk. | +| `Mix_FreeMusic(music)` | Frees a music object. | +| `Mix_PlayChannel(channel, chunk, loops)` | Plays a chunk on a channel. | +| `Mix_PlayMusic(music, loops)` | Plays music. | +| `Mix_Pause(channel)` / `Mix_PauseMusic()` | Pauses a channel / music. | +| `Mix_Resume(channel)` / `Mix_ResumeMusic()` | Resumes a channel / music. | +| `Mix_HaltChannel(channel)` / `Mix_HaltMusic()` | Stops a channel / music. | +| `Mix_SetMusicPosition(position)` | Seeks music to position (seconds). | +| `Mix_Volume(channel, volume)` | Sets/gets channel volume (0–128, `-1` = get). | +| `Mix_VolumeMusic(volume)` | Sets/gets music volume (0–128, `-1` = get). | +| `Mix_FadeInMusic(music, loops, ms)` | Fades music in. | +| `Mix_FadeOutMusic(ms)` | Fades music out. | +| `Mix_FadeInMusicPos(music, loops, ms, position)` | Fades music in from position. | + + +## Constants Reference + +### SDL Initialization Flags + +| Constant | Value | Description | +|----------|-------|-------------| +| `SDL_INIT_TIMER` | `0x00000001` | Timer subsystem. | +| `SDL_INIT_AUDIO` | `0x00000010` | Audio subsystem. | +| `SDL_INIT_VIDEO` | `0x00000020` | Video subsystem. | +| `SDL_INIT_JOYSTICK` | `0x00000200` | Joystick subsystem. | +| `SDL_INIT_HAPTIC` | `0x00001000` | Haptic subsystem. | +| `SDL_INIT_GAMECONTROLLER` | `0x00002000` | Game controller subsystem. | +| `SDL_INIT_EVENTS` | `0x00004000` | Events subsystem. | +| `SDL_INIT_EVERYTHING` | `0x00007231` | All subsystems combined. | + +### Audio Formats + +| Constant | Value | Description | +|----------|-------|-------------| +| `AUDIO_U8` | `0x0008` | Unsigned 8-bit. | +| `AUDIO_S8` | `0x8008` | Signed 8-bit. | +| `AUDIO_U16LSB` | `0x0010` | Unsigned 16-bit little-endian. | +| `AUDIO_S16LSB` | `0x8010` | Signed 16-bit little-endian. | +| `AUDIO_U16MSB` | `0x1010` | Unsigned 16-bit big-endian. | +| `AUDIO_S16MSB` | `0x9010` | Signed 16-bit big-endian. | +| `AUDIO_S32LSB` | `0x8020` | Signed 32-bit little-endian. | +| `AUDIO_S32MSB` | `0x9020` | Signed 32-bit big-endian. | +| `AUDIO_F32LSB` | `0x8120` | Float 32-bit little-endian. | +| `AUDIO_F32MSB` | `0x9120` | Float 32-bit big-endian. | +| `MIX_DEFAULT_FORMAT` | `AUDIO_S16SYS` | Default mixer format. | + +### Mixer Initialization Flags + +| Constant | Value | Description | +|----------|-------|-------------| +| `MIX_INIT_FLAC` | `0x00000001` | FLAC support. | +| `MIX_INIT_MOD` | `0x00000002` | MOD support. | +| `MIX_INIT_MP3` | `0x00000008` | MP3 support. | +| `MIX_INIT_OGG` | `0x00000010` | OGG support. | +| `MIX_INIT_MID` | `0x00000020` | MIDI support. | +| `MIX_INIT_OPUS` | `0x00000040` | Opus support (unused in SDL2_mixer, replaced by OpusAudio). | + +### Music Type Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `MUS_NONE` | `0` | No music. | +| `MUS_CMD` | `1` | Command-based. | +| `MUS_WAV` | `2` | WAV. | +| `MUS_MOD` | `3` | MOD. | +| `MUS_MID` | `4` | MIDI. | +| `MUS_OGG` | `5` | OGG. | +| `MUS_MP3` | `6` | MP3. | +| `MUS_FLAC` | `7` | FLAC. | +| `MUS_OPUS` | `8` | Opus (unused in SDL2_mixer). | + +### Fade Status Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `MUS_NO_FADING` | `0` | No fade in progress. | +| `MUS_FADING_IN` | `1` | Fading in. | +| `MUS_FADING_OUT` | `2` | Fading out. | + + +## Unified Error Handling and Error Code Reference + +### Overview + +Starting from v4.0.0, ap_ds uses a **unified error handling system**. All methods that can fail return a consistent tuple format: + +``` +(error_code: int, error_message: str, suggestion: str) +``` + +**Success:** `(0, "", "")` + +**Failure:** `(error_code, error_message, suggestion)` + +### Complete Error Code Reference + +| Code | Constant | Meaning | Suggestion | +|------|----------|---------|------------| +| 0 | `AP_DS_SUCCESS` | Operation completed successfully | No action needed | +| 1001 | `AP_DS_ERR_FILE_NOT_FOUND` | File does not exist | Verify file path exists and is accessible | +| 1002 | `AP_DS_ERR_INVALID_AID` | Invalid or expired Audio ID | Check AID is valid and audio is loaded | +| 1003 | `AP_DS_ERR_AUDIO_LOAD_FAILED` | Audio file loading failed | Check file format and integrity | +| 1004 | `AP_DS_ERR_PLAYBACK_FAILED` | Audio playback failed | Check audio device and file format | +| 1005 | `AP_DS_ERR_SDL_INIT_FAILED` | SDL2 initialization failed | Check SDL2 installation and audio driver | +| 1006 | `AP_DS_ERR_MIXER_INIT_FAILED` | SDL2_mixer initialization failed | Check audio device and available formats | +| 1007 | `AP_DS_ERR_UNSUPPORTED_FORMAT` | Audio format not supported | Use a supported format | +| 1008 | `AP_DS_ERR_NOT_MUSIC_FILE` | Operation only supports music files | Sound effects do not support this operation | +| 1009 | `AP_DS_ERR_DAP_INVALID_EXT` | Invalid DAP file extension | Use `.ap-ds-dap` extension when saving DAP records | +| 1010 | `AP_DS_ERR_DAP_SAVE_FAILED` | DAP record saving failed | Check write permissions and disk space | +| 1011 | `AP_DS_ERR_METADATA_PARSE_FAILED` | Metadata parsing failed | File may be corrupted or uses unsupported variant | +| 1012 | `AP_DS_ERR_FADE_NOT_SUPPORTED` | Fade operation not supported by SDL_mixer | Update SDL_mixer or use alternative method | +| 1013 | `AP_DS_ERR_AUDIO_NOT_LOADED` | Audio file not loaded into memory | Call `new_aid()` first to load file into cache | +| 1014 | `AP_DS_ERR_INVALID_SOURCE` | Invalid source type for metadata query | Use file path (str) or AID (int) | +| 1015 | `AP_DS_ERR_INVALID_VOLUME` | Volume value out of range | Volume must be between 0 and 128 | +| 1016 | `AP_DS_ERR_SEEK_NOT_SUPPORTED` | Seeking not supported for this audio | Sound effects (short WAVs) do not support seeking | +| **2001** | `AP_DS_ERR_OPUS_LIB_LOAD_FAILED` | **libopusfile loading failed** | **Check if DLL exists** | +| **2002** | `AP_DS_ERR_OPUS_DLL_DEPENDENCY` | **DLL dependency missing** | **Ensure libopus-0.dll and libogg-0.dll exist** | +| **2003** | `AP_DS_ERR_OPUS_OPEN_FAILED` | **Opus file open failed** | **File may be corrupted or not a valid Opus stream** | +| **2004** | `AP_DS_ERR_OPUS_HEADER_CORRUPT` | **OpusHead header corrupted** | **Header info invalid or corrupted** | +| **2005** | `AP_DS_ERR_OPUS_TAGS_PARSE_FAILED` | **Tags parsing failed** | **Tag data corrupted or invalid format** | +| **2006** | `AP_DS_ERR_OPUS_DECODE_FAILED` | **Opus decoding failed** | **Audio data corrupted** | +| **2007** | `AP_DS_ERR_OPUS_SEEK_FAILED` | **Opus seek failed** | **Stream may not support seeking to that position** | +| **2008** | `AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE` | **Bitrate unavailable** | **Unable to determine bitrate for this Opus stream** | +| **2009** | `AP_DS_ERR_OPUS_NOT_SEEKABLE` | **Stream not seekable** | **This Opus stream does not support seeking** | +| **2010** | `AP_DS_ERR_OPUS_CHANNEL_INVALID` | **Invalid channel count** | **Opus stream has invalid channel count** | +| 1999 | `AP_DS_ERR_UNKNOWN` | An unexpected error occurred | Check file integrity and retry | + + +## Environment Variables Reference + +| Variable | Default | Description | +|----------|---------|-------------| +| `AP_DS_HIDE_SUPPORT_PROMPT` | Not set | Set to `1` to hide startup banner | +| `AP_DS_WAV_THRESHOLD` | `6` | WAV mode switch threshold (seconds) | +| `AP_DS_SUPPRESS_WARNINGS` | Not set | Set to `1` to suppress deprecation warnings | +| `AP_DS_SHOW_CONGRATS` | Not set | Set to `0` to hide full-performance congratulatory message | +| `AP_DS_SKIP_AUTO_CHECK` | `1` | Set to `0` to enable runtime self-check on import | + + +## Technical Manual (show_tech_manual()) + +ap_ds 4.1.0 RC includes a built-in technical manual that can be displayed by calling `show_tech_manual()`. This manual contains: + +- Library overview +- Supported audio formats (including Opus chapter) +- Core components (AudioLibrary, metadata parser, SDL2 loader, Opus player) +- DAP system documentation +- WAV smart mode documentation +- Environment variables reference +- Performance optimization tips +- Cross-platform notes (including macOS Opus special notes) +- Troubleshooting guide +- API reference +- Version history +- Contribution and support information + +View the manual: + +```python +from ap_ds import show_tech_manual +show_tech_manual() +``` + +# Version History + +### v4.1.0 RC (August 19, 2026) — Opus Support Preview + +**⚠️ This is an RC (Release Candidate) prerelease version for collecting feedback and bug reports.** + +**🚨 Important: Opus support exists ONLY in version 4.1.0 of the mainline. Mainline 4.2.0+ will REMOVE Opus support, migrating to the AFS branch (ap-ds-afs).** + +**🎯 New: Opus Support** + +- Added `opusplayer.py`: `OpusAudio` class, complete Opus playback engine +- Added `_opusdll.py`: Cross-platform Opus library loader (Windows auto-DLL download, Linux/macOS system library detection + installation) +- Added 10 Opus-specific error codes (2001-2010) +- Windows: Auto-download libopusfile-0.dll, libopus-0.dll, libogg-0.dll, libopusurl-0.dll with SHA256 hash verification +- Linux: apt/dnf/pacman auto-installation + interactive guidance +- macOS: Homebrew/MacPorts auto-detection + auto-install attempt + manual guidance +- Cross-platform playback backends: Windows (winmm waveOut) / Linux (ALSA) / macOS (Core Audio AudioQueue) + +**🎯 New: AFS Branch** + +- Announced AFS (All-Format Support) branch (ap-ds-afs) +- Opus will migrate to AFS branch after 4.1.0 RC +- Mainline 4.2.0+ will remove Opus, returning to 2.5MB lightweight positioning +- Added package conflict detection (foolproof design) + +**🧪 Testing** + +- Added OPUS_TEST.py: 229 Opus-specific tests, all passed +- CI/CD integration: 650+ comprehensive tests, all passed +- 5 listening tests (normal playback, fade-in, fade-out, volume 0, Seek) + +**📚 Documentation** + +- `show_tech_manual()` added 2.1 OPUS Support section +- Added 10.4 Opus Error Codes section +- Version history added 4.1.0 RC entry +- Updated README complete version with API reference, error codes, environment variables, and all other content + +**📦 Size** + +- Current 3.87 MB (4,059,251 bytes) — 4.1.0 RC only +- 4.2.0+ will remove Opus, returning to ~2.5MB + + +### v4.0.0 (August 2026) — Architecture Refactor and Performance Edition + +**This is a complete rewrite of the core architecture.** This is the largest refactor in ap_ds history, addressing all technical debt accumulated in the v3.1.x series. + +**🔧 Architecture Changes** + +- Complete module refactoring: + - Merged `audio_parser.py` (wrapper) with `audio_info.py` (actual parsers) into a single `audio_parser.py`, eliminating circular dependencies + - Split `player.py` into `player.py` (AudioLibrary only) and `_sdl2.py` (loader + constants + bindings) + - Each file now has a single responsibility, easy to maintain and debug + +**🐛 Bug Fixes** + +- Fixed v3.1.x circular import bug: `get_audio_duration()` and `get_audio_metadata()` returning 0 or None in some environments is now completely resolved +- Fixed inconsistent import behavior across Python versions +- Eliminated redundant wrapper layers that caused confusion + +**🚀 New Features** + +- **Unified error handling system**: All methods return `(error_code, error_message, suggestion)` tuples,告别 the era of exception chaos and inconsistent return values +- **Batch parsing API**: `batch_get_metadata()`, `batch_get_duration()`, `batch_get_metadata_by_type()` using `ProcessPoolExecutor` to process hundreds of files in parallel +- **Python 3.15t free-threading support**: GIL-free true parallelism, batch parsing scales linearly on multi-core CPUs +- **O(1) DAP deduplication**: `_add_to_dap_recordings()` uses set-based deduplication, near-instantaneous duplicate checking +- **Lazy imports** (Python 3.15+): Heavy modules loaded on demand, speeding up `import ap_ds` +- **Runtime self-check**: Automatic environment diagnosis on import (disable with `AP_DS_SKIP_AUTO_CHECK=1`) +- **Smart WAV mode**: WAV files shorter than `AP_DS_WAV_THRESHOLD` (default 6 seconds) play as sound effects; longer files stream as music, supporting seeking and fade effects +- **Fade control**: `fadein_music()`, `fadein_music_pos()`, `fadeout_music()` and status checks +- **Cross-platform SDL2 loader**: Auto-downloads and hash-verifies SDL2 binaries on Windows and macOS; intelligent fallback on Linux +- **Parameter type validation**: Every public entry point performs strict type checking, programming errors return clear error tuples instead of obscure `ctypes.ArgumentError` + +**📚 Documentation** + +- Complete API reference: covers all AudioLibrary methods, parameters, return values, error codes +- Unified error code reference: list of all error codes from 1001 to 1999 +- Environment variables reference: `AP_DS_WAV_THRESHOLD`, `AP_DS_SUPPRESS_WARNINGS`, etc. +- `show_tech_manual()` built-in technical manual + +**🔗 Repository** + +- Added GitHub compatibility mirror: `dvs-dvsxt/ap_ds`, providing convenience for GitHub users +- Primary repositories remain apds.top and GitCode + +**⚡ Performance** + +- 120 MP3 files batch parsing: 0.331 seconds (8 processes), 2.94x faster than Mutagen (0.973 seconds) +- 3.88x faster than v3.0.0 multi-threaded solution (1.285 seconds) + + +### v3.1.2 (July 2026) — Performance and Batch Parsing Edition (LFV) + +**🚨 Known Issue: Circular import bug causing `get_audio_duration()` and `get_audio_metadata()` to return 0 or None in some environments. This is fixed in v4.0.0. Users are advised to skip this version and upgrade directly to v4.0.0.** + +**🚀 New Features** + +- **Batch parsing API**: + - `batch_get_metadata()`: Batch parse audio files, returns full metadata list + - `batch_get_duration()`: Batch get audio durations, returns `{path: duration}` + - `batch_get_metadata_by_type()`: Batch parse filtered by format +- **Python 3.15t free-threading support**: Full adaptation to GIL-free environment, runtime auto-detection of GIL status +- **DAP deduplication optimization**: Upgraded from O(n) linear scan to O(1) set-based deduplication, with O(n) fallback retained +- **Lazy imports** (Python 3.15+): Heavy modules loaded on demand +- **Runtime diagnostic functions**: `is_full_performance()` and `get_runtime_info()` +- **Runtime self-check**: Auto-executed on import (skip with `AP_DS_SKIP_AUTO_CHECK=1`) + +**⚡ Performance** + +- 120 MP3 files: Serial 1.367s → 8-process parallel 0.331s (4.13x speedup) +- 2.94x faster than Mutagen, 3.88x faster than v3.0.0 + +**📦 Version Relationship** + +- This version is an LFV (Latest Feature Version), not LTS +- Next LTS is v4.0.0 LTS (after Python 3.15 stabilizes) + + +### v3.0.0 LTS (March 22, 2026) — First Long-Term Support Version + +This is ap_ds's first LTS version. After years of refinement, extensive real-world testing, and thorough internal resource management refactoring, this version is ready for mission-critical applications, enterprise deployments, and personal projects. + +**🎯 New Features** + +- **Deterministic resource cleanup**: Replaced unreliable `__del__` finalizers with explicit exit handlers +- **Hash-verified downloads**: Each downloaded SDL2 library is verified against hardcoded SHA-256 hashes before use +- **Complete test coverage**: Tested on all platforms with zero memory leaks +- **5-year support period**: Until March 22, 2031, with free technical support + +**🔄 Compatibility** + +- No breaking changes, fully backward compatible with v2.x + + +### v2.4.2 (March 22, 2026) — Development Mishap Version + +**⚠️ This version was accidentally uploaded with a development-stage `player.py` file. While technically usable, it may contain subtle issues and is not recommended for use in any real-world project.** + +This version is for curious exploration only — do not use in production environments. + + +### v2.4.1 (March 1, 2026) — Documentation Update + +Updated PyPI documentation to fully reflect v2.4.0's new features. + +**📚 Changes** + +- Updated PyPI project description +- Added detailed examples for all new fade functions +- Documented `AP_DS_HIDE_SUPPORT_PROMPT` environment variable +- Improved quick-start guide + +> **Note:** No code changes in this release — documentation only. + + +### v2.4.0 (March 1, 2026) — Audio Effects and Engineering Improvements + +Introduced professional audio transitions and important internal engineering upgrades. + +**🎵 New Audio Control Functions** + +| Function | Description | +|----------|-------------| +| `fadein_music(aid, loops=-1, ms=0)` | Fade music in over specified milliseconds | +| `fadein_music_pos(aid, loops=-1, ms=0, position=0.0)` | Fade music in from specified position | +| `fadeout_music(ms=0)` | Fade out currently playing music | +| `is_music_playing()` | Check if music is playing | +| `is_music_paused()` | Check if music is paused | +| `get_music_fading()` | Get current fade status | + +**🧠 Engineering Improvements** + +- Cleaner startup banner, controllable via `AP_DS_HIDE_SUPPORT_PROMPT` +- Centralized version management +- Robust import system (dual-layer fallback) +- Unified project URLs + +**🔄 Compatibility** + +- No breaking changes, all existing code continues to work + + +### v2.3.6 (February 27, 2026) — Documentation Update + +Updated PyPI documentation with detailed license information and version history, added more examples. + + +### v2.3.5 (February 26, 2026) — Stability Optimization and Embedded Verification + +**Six-dimensional test coverage:** + +1. Library loading and initialization +2. Playback testing (MP3, FLAC, OGG, WAV) +3. Seek testing +4. Memory pressure and leak detection (~4MB growth) +5. Metadata parsing accuracy +6. DAP system validation + +**Embedded Platform Support:** + +- Orange Pi 4 Pro (Allwinner A733) +- Raspberry Pi 5 (BCM2712) + +**🐛 Bug Fixes:** + +- Fixed WAV files being incorrectly treated as sound effects — configurable via `AP_DS_WAV_THRESHOLD` + + +### v2.3.4 (February 10, 2026) — Linux Smart Import System + +**Revolutionary Linux support improvement with four-layer fallback strategy:** + +1. System library check +2. User configuration check +3. Automatic package manager installation (apt-get, dnf, pacman) +4. Interactive guidance + +**Automatic Configuration Saving:** + +- Environment variables (`AP_DS_SDL2_PATH`, `AP_DS_SDL2_MIXER_PATH`) +- Persistent config file (`~/.config/ap_ds/sdl_paths.conf`) + + +### v2.3.3 (February 9, 2026) — Critical Bug Fix and Platform Stabilization + +**🚨 Critical Update:** Fixed a severe segmentation fault that caused the library to fail on macOS and Linux. + +**Root Cause:** Earlier versions only defined C function prototypes (ctypes argtypes/restype) on Windows, causing memory access violations on other operating systems. + +**Solution:** All necessary C function bindings are now unconditionally defined after loading the SDL2 library. + + +### v2.3.2 (February 9, 2026) — Linux Support Enhancement + +**Expanded Linux support with interactive setup.** + +**Interactive Linux Support:** + +1. Use system-installed libraries +2. Specify compiled .so file paths +3. Get detailed compilation instructions + + +### v2.3.1 (February 9, 2026) — Documentation Update + +Improved README.md with better examples and explanations. Fixed minor errors in documentation examples. + + +### v2.3.0 (January 31, 2026) — DAP Recording System + +**Introducing the DAP (Dvs Audio Playlist) system.** + +**Core Features:** + +- **Smart automatic recording**: Auto-triggered in `play_from_file()`, `play_from_memory()` +- **Lightweight design**: Metadata only, no audio data +- **Standardized file format**: `.ap-ds-dap` extension, JSON format +- **Intelligent deduplication**: Automatically avoids duplicate recordings of the same file + +**New APIs:** + +- `_add_to_dap_recordings(file_path)` — internal use +- `save_dap_to_json(save_path)` — save as JSON +- `get_dap_recordings()` — get all records +- `clear_dap_recordings()` — clear records + + +### v2.2.0 (January 19, 2026) — Cross-Platform Revolution + +**From single-platform to cross-platform.** + +**Major New Features:** + +**1. Full macOS Support** + +- Automatically downloads and installs SDL2.framework, SDL2_mixer.framework +- Intelligent .dmg file extraction and framework loading +- Maintains extreme lightweight: only 3.36MB (vs Windows 2.5MB) + +**2. Enhanced Automatic Dependency Management** + +- Cross-platform intelligent download strategy +- Complete error handling and retry mechanisms +- Local caching of dependency files + + +### v2.1.4 (January 18, 2026) — Stable Release + +**Production-ready stable release.** + +- Core stability: Extensively tested, no known critical bugs +- Extreme lightweight: Only 2.5MB complete solution +- Full documentation: Detailed technical manual and examples + + +### v2.1.0 (December 26, 2025) — Feature Enhancement + +**Professional feature expansion.** + +**New Features:** + +- Metadata enhancement: More precise audio information parsing +- Playback precision improvements: Better time control and seeking + + +### v2.0.0 (November 5, 2025) — Architecture Refactor + +**Introducing modern audio management system.** + +**Major Improvements:** + +- **AID system**: Unified audio instance management +- **Architecture refactor**: Modular design, improved maintainability +- **Smart memory management**: Automatic cleanup of unused audio resources +- **State management**: Unified playback state tracking + + +### v1.0.0 (July 8, 2025) — Initial Release + +**Project birth, basic functionality.** + +**Core Features:** + +- Basic audio playback: MP3, WAV, FLAC, OGG formats +- Playback controls: Basic play, pause, stop, seek APIs +- Volume control: Real-time volume adjustment (0-100%) +- Lightweight design: ~2MB initial version + +## License + +This project is licensed under the **DVS Audio Library (ap_ds) Open Source License v2.0**. The full license text is below. Use, copying, modification, or distribution of this software constitutes acceptance of all terms and conditions of this license. + +# DVS Audio Library (ap_ds) Open Source License v2.0 + +**Version: 2.0** +**Effective Date: March 22, 2026** +**Applies to: ap_ds 2.4.1 and above (except for subsequent license updates)** +**Project Homepage: https://apds.top** + +--- + +## 1. Definitions + +1.1. **"Software"** means the DVS Audio Library (ap_ds) project and all its components, source code, object code, and related documentation. + +1.2. **"Source Code"** means the human-readable form of the Software. + +1.3. **"Modified Version"** means any derivative work created by modifying, supplementing, translating, or otherwise altering the Software. + +1.4. **"Distribution"** means making the Software or a Modified Version available to any third party by any means or medium. + +1.5. **"You"** means any individual or legal entity exercising rights granted by this License. + +1.6. **"Independent Brand"** means a completely new project name, logo, and brand identity that does not create a confusing association with the Software's official names (including but not limited to "ap_ds", "AP_DS", "Audio Library By DVS", "DVS Audio Player", and any variations thereof). + + +## 2. License Grant + +Subject to the terms and conditions of this License, the author grants You a perpetual, worldwide, royalty-free, non-exclusive, irrevocable right to: + +2.1. **Use and Run**: Run the Software on any computer system for any lawful purpose. + +2.2. **Copy and Distribute**: Make any number of copies of the Software and distribute them. + +2.3. **Study and Modify**: Study the Source Code of the Software and make any modifications to suit Your needs. + +2.4. **Integrate and Use Commercially**: Integrate the Software into Your products or projects and use it in any commercial environment. + + +## 3. Obligations and Restrictions + +### 3.1. Attribution and Source Identification + +Whenever using, distributing, or integrating the Software or a Modified Version, You must: + +a) **Retain Original Copyright Notices**: Retain all original copyright, patent, and trademark notices in all copies of the Software. + +b) **Provide Prominent Source Attribution**: Clearly and prominently state in the Software's documentation, official website, user interface, or related materials: + ``` + Based on DVS Audio Library (ap_ds) v[version number] + Original Author: Dvs (DvsXT) + Project Homepage: https://apds.top + ``` + +c) **Add Notice for Modified Versions**: If You distribute a Modified Version, in addition to the above attribution, You must add the following notice: + ``` + This is a modified version maintained by [Your name/organization]. + Support: [Your contact information]. + This version is not official and is not affiliated with the original author. + ``` + +### 3.2. Brand Protection + +To prevent brand confusion and project fragmentation, Modified Versions must comply with the following strict rules: + +a) **No Use of Original Brand Names**: You may not name a Modified Version "ap_ds", "AP_DS", "Audio Library By DVS", "DVS Audio Player", or any confusingly similar variation, combination, or derivative name. + +b) **Independent Brand Requirement**: Modified Versions must use a completely independent project name and establish their own independent project identity, documentation, and community. + +c) **Maintainer Responsibility Statement**: Distributors of Modified Versions must state on their project homepage or in a prominent location: + ``` + This project is based on DVS Audio Library (ap_ds) but has independently evolved and is entirely maintained by [Your name]. + For the original version, please visit: https://apds.top. + The maintainer is solely responsible for any issues related to this project. + ``` + +### 3.3. Modified Version Quality Commitment + +If You distribute a Modified Version, You must: + +a) **Clearly State Modifications**: Clearly indicate that this is a Modified Version and list key modifications and compatibility notes compared to the original version. + +b) **Provide Technical Support**: Provide valid technical support contact information for the Modified Version You distribute, and define the scope of support. + +c) **Do Not Mislead Users**: You may not imply in any way that Your Modified Version is officially endorsed, supported, or a continuation of the original project. + +### 3.4. Prohibited Uses + +You may not use the Software for any illegal activities, malicious purposes, or in violation of local laws and regulations. + + +## 4. Patent Grant + +4.1. **Patent License**: The author grants You a worldwide, royalty-free, non-exclusive, non-transferable patent license to make, use, sell, offer for sale, import, or otherwise transfer the Software. + +4.2. **Patent Defense Termination**: If You or Your affiliates file a patent infringement lawsuit against the author regarding the Software, all rights granted to You under this License will automatically and immediately terminate. + + +## 5. Technical Transparency and Security + +5.1. **Security Review Right**: Any user has the right to conduct security audits of the Software's Source Code. + +5.2. **Security Reporting**: Reporting of discovered security issues to the original author (me@dvsyun.top) is encouraged, and public disclosure after resolution is supported. + +5.3. **No Backdoors Commitment**: Official releases commit to containing no malicious code, backdoors, or functionality that collects user data without explicit user consent. + + +## 6. Disclaimer of Warranties and Limitation of Liability + +6.1. **Disclaimer of Warranties**: The Software is provided "AS IS", without warranty of any kind, express or implied. + +6.2. **Limitation of Liability**: To the maximum extent permitted by applicable law, the author or copyright holder shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages. + + +## 7. License Management and Termination + +7.1. **Version Control**: This License is version 2.0. Subsequent versions will be published on the project homepage. + +7.2. **Compatibility**: This License is compatible with the MIT, BSD 3-Clause, and Apache 2.0 licenses. + +7.3. **Automatic Termination**: If You fail to comply with the terms of this License, Your rights will automatically terminate. + + +## 8. Governing Law and Dispute Resolution + +8.1. **Governing Law**: This License shall be governed by the laws of the People's Republic of China. + +8.2. **Dispute Resolution**: Any dispute arising out of or in connection with this License shall first be resolved through friendly negotiation. If negotiation fails, either party may submit the dispute to the competent people's court in the place of the project author's domicile. + + +## 9. Contact Information + +9.1. **Licensing Inquiries**: + - Email: me@dvsyun.top or dvs6666@163.com + - Project Homepage: https://apds.top + - Response Time: Within 7 business days + +9.2. **Technical Support**: + - Priority: File issues via GitCode Issues + - Urgent matters: Send to the above email addresses + + +**Use, copying, modification, or distribution of this software constitutes acceptance of all terms and conditions of this license.** + + +## SDL2 Acknowledgements + +### Sincere Gratitude + +ap_ds would not exist without the extraordinary work of the **SDL2** development team. We owe them a debt of gratitude that words cannot adequately express. + +**To Sam Lantinga and the entire SDL development community:** + +Thank you. From the bottom of our hearts, thank you. + +You have built something truly remarkable. For over twenty years, SDL has been the backbone of countless games, multimedia applications, and creative projects worldwide. It runs on everything — Windows, macOS, Linux, Android, iOS, game consoles, and embedded devices. It is stable, efficient, and beautifully designed. It is one of the most important open-source projects of our time. + +We are just one small library among thousands that depend on your work. But we are deeply grateful. Every time a user plays an audio file through ap_ds, it's SDL2 doing the heavy lifting — decoding, mixing, and streaming audio with low latency and rock-solid reliability. We just provide the Python wrapper. You provide the magic. + +### Legal Compliance + +This library uses the **Simple DirectMedia Layer (SDL2)** and **SDL2_mixer** libraries. + +- **SDL2 Website:** https://www.libsdl.org/ +- **SDL2 License:** zlib/libpng license +- **SDL2_mixer Website:** https://www.libsdl.org/projects/SDL_mixer/ +- **SDL2_mixer License:** zlib/libpng license + +The zlib/libpng license is a permissive free software license that allows free use, modification, and distribution of the software in commercial products with minimal attribution requirements. + + +## Closing + +ap_ds is built on a simple philosophy: **Focus on playback and parsing, stay lightweight, and let developers build great applications.** + +We welcome feedback, bug reports, and contributions. If you have questions or concerns, please contact us through the official channels. + +**Thank you for using ap_ds!** \ No newline at end of file diff --git a/ap_ds/Test/CI,CD_TEST.py b/ap_ds/Test/CI,CD_TEST.py new file mode 100644 index 0000000..6f6f675 --- /dev/null +++ b/ap_ds/Test/CI,CD_TEST.py @@ -0,0 +1,1171 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r""" +============================================================================ + AP_DS 4.0.1 - Comprehensive CICD Test Suite + Audio Library By DVS +============================================================================ + +Automated + interactive test coverage for every module of the ap_ds package: + + [A] Package Imports / Exports / Error Codes + [B] Metadata Parsing (WAV/MP3, single file + batch) + [C] AudioLibrary Initialization + [D] Playback (file / memory / DAP / error paths) + [E] Playback Control (pause / resume / stop) + [F] Volume Control + [G] Seek + [H] Fade In / Out + [I] DAP Recording System + [J] Metadata Methods + [K] Helper Methods + [L] Resource Management + [M] Top-Level API + [N] Listening Tests (interactive, requires ears) + [O] Edge Cases & Error Handling + [P] __init__.py Module Coverage + [Q] _sdl2.py Constants / Structures / Bindings + [R] audio_parser.py Deep Parser Coverage + [S] Supplementary Cases + +Usage: + python cicd_test.py --auto Run automated tests only + python cicd_test.py --listen Run interactive listening tests only + python cicd_test.py --full Run everything (default) + +The suite verifies that every public method returns the documented +(result, error_code, suggestion) tuple on failure and never raises +unexpected exceptions for invalid or boundary input. +============================================================================ +""" +import os +import sys +import io +import json +import time +import struct +import wave +import contextlib + +# ============================================================================ +# Environment +# ============================================================================ +os.environ.setdefault('AP_DS_SKIP_AUTO_CHECK', '1') +os.environ.setdefault('AP_DS_SUPPRESS_WARNINGS', '1') + +# Make ap_ds importable regardless of where this script lives. +_HERE = os.path.dirname(os.path.abspath(__file__)) +if os.path.basename(_HERE) == 'ap_ds': + sys.path.insert(0, os.path.dirname(_HERE)) +else: + sys.path.insert(0, _HERE) + +import ap_ds +from ap_ds import ( + AudioLibrary, + get_audio_duration, + get_audio_metadata, + batch_get_metadata, + batch_get_duration, + batch_get_metadata_by_type, + is_full_performance, + get_runtime_info, +) +import ap_ds.player as player +import ap_ds.audio_parser as audio_parser + + +def _prompt_mp3(): + """Ask the user for an MP3 file path. Returns None if skipped/EOF.""" + print("\n" + "=" * 60) + print(" AP_DS 4.0.1 CICD Test Suite") + print("=" * 60) + try: + answer = input( + "Enter path to an MP3 file for playback / metadata tests\n" + "(or press Enter to skip MP3-dependent tests): " + ).strip().strip('"').strip("'") + return answer if answer else None + except EOFError: + # Non-interactive execution (e.g. piped stdin) -> skip MP3 tests + return None + + +# Prompting happens inside main() (guarded by __main__) so that +# multiprocessing spawn children never re-run input(). +MP3_FILE = None + +# ============================================================================ +# Test resources +# ============================================================================ +TMP_DIR = os.path.join(_HERE, 'cicd_tmp') +os.makedirs(TMP_DIR, exist_ok=True) + +WAV_SHORT = os.path.join(TMP_DIR, 't_short.wav') # 2s -> sound-effect mode +WAV_LONG = os.path.join(TMP_DIR, 't_long.wav') # 10s -> music mode +WAV_BAD = os.path.join(TMP_DIR, 't_bad.wav') # corrupted +FAKE_DAP = os.path.join(TMP_DIR, 't.ap-ds-dap') # DAP export artifact +NODIR = os.path.join(TMP_DIR, 'no_such_dir') + + +def make_wav(path, seconds, sr=22050, ch=1, sw=2): + """Create a valid silent WAV file (PCM). Reuses the file if locked.""" + n = sr * seconds * ch + try: + with wave.open(path, 'w') as w: + w.setnchannels(ch) + w.setsampwidth(sw) + w.setframerate(sr) + if sw == 1: + data = b'\x80' * (n * sw) + elif sw == 2: + data = b''.join(struct.pack('> 12) & 0xFF + data[11] = (sr >> 4) & 0xFF + d12_hi = sr & 0xF + data[12] = (d12_hi << 4) | ((ch - 1) << 1) + ts = total_samples + data[17] = ts & 0xFF + data[16] = (ts >> 8) & 0xFF + data[15] = (ts >> 16) & 0xFF + data[14] = (ts >> 24) & 0xFF + data[13] = (ts >> 32) & 0x0F + header = b'\x80\x00\x00\x22' # is_last=1, type=STREAMINFO(0), size=34 + with open(path, 'wb') as f: + f.write(b'fLaC' + header + bytes(data)) + return path + + +# ============================================================================ +# Test framework +# ============================================================================ +class CICD: + def __init__(self): + self.passed = 0 + self.failed = 0 + self.skipped = 0 + self.failures = [] + + def log(self, msg): + print(msg, flush=True) + + def check(self, name, cond, detail=""): + if cond: + self.passed += 1 + self.log(f" [PASS] {name}" + (f" | {detail}" if detail else "")) + else: + self.failed += 1 + self.failures.append((name, detail)) + self.log(f" [FAIL] {name}" + (f" | {detail}" if detail else "")) + + def skip(self, name, reason): + self.skipped += 1 + self.log(f" [SKIP] {name} | {reason}") + + def section(self, title): + self.log("\n" + "=" * 66) + self.log(f" {title}") + self.log("=" * 66) + + def summary(self): + self.log("\n" + "=" * 66) + self.log(" CICD Test Summary") + self.log("=" * 66) + self.log(f" Passed : {self.passed}") + self.log(f" Failed : {self.failed}") + self.log(f" Skipped: {self.skipped}") + if self.failures: + self.log(" --- Failed details ---") + for name, detail in self.failures: + self.log(f" [FAIL] {name}: {detail}") + self.log("=" * 66) + + +# ============================================================================ +# [A] Package Imports / Exports / Error Codes +# ============================================================================ +def test_imports(t): + t.section("[A] Package Imports / Exports / Error Codes") + t.check("ap_ds version", ap_ds.__version__ == "4.0.1", f"v{ap_ds.__version__}") + t.check("AudioLibrary importable", callable(AudioLibrary)) + t.check("get_audio_duration importable", callable(get_audio_duration)) + t.check("get_audio_metadata importable", callable(get_audio_metadata)) + t.check("batch_get_metadata importable", callable(batch_get_metadata)) + t.check("batch_get_duration importable", callable(batch_get_duration)) + t.check("batch_get_metadata_by_type importable", callable(batch_get_metadata_by_type)) + t.check("is_full_performance importable", callable(is_full_performance)) + t.check("get_runtime_info importable", callable(get_runtime_info)) + required = ["__version__", "AudioLibrary", "get_audio_duration", "get_audio_metadata", + "batch_get_metadata", "batch_get_duration", "batch_get_metadata_by_type", + "auto_check_runtime", "check_runtime_mode", "show_tech_manual"] + missing = [x for x in required if x not in ap_ds.__all__] + t.check("__all__ complete", not missing, f"missing={missing}") + # Error-code constants + t.check("AP_DS_SUCCESS=0", player.AP_DS_SUCCESS == 0) + t.check("AP_DS_ERR_FILE_NOT_FOUND=1001", player.AP_DS_ERR_FILE_NOT_FOUND == 1001) + t.check("AP_DS_ERR_INVALID_AID=1002", player.AP_DS_ERR_INVALID_AID == 1002) + t.check("AP_DS_ERR_AUDIO_LOAD_FAILED=1003", player.AP_DS_ERR_AUDIO_LOAD_FAILED == 1003) + t.check("AP_DS_ERR_PLAYBACK_FAILED=1004", player.AP_DS_ERR_PLAYBACK_FAILED == 1004) + t.check("AP_DS_ERR_DAP_INVALID_EXT=1009", player.AP_DS_ERR_DAP_INVALID_EXT == 1009) + t.check("AP_DS_ERR_DAP_SAVE_FAILED=1010", player.AP_DS_ERR_DAP_SAVE_FAILED == 1010) + t.check("AP_DS_ERR_METADATA_PARSE_FAILED=1011", player.AP_DS_ERR_METADATA_PARSE_FAILED == 1011) + t.check("AP_DS_ERR_FADE_NOT_SUPPORTED=1012", player.AP_DS_ERR_FADE_NOT_SUPPORTED == 1012) + t.check("AP_DS_ERR_AUDIO_NOT_LOADED=1013", player.AP_DS_ERR_AUDIO_NOT_LOADED == 1013) + t.check("AP_DS_ERR_INVALID_SOURCE=1014", player.AP_DS_ERR_INVALID_SOURCE == 1014) + t.check("AP_DS_ERR_INVALID_VOLUME=1015", player.AP_DS_ERR_INVALID_VOLUME == 1015) + t.check("AP_DS_ERR_SEEK_NOT_SUPPORTED=1016", player.AP_DS_ERR_SEEK_NOT_SUPPORTED == 1016) + t.check("AP_DS_ERR_UNKNOWN=1999", player.AP_DS_ERR_UNKNOWN == 1999) + # SDL bindings present + t.check("SDL_Init bound", callable(player.SDL_Init)) + t.check("SDL_GetError bound", callable(player.SDL_GetError)) + t.check("Mix_LoadMUS bound", callable(player.Mix_LoadMUS)) + t.check("Mix_PlayMusic bound", callable(player.Mix_PlayMusic)) + t.check("Mix_SetMusicPosition bound", callable(player.Mix_SetMusicPosition)) + t.check("Mix_FadeInMusicPos bound", callable(player.Mix_FadeInMusicPos)) + t.check("WAV_THRESHOLD=6", player.WAV_THRESHOLD == 6, f"got={player.WAV_THRESHOLD}") + + +# ============================================================================ +# [B] Metadata Parsing +# ============================================================================ +def test_metadata(t): + t.section("[B] Metadata Parsing (WAV/MP3 + batch)") + make_wav(WAV_SHORT, 2) + make_wav(WAV_LONG, 10) + make_bad_wav(WAV_BAD) + with io.open(FAKE_DAP, 'w', encoding='utf-8') as f: + json.dump([], f) + + d1 = get_audio_duration(WAV_SHORT) + t.check("WAV short duration=2s", d1 == 2, f"got={d1}") + d2 = get_audio_duration(WAV_LONG) + t.check("WAV long duration=10s", d2 == 10, f"got={d2}") + meta = get_audio_metadata(WAV_SHORT) + t.check("WAV metadata is dict", isinstance(meta, dict)) + if isinstance(meta, dict): + t.check("WAV sample_rate=22050", meta.get('sample_rate') == 22050, f"got={meta.get('sample_rate')}") + t.check("WAV channels=1", meta.get('channels') == 1, f"got={meta.get('channels')}") + t.check("WAV fields complete", all(k in meta for k in ('path', 'format', 'duration', 'length', 'sample_rate', 'channels', 'bitrate'))) + if MP3_FILE and os.path.exists(MP3_FILE): + dm = get_audio_duration(MP3_FILE) + t.check("MP3 duration>0", dm > 0, f"got={dm}") + mm = get_audio_metadata(MP3_FILE) + t.check("MP3 metadata is dict", isinstance(mm, dict)) + if isinstance(mm, dict): + t.check("MP3 format=mp3", mm.get('format') == 'mp3', f"got={mm.get('format')}") + t.check("MP3 duration field>0", mm.get('duration', 0) > 0, f"got={mm.get('duration')}") + else: + t.skip("MP3 metadata", "no MP3 file provided") + + db = get_audio_duration(WAV_BAD) + t.check("Corrupted WAV duration=0", db == 0, f"got={db}") + mb = get_audio_metadata(WAV_BAD) + t.check("Corrupted WAV metadata=None", mb is None, f"got={mb}") + + files = [WAV_SHORT, WAV_LONG] + bl = batch_get_metadata(files, max_workers=2) + t.check("batch_get_metadata returns 2", len(bl) == 2, f"got={len(bl)}") + bd = batch_get_duration(files, max_workers=2) + t.check("batch_get_duration returns 2", len(bd) == 2, f"got={bd}") + bt = batch_get_metadata_by_type(files, 'wav', max_workers=2) + t.check("batch_by_type filters wav=2", len(bt) == 2, f"got={len(bt)}") + bl2 = batch_get_metadata([WAV_SHORT, WAV_BAD], max_workers=2) + t.check("batch with corrupted -> 1 kept", len(bl2) == 1, f"got={len(bl2)}") + bd3 = batch_get_duration(TMP_DIR, max_workers=2) + t.check("batch on directory >0", len(bd3) > 0, f"got={len(bd3)}") + try: + audio_parser.open_audio(os.path.join(TMP_DIR, 'x.txt')) + t.check("Unsupported format raises ValueError", False) + except ValueError: + t.check("Unsupported format raises ValueError", True) + + +# ============================================================================ +# [C] AudioLibrary Initialization +# ============================================================================ +def test_init(t): + t.section("[C] AudioLibrary Initialization") + lib = AudioLibrary() + t.check("Default init ok", lib is not None) + t.check("AID counter starts at 0", lib._aid_counter == 0) + t.check("Caches initially empty", lib._audio_cache == {} and lib._music_cache == {}) + t.check("DAP initially empty", lib._dap_recordings == [] and lib._dap_records_set == set()) + t.check("MUS_NO_FADING=0", lib.MUS_NO_FADING == 0) + lib.cleanup_function() + + lib2 = AudioLibrary(frequency=48000, channels=1, chunksize=1024) + t.check("Custom-param init ok", lib2 is not None) + t.check("frequency stored=48000", lib2._sample_rate == 48000) + t.check("channels stored=1", lib2._channels == 1) + lib2.cleanup_function() + return lib + + +# ============================================================================ +# [D] Playback +# ============================================================================ +def test_play(t, lib): + t.section("[D] Playback") + aid = lib.play_from_file(WAV_SHORT) + t.check("play_from_file(short WAV) returns AID", isinstance(aid, int), f"got={aid}") + if isinstance(aid, int): + time.sleep(0.3) + t.check("short WAV is_music_playing=False", lib.is_music_playing() is False) + t.check("short WAV in audio_cache", WAV_SHORT in lib._audio_cache) + lib.stop_audio(aid) + aid2 = lib.play_from_file(WAV_LONG) + t.check("play_from_file(long WAV) returns AID", isinstance(aid2, int), f"got={aid2}") + if isinstance(aid2, int): + time.sleep(0.3) + t.check("long WAV is_music_playing=True", lib.is_music_playing() is True) + t.check("long WAV in music_cache", WAV_LONG in lib._music_cache) + lib.stop_audio(aid2) + aid3 = lib.play_from_file(WAV_LONG, start_pos=3.0) + t.check("play_from_file(start_pos=3) returns AID", isinstance(aid3, int), f"got={aid3}") + if isinstance(aid3, int): + lib.stop_audio(aid3) + r = lib.play_from_file(os.path.join(TMP_DIR, 'missing.mp3')) + t.check("Missing file -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}") + r = lib.play_from_file(FAKE_DAP) + t.check(".ap-ds-dap -> 1003", isinstance(r, tuple) and r[0] == 1003, f"got={r}") + r = lib.play_from_memory(os.path.join(TMP_DIR, 'never_loaded.wav')) + t.check("play_from_memory(not loaded) -> 1013", isinstance(r, tuple) and r[0] == 1013, f"got={r}") + aidn = lib.new_aid(WAV_SHORT) + t.check("new_aid returns AID", isinstance(aidn, int), f"got={aidn}") + r = lib.play_from_memory(WAV_SHORT) + t.check("play_from_memory after new_aid ok", isinstance(r, int), f"got={r}") + if isinstance(r, int): + lib.stop_audio(r) + r = lib.new_aid(os.path.join(TMP_DIR, 'missing.wav')) + t.check("new_aid(missing) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}") + return aidn + + +# ============================================================================ +# [E] Playback Control +# ============================================================================ +def test_control(t, lib): + t.section("[E] Playback Control") + aid = lib.play_from_file(WAV_LONG) + t.check("Play long WAV ok", isinstance(aid, int), f"got={aid}") + if not isinstance(aid, int): + return + time.sleep(0.3) + r = lib.pause_audio(aid) + t.check("pause_audio ok", r[0] == 0, f"got={r}") + time.sleep(0.2) + t.check("is_music_paused=True", lib.is_music_paused() is True) + r = lib.play_audio(aid) + t.check("play_audio resume ok", r[0] == 0, f"got={r}") + time.sleep(0.2) + t.check("is_music_playing=True after resume", lib.is_music_playing() is True) + r = lib.stop_audio(aid) + t.check("stop_audio returns float", isinstance(r, float), f"got={r}") + r = lib.pause_audio(99999) + t.check("pause_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.play_audio(99999) + t.check("play_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.stop_audio(99999) + t.check("stop_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.seek_audio(99999, 1.0) + t.check("seek_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + + +# ============================================================================ +# [F] Volume Control +# ============================================================================ +def test_volume(t, lib): + t.section("[F] Volume Control") + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + r = lib.set_volume(aid, 64) + t.check("set_volume(music,64) ok", r[0] == 0, f"got={r}") + g = lib.get_volume(aid) + t.check("get_volume(music) is int", isinstance(g, int), f"got={g}") + lib.stop_audio(aid) + aid2 = lib.play_from_file(WAV_SHORT) + if isinstance(aid2, int): + r = lib.set_volume(aid2, 100) + t.check("set_volume(sound,100) ok", r[0] == 0, f"got={r}") + g = lib.get_volume(aid2) + t.check("get_volume(sound) is int", isinstance(g, int), f"got={g}") + lib.stop_audio(aid2) + aidv = lib.play_from_file(WAV_LONG) + if isinstance(aidv, int): + r = lib.set_volume(aidv, -1) + t.check("set_volume(-1) -> 1015", r[0] == 1015, f"got={r}") + r = lib.set_volume(aidv, 129) + t.check("set_volume(129) -> 1015", r[0] == 1015, f"got={r}") + lib.stop_audio(aidv) + r = lib.set_volume(99999, 50) + t.check("set_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.get_volume(99999) + t.check("get_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + + +# ============================================================================ +# [G] Seek +# ============================================================================ +def test_seek(t, lib): + t.section("[G] Seek") + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + time.sleep(0.2) + r = lib.seek_audio(aid, 5.0) + t.check("seek_audio(music,5s) ok", r[0] == 0, f"got={r}") + t.check("music still playing after seek", lib.is_music_playing() is True) + lib.stop_audio(aid) + aid2 = lib.play_from_file(WAV_SHORT) + if isinstance(aid2, int): + r = lib.seek_audio(aid2, 0.5) + t.check("seek_audio(sound) ok", r[0] == 0, f"got={r}") + still = any(v['aid'] == aid2 for v in lib._channel_info.values()) + t.check("sound entry retained after seek", still) + rp = lib.pause_audio(aid2) + t.check("sound controllable after seek", rp[0] == 0, f"got={rp}") + lib.stop_audio(aid2) + + +# ============================================================================ +# [H] Fade In / Out +# ============================================================================ +def test_fade(t, lib): + t.section("[H] Fade In / Out") + aid = lib.play_from_file(WAV_LONG) + if not isinstance(aid, int): + return + time.sleep(0.2) + r = lib.fadein_music(aid, ms=500) + t.check("fadein_music ok", r[0] == 0, f"got={r}") + if r[0] == 0: + time.sleep(0.2) + fading = lib.get_music_fading() + t.check("get_music_fading returns 0/1/2", fading in (0, 1, 2), f"got={fading}") + r = lib.fadeout_music(500) + t.check("fadeout_music ok", r[0] == 0, f"got={r}") + time.sleep(0.3) + r = lib.fadein_music_pos(aid, ms=500, position=2.0) + t.check("fadein_music_pos ok", r[0] == 0, f"got={r}") + lib.stop_audio(aid) + r = lib.fadein_music(99999) + t.check("fadein_music(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.fadein_music_pos(99999, ms=500) + t.check("fadein_music_pos(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + + +# ============================================================================ +# [I] DAP Recording System +# ============================================================================ +def test_dap(t, lib): + t.section("[I] DAP Recording System") + lib.clear_dap_recordings() + t.check("clear_dap empties list", lib.get_dap_recordings() == []) + lib._add_to_dap_recordings(WAV_SHORT) + lib._add_to_dap_recordings(WAV_LONG) + recs = lib.get_dap_recordings() + t.check("DAP records 2 entries", len(recs) == 2, f"got={len(recs)}") + lib._add_to_dap_recordings(WAV_SHORT) + recs = lib.get_dap_recordings() + t.check("DAP dedupe keeps 2", len(recs) == 2, f"got={len(recs)}") + if recs: + t.check("DAP record fields complete", + all(k in recs[0] for k in ('path', 'duration', 'bitrate', 'channels'))) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + lib._add_to_dap_recordings(os.path.join(TMP_DIR, 'missing.m4a')) + t.check("DAP record missing file no crash", "set deduplication failed" not in buf.getvalue()) + dap_save = os.path.join(TMP_DIR, 'out.ap-ds-dap') + r = lib.save_dap_to_json(dap_save) + t.check("save_dap_to_json ok", r[0] == 0, f"got={r}") + t.check("DAP file created", os.path.exists(dap_save)) + r = lib.save_dap_to_json(os.path.join(TMP_DIR, 'out.json')) + t.check("save_dap_to_json(.json) -> 1009", r[0] == 1009, f"got={r}") + r = lib.save_dap_to_json(r'Z:\no\such\dir\out.ap-ds-dap') + t.check("save_dap_to_json(bad path) -> 1010", r[0] == 1010, f"got={r}") + lib.clear_dap_recordings() + t.check("clear_dap again empties", lib.get_dap_recordings() == []) + + +# ============================================================================ +# [J] Metadata Methods +# ============================================================================ +def test_metadata_methods(t, lib): + t.section("[J] Metadata Methods") + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + r = lib.get_audio_metadata_by_aid(aid) + t.check("get_audio_metadata_by_aid is dict", isinstance(r, dict), f"got={type(r).__name__}") + r = lib.get_audio_metadata_by_path(WAV_LONG) + t.check("get_audio_metadata_by_path is dict", isinstance(r, dict), f"got={type(r).__name__}") + r = lib.get_audio_metadata(WAV_LONG, is_file=True) + t.check("get_audio_metadata(str path) is dict", isinstance(r, dict), f"got={type(r).__name__}") + r = lib.get_audio_metadata(aid) + t.check("get_audio_metadata(int AID) is dict", isinstance(r, dict), f"got={type(r).__name__}") + r = lib.get_audio_metadata(1.5) + t.check("get_audio_metadata(float) -> 1014", isinstance(r, tuple) and r[0] == 1014, f"got={r}") + r = lib.get_audio_duration(aid) + t.check("get_audio_duration(AID)=10", r == 10, f"got={r}") + r = lib.get_audio_duration(WAV_LONG, is_file=True) + t.check("get_audio_duration(path)=10", r == 10, f"got={r}") + r = lib.get_audio_duration(99999) + t.check("get_audio_duration(invalid AID) -> 1002", isinstance(r, tuple) and r[0] == 1002, f"got={r}") + sr = lib._get_sample_rate(WAV_LONG) + t.check("_get_sample_rate=22050", sr == 22050, f"got={sr}") + ch = lib._get_channels(WAV_LONG) + t.check("_get_channels=1", ch == 1, f"got={ch}") + est = lib.simple_mp3_duration_estimation(WAV_LONG) + t.check("simple_mp3_duration_estimation>0", est > 0, f"got={est}") + pd = lib._get_playing_duration(aid) + t.check("_get_playing_duration>=10", pd >= 10, f"got={pd}") + fd = lib._get_file_duration(WAV_LONG) + t.check("_get_file_duration=10", fd == 10.0, f"got={fd}") + r = lib.get_audio_metadata_by_aid(99999) + t.check("get_audio_metadata_by_aid(invalid) -> 1002", r[0] == 1002, f"got={r}") + r = lib.get_audio_metadata_by_path(os.path.join(TMP_DIR, 'missing.mp3')) + t.check("get_audio_metadata_by_path(missing) -> 1001", r[0] == 1001, f"got={r}") + r = lib.get_audio_duration(os.path.join(TMP_DIR, 'missing.mp3'), is_file=True) + t.check("get_audio_duration(missing path) -> 1001", r[0] == 1001, f"got={r}") + lib.stop_audio(aid) + + +# ============================================================================ +# [K] Helper Methods +# ============================================================================ +def test_helpers(t, lib): + t.section("[K] Helper Methods") + t.check("_is_music_file(.mp3)=True", lib._is_music_file('x.mp3') is True) + t.check("_is_music_file(.ogg)=True", lib._is_music_file('x.ogg') is True) + t.check("_is_music_file(.flac)=True", lib._is_music_file('x.flac') is True) + t.check("_is_music_file(long wav)=True", lib._is_music_file(WAV_LONG) is True) + t.check("_is_music_file(short wav)=False", lib._is_music_file(WAV_SHORT) is False) + t.check("_is_music_file(.aif)=False", lib._is_music_file('x.aif') is False) + t.check("_is_music_file(.txt)=False", lib._is_music_file('x.txt') is False) + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + ch = lib._find_channel_by_aid(aid) + t.check("_find_channel_by_aid found", ch is not None, f"got={ch}") + t.check("_find_channel_by_aid(invalid)=None", lib._find_channel_by_aid(99999) is None) + fp = lib._get_file_path_by_aid(aid) + t.check("_get_file_path_by_aid returns path", fp == WAV_LONG, f"got={fp}") + fp = lib._get_file_path_by_aid(99999) + t.check("_get_file_path_by_aid(invalid) -> 1002", isinstance(fp, tuple) and fp[0] == 1002, f"got={fp}") + ga = lib._get_aid_for_music(WAV_LONG) + t.check("_get_aid_for_music found", ga == aid, f"got={ga}") + lib.stop_audio(aid) + aid2 = lib.play_from_file(WAV_SHORT) + if isinstance(aid2, int): + ga = lib._get_aid_for_audio(WAV_SHORT) + t.check("_get_aid_for_audio found", ga == aid2, f"got={ga}") + ga = lib._get_aid_for_audio(WAV_LONG) + t.check("_get_aid_for_audio(music file) -> 1002", isinstance(ga, tuple) and ga[0] == 1002, f"got={ga}") + lib.stop_audio(aid2) + + +# ============================================================================ +# [L] Resource Management + [M] Top-Level API +# ============================================================================ +def test_resources(t, lib): + t.section("[L] Resource Management") + lib.play_from_file(WAV_LONG) + lib.play_from_file(WAV_SHORT) + lib.clear_memory_cache() + t.check("clear_memory_cache empties caches", lib._audio_cache == {} and lib._music_cache == {}) + lib.cleanup_function() + t.check("cleanup_function completes", True) + t.section("[M] Top-Level API") + info = get_runtime_info() + t.check("get_runtime_info is dict", isinstance(info, dict), f"got={type(info).__name__}") + fp = is_full_performance() + t.check("is_full_performance is bool", isinstance(fp, bool), f"got={fp}") + + +# ============================================================================ +# [O] Edge Cases & Error Handling +# ============================================================================ +def test_edge_cases(t): + t.section("[O] Edge Cases & Error Handling") + lib = AudioLibrary() + + # --- O1: invalid argument types -> error tuples, never crash --- + t.log(" --- O1 invalid argument types ---") + for bad in (None, 1.5, [], {}, ('a',)): + r = lib.play_from_file(bad) + ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001 + t.check(f"play_from_file({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}") + for bad in ([], {}): + r = lib.play_from_memory(bad) + ok = isinstance(r, tuple) and len(r) == 3 + t.check(f"play_from_memory({type(bad).__name__}) -> tuple", ok, f"got={r!r}") + for bad in (None, 1.5, []): + r = lib.new_aid(bad) + ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001 + t.check(f"new_aid({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}") + + # --- O2: seek / volume / fade boundary values --- + t.log(" --- O2 boundary values ---") + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + for pos in (None, '5'): + r = lib.seek_audio(aid, pos) + t.check(f"seek_audio({pos!r}) -> tuple", isinstance(r, tuple), f"got={r!r}") + r = lib.seek_audio(aid, 3.0) + t.check("seek_audio(3.0) -> success", isinstance(r, tuple) and r[0] == 0, f"got={r!r}") + for v in (None, '50', 1.5): + r = lib.set_volume(aid, v) + t.check(f"set_volume({v!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r!r}") + r = lib.set_volume(aid, 0) + t.check("set_volume(0) -> success", r[0] == 0, f"got={r}") + r = lib.set_volume(aid, 128) + t.check("set_volume(128) -> success", r[0] == 0, f"got={r}") + r = lib.set_volume(aid, -1) + t.check("set_volume(-1) -> 1015", r[0] == 1015, f"got={r}") + r = lib.set_volume(aid, 129) + t.check("set_volume(129) -> 1015", r[0] == 1015, f"got={r}") + r = lib.fadein_music(aid, ms=None) + t.check("fadein_music(ms=None) -> tuple", isinstance(r, tuple), f"got={r!r}") + r = lib.fadein_music_pos(aid, ms=100, position=None) + t.check("fadein_music_pos(position=None) -> tuple", isinstance(r, tuple), f"got={r!r}") + lib.stop_audio(aid) + + # --- O3: exact error codes --- + t.log(" --- O3 exact error codes ---") + _aidv = lib.play_from_file(WAV_LONG) + _valid = _aidv if isinstance(_aidv, int) else None + cases = { + '1001 missing file': (lambda: lib.play_from_file(os.path.join(TMP_DIR, 'no_such_dir.mp3')), 1001), + '1002 invalid AID': (lambda: lib.pause_audio(99999), 1002), + '1003 load failure (DAP file)': (lambda: lib.play_from_file(FAKE_DAP), 1003), + '1009 DAP bad extension': (lambda: lib.save_dap_to_json(os.path.join(TMP_DIR, 'x.json')), 1009), + '1010 DAP save failure': (lambda: lib.save_dap_to_json(r'Z:\no\such\dir\x.ap-ds-dap'), 1010), + '1011 metadata parse failure': (lambda: lib.get_audio_metadata_by_path(WAV_BAD), 1011), + '1013 not loaded in memory': (lambda: lib.play_from_memory(os.path.join(TMP_DIR, 'x.wav')), 1013), + '1014 invalid source type': (lambda: lib.get_audio_metadata(1.5), 1014), + '1015 invalid volume': (lambda: lib.set_volume(_valid if _valid is not None else 99999, 200), 1015), + } + for name, (fn, expect_code) in cases.items(): + r = fn() + ok = isinstance(r, tuple) and r[0] == expect_code + t.check(f"{name} -> {expect_code}", ok, f"got={r!r}") + if _valid is not None: + lib.stop_audio(_valid) + + # --- O4: valid return values --- + # --- O4: valid return values --- + t.log(" --- O4 valid return values ---") + r = lib.play_from_file(WAV_SHORT) + t.check("play_from_file(short WAV) -> int AID", isinstance(r, int), f"got={r!r}") + if isinstance(r, int): + lib.stop_audio(r) + r = lib.play_from_file(WAV_LONG) + if isinstance(r, int): + lib.stop_audio(r) + d = lib.get_audio_duration(WAV_LONG, is_file=True) + t.check("get_audio_duration(path) -> valid int>0", isinstance(d, int) and d > 0, f"got={d!r}") + m = lib.get_audio_metadata_by_path(WAV_SHORT) + t.check("get_audio_metadata_by_path -> dict complete", + isinstance(m, dict) and all(k in m for k in ('path', 'format', 'duration', 'length', 'sample_rate', 'channels', 'bitrate')), + f"got={m!r}") + lib.clear_dap_recordings() + lib._add_to_dap_recordings(WAV_SHORT) + r = lib.save_dap_to_json(os.path.join(TMP_DIR, 'edge.ap-ds-dap')) + t.check("save_dap_to_json -> (0,'','')", isinstance(r, tuple) and r[0] == 0, f"got={r!r}") + bl = batch_get_metadata([WAV_SHORT, WAV_LONG], max_workers=2) + t.check("batch_get_metadata -> valid list", + isinstance(bl, list) and all(isinstance(x, dict) for x in bl) and len(bl) == 2, f"got={bl!r}") + r = batch_get_metadata([]) + t.check("batch_get_metadata([]) -> []", r == [], f"got={r!r}") + r = batch_get_metadata(None) + t.check("batch_get_metadata(None) -> []", r == [], f"got={r!r}") + r = batch_get_metadata([WAV_SHORT], max_workers=0) + t.check("batch max_workers=0 returns error tuple", isinstance(r, tuple) and r[0] == 1999, f"got={r!r}") + r = batch_get_metadata([WAV_SHORT], max_workers=1000) + t.check("batch max_workers=1000 returns error tuple", isinstance(r, tuple) and r[0] == 1999, f"got={r!r}") + + lib.cleanup_function() + +# ============================================================================ +# [P] __init__.py Module Coverage +# ============================================================================ +def test_init_module(t): + t.section("[P] __init__.py Module Coverage") + import ap_ds as _m + + # Public API + t.check("__version__ = 4.0.1", _m.__version__ == "4.0.1", f"{_m.__version__}") + for fn_name in ('get_audio_duration', 'get_audio_metadata', 'batch_get_metadata', + 'batch_get_duration', 'batch_get_metadata_by_type', + 'is_full_performance', 'get_runtime_info', 'auto_check_runtime', + 'check_runtime_mode', 'show_tech_manual'): + t.check(f"top-level {fn_name} callable", callable(getattr(_m, fn_name, None))) + for name in ('__version__', 'AudioLibrary', 'get_audio_duration', 'get_audio_metadata', + 'batch_get_metadata', 'batch_get_duration', 'batch_get_metadata_by_type', + 'auto_check_runtime', 'check_runtime_mode', 'show_tech_manual'): + t.check(f"__all__ contains {name}", name in _m.__all__) + + # show_tech_manual output + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _m.show_tech_manual() + out = buf.getvalue() + t.check("show_tech_manual output >1000 chars", len(out) > 1000, f"{len(out)}") + for key in ('TECHNICAL MANUAL', 'AudioLibrary', 'AP_DS_WAV_THRESHOLD', 'DAP', 'SDL2'): + t.check(f"manual contains '{key}'", key in out) + + # Import-time auto execution + env-var control + t.check("_RUNTIME_CHECKED=True after import", _m._RUNTIME_CHECKED is True) + _exp_sup = os.environ.get('AP_DS_SUPPRESS_WARNINGS', '').lower() in ('1', 'true', 'yes', 'on') + t.check("SUPPRESS_WARNINGS matches env", _m.SUPPRESS_WARNINGS == _exp_sup, + f"module={_m.SUPPRESS_WARNINGS} env={_exp_sup}") + _exp_con = os.environ.get('AP_DS_SHOW_CONGRATS', '').lower() not in ('0', 'false', 'no', 'off') + t.check("SHOW_CONGRATS matches env", _m.SHOW_CONGRATS == _exp_con, + f"module={_m.SHOW_CONGRATS} env={_exp_con}") + _exp_skip = os.environ.get('AP_DS_SKIP_AUTO_CHECK', '1').lower() in ('1', 'true', 'yes', 'on') + t.check("_AUTO_CHECK_SKIP matches env", _m._AUTO_CHECK_SKIP == _exp_skip, + f"module={_m._AUTO_CHECK_SKIP} env={_exp_skip}") + + # ensure_runtime_checked + _saved_rc = _m._RUNTIME_CHECKED + _m._RUNTIME_CHECKED = False + _m.ensure_runtime_checked() + t.check("ensure_runtime_checked sets True", _m._RUNTIME_CHECKED is True) + _m._RUNTIME_CHECKED = False + _m.ensure_runtime_checked() + t.check("ensure_runtime_checked idempotent", _m._RUNTIME_CHECKED is True) + _m._RUNTIME_CHECKED = _saved_rc + + # check_runtime_mode + r = _m.check_runtime_mode() + t.check("check_runtime_mode returns bool", isinstance(r, bool), f"{r}") + r2 = _m._check_runtime_mode() + t.check("_check_runtime_mode returns bool", isinstance(r2, bool), f"{r2}") + t.check("check_runtime_mode == _check_runtime_mode", r == r2) + + # SKIP mode (default) + t.check("auto_check_runtime(SKIP) returns None", _m.auto_check_runtime() is None) + t.check("_auto_check_runtime(SKIP) returns None", _m._auto_check_runtime() is None) + t.check("get_runtime_info(SKIP) returns {}", _m.get_runtime_info() == {}) + t.check("is_full_performance(SKIP) returns False", _m.is_full_performance() is False) + + # Simulate AP_DS_SKIP_AUTO_CHECK=0 + _m._AUTO_CHECK_SKIP = False + try: + buf2 = io.StringIO() + with contextlib.redirect_stdout(buf2): + info = _m._auto_check_runtime() + out2 = buf2.getvalue() + t.check("_auto_check_runtime(SKIP=0) returns dict", isinstance(info, dict)) + expect_keys = ('library_name', 'library_version', 'library_install_path', + 'library_website', 'library_author', 'python_version', + 'gil_enabled', 'has_profiling', 'is_full_performance', + 'cpu_count', 'platform') + if isinstance(info, dict): + missing = [k for k in expect_keys if k not in info] + t.check("auto dict 11 keys complete", not missing, f"missing={missing}") + t.check("info[library_name]=AP_DS", info.get('library_name') == 'AP_DS') + t.check("info[library_version]=4.0.1", info.get('library_version') == '4.0.1') + t.check("info[gil_enabled] is bool", isinstance(info.get('gil_enabled'), bool)) + t.check("info[has_profiling] is bool", isinstance(info.get('has_profiling'), bool)) + t.check("info[is_full_performance] is bool", isinstance(info.get('is_full_performance'), bool)) + t.check("info[cpu_count]>0", info.get('cpu_count', 0) > 0) + t.check("info[platform]=win32", info.get('platform') == sys.platform) + t.check("self-check prints 'Runtime Self-Check'", "Runtime Self-Check" in out2) + t.check("self-check prints 'Python'", "Python" in out2) + t.check("self-check prints 'GIL'", "GIL" in out2) + t.check("self-check prints 'CPU Cores'", "CPU Cores" in out2) + gi = _m.get_runtime_info() + t.check("get_runtime_info(SKIP=0) returns dict", isinstance(gi, dict)) + if isinstance(gi, dict): + t.check("get_runtime_info keys complete", all(k in gi for k in expect_keys), f"keys={list(gi.keys())}") + t.check("get_runtime_info[library_name]=AP_DS", gi.get('library_name') == 'AP_DS') + t.check("get_runtime_info same source as auto", gi.get('library_version') == info.get('library_version')) + fp = _m.is_full_performance() + t.check("is_full_performance(SKIP=0) is bool", isinstance(fp, bool)) + t.check("is_full_performance matches info", fp == info.get('is_full_performance')) + finally: + _m._AUTO_CHECK_SKIP = True + + # Restored default behaviour + t.check("after restore auto_check_runtime returns None", _m.auto_check_runtime() is None) + t.check("after restore get_runtime_info returns {}", _m.get_runtime_info() == {}) + + +# ============================================================================ +# [Q] _sdl2.py Constants / Structures / Bindings +# ============================================================================ +def test_sdl2_bindings(t): + t.section("[Q] _sdl2.py Constants / Structures / Bindings") + import ap_ds._sdl2 as s + consts = { + 'SDL_TRUE': 1, 'SDL_FALSE': 0, + 'SDL_INIT_TIMER': 1, 'SDL_INIT_AUDIO': 0x10, 'SDL_INIT_VIDEO': 0x20, + 'SDL_INIT_JOYSTICK': 0x200, 'SDL_INIT_HAPTIC': 0x1000, + 'SDL_INIT_GAMECONTROLLER': 0x2000, 'SDL_INIT_EVENTS': 0x4000, + 'AUDIO_U8': 0x8, 'AUDIO_S8': 0x8008, 'AUDIO_U16LSB': 0x10, + 'AUDIO_S16LSB': 0x8010, 'AUDIO_U16MSB': 0x1010, 'AUDIO_S16MSB': 0x9010, + 'AUDIO_S32LSB': 0x8020, 'AUDIO_S32MSB': 0x9020, + 'AUDIO_F32LSB': 0x8120, 'AUDIO_F32MSB': 0x9120, + 'MIX_INIT_FLAC': 1, 'MIX_INIT_MOD': 2, 'MIX_INIT_MP3': 8, 'MIX_INIT_OGG': 0x10, + 'MIX_INIT_MID': 0x20, 'MIX_INIT_OPUS': 0x40, + 'MIX_CHANNEL_POST': -2, 'MIX_DEFAULT_CHANNELS': 2, + 'MUS_NONE': 0, 'MUS_CMD': 1, 'MUS_WAV': 2, 'MUS_MOD': 3, 'MUS_MID': 4, + 'MUS_OGG': 5, 'MUS_MP3': 6, 'MUS_FLAC': 7, 'MUS_OPUS': 8, + } + for name, val in consts.items(): + t.check(f"constant {name}={val}", getattr(s, name, None) == val, f"got={getattr(s, name, None)}") + t.check("MIX_DEFAULT_FORMAT==AUDIO_S16SYS", s.MIX_DEFAULT_FORMAT == s.AUDIO_S16SYS) + t.check("SDL_INIT_EVERYTHING combination", s.SDL_INIT_EVERYTHING == 0x00007231) + t.check("AUDIO_S16SYS is S16LSB on little-endian", s.AUDIO_S16SYS == s.AUDIO_S16LSB) + spec_fields = dict(s.SDL_AudioSpec._fields_) + for fn in ('freq', 'format', 'channels', 'silence', 'samples', 'padding', 'size', 'callback', 'userdata'): + t.check(f"SDL_AudioSpec.{fn} field", fn in spec_fields) + chunk_fields = dict(s.Mix_Chunk._fields_) + for fn in ('allocated', 'abuf', 'alen', 'volume'): + t.check(f"Mix_Chunk.{fn} field", fn in chunk_fields) + t.check("_sdl_lib loaded", s._sdl_lib is not None) + t.check("_mix_lib loaded", s._mix_lib is not None) + r = s.import_sdl2() + t.check("import_sdl2 returns 2-tuple", isinstance(r, tuple) and len(r) == 2) + t.check("_check_sdl2_loaded() True", s._check_sdl2_loaded() is True) + pkg = os.path.dirname(s.__file__) + t.check("_check_sdl_libraries_exist(package dir)", s._check_sdl_libraries_exist(pkg) is True) + for fn in ('SDL_Init', 'SDL_Quit', 'SDL_GetError', 'SDL_RWFromFile', 'SDL_Delay', + 'Mix_OpenAudio', 'Mix_CloseAudio', 'Mix_LoadWAV', 'Mix_LoadMUS', + 'Mix_FreeChunk', 'Mix_FreeMusic', 'Mix_PlayChannel', 'Mix_PlayMusic', + 'Mix_Pause', 'Mix_PauseMusic', 'Mix_Resume', 'Mix_ResumeMusic', + 'Mix_HaltChannel', 'Mix_HaltMusic', 'Mix_SetMusicPosition', + 'Mix_MusicDuration', 'Mix_Volume', 'Mix_VolumeMusic', 'Mix_AllocateChannels', + 'Mix_GetMusicType', 'Mix_FadingMusic', 'Mix_FadeInMusic', 'Mix_FadeOutMusic', + 'Mix_FadeInChannel', 'Mix_FadeOutChannel', 'Mix_Playing', 'Mix_PlayingMusic', + 'Mix_Paused', 'Mix_PausedMusic', 'Mix_SetPanning', 'Mix_SetDistance', + 'Mix_SetPosition', 'Mix_SetReverseStereo', 'Mix_FadeInMusicPos', + '_load_from_directory', '_load_from_system', '_load_user_config', + '_linux_auto_install', '_linux_interactive_setup', '_check_sdl_libraries_exist', + '_check_sdl2_loaded', 'import_sdl2', '_setup_prototypes'): + t.check(f"binding {fn} exists", callable(getattr(s, fn, None))) + t.check("SDL_Init.argtypes=[c_uint32]", list(s._sdl_lib.SDL_Init.argtypes) == [s.c_uint32]) + t.check("SDL_GetError.restype=c_char_p", s._sdl_lib.SDL_GetError.restype == s.c_char_p) + t.check("Mix_OpenAudio.argtypes set", list(s._mix_lib.Mix_OpenAudio.argtypes) == [s.c_int, s.c_uint16, s.c_int, s.c_int]) + + +# ============================================================================ +# [R] audio_parser.py Deep Parser Coverage +# ============================================================================ +def test_parser_deep(t): + t.section("[R] audio_parser.py Deep Parser Coverage") + import ap_ds.audio_parser as ap + si = ap.StreamInfo(10.5, 44100, 2, 128000) + t.check("StreamInfo.length=10.5", si.length == 10.5) + t.check("StreamInfo.sample_rate=44100", si.sample_rate == 44100) + t.check("StreamInfo.channels=2", si.channels == 2) + t.check("StreamInfo.bitrate=128000", si.bitrate == 128000) + t.check("StreamInfo repr contains StreamInfo", "StreamInfo" in repr(si)) + try: + ap.FileType(os.path.join(TMP_DIR, 'x.wav')) + t.check("FileType._parse raises ValueError", False) + except ValueError: + t.check("FileType._parse raises ValueError", True) + t.check("read_u32_be(0x0100)", ap.read_u32_be(io.BytesIO(b'\x00\x00\x01\x00')) == 256) + t.check("read_u32_le(0x0100)", ap.read_u32_le(io.BytesIO(b'\x00\x01\x00\x00')) == 256) + t.check("read_u16_le(0x0100)", ap.read_u16_le(io.BytesIO(b'\x00\x01')) == 256) + t.check("open_audio(.wav) -> WAVFile", isinstance(ap.open_audio(WAV_SHORT), ap.WAVFile)) + if MP3_FILE and os.path.exists(MP3_FILE): + t.check("open_audio(.mp3) -> MP3File", isinstance(ap.open_audio(MP3_FILE), ap.MP3File)) + else: + t.skip("open_audio(.mp3)", "no MP3 file provided") + for bad in ('.txt', '.xyz', '.flac_bad'): + try: + ap.open_audio(os.path.join(TMP_DIR, 'x' + bad)) + t.check(f"open_audio({bad}) raises ValueError", False) + except ValueError: + t.check(f"open_audio({bad}) raises ValueError", True) + _st = make_wav(os.path.join(TMP_DIR, 'r_stereo.wav'), 2, 22050, 2) + for name, path, exp_d, exp_sr, exp_ch in ( + ('16bit mono', WAV_SHORT, 2, 22050, 1), + ('16bit stereo', _st, 2, 22050, 2), + ('10s long', WAV_LONG, 10, 22050, 1), + ): + m = ap.get_audio_metadata(path) + ok = (isinstance(m, dict) and m['duration'] == exp_d and m['sample_rate'] == exp_sr + and m['channels'] == exp_ch) + t.check(f"metadata {name} ({exp_d}s/{exp_sr}/{exp_ch}ch)", ok, f"got={m}") + flac_path = os.path.join(TMP_DIR, 'r_test.flac') + _make_flac(flac_path, seconds=10, sr=44100, ch=2) + m = ap.get_audio_metadata(flac_path) + ok = (isinstance(m, dict) and m['duration'] == 10 and m['sample_rate'] == 44100 + and m['channels'] == 2 and m['format'] == 'flac') + t.check("Constructed FLAC parses (10s/44100/2ch)", ok, f"got={m}") + for ext in ('.flac', '.aac', '.ogg', '.mp3'): + empty = os.path.join(TMP_DIR, 'empty' + ext) + with io.open(empty, 'wb'): + pass + d = ap.get_audio_duration(empty) + t.check(f"empty {ext} -> 0", d == 0, f"got={d}") + bl = batch_get_metadata_by_type([WAV_SHORT, WAV_LONG, flac_path], 'flac', max_workers=2) + t.check("by_type filters flac -> 1", len(bl) == 1 and bl[0]['format'] == 'flac', f"got={len(bl)}") + bl2 = batch_get_metadata([WAV_SHORT, WAV_LONG, flac_path], max_workers=2) + t.check("batch mixed formats -> 3", len(bl2) == 3, f"got={len(bl2)}") + + +# ============================================================================ +# [S] Supplementary Cases +# ============================================================================ +def test_supplementary(t): + t.section("[S] Supplementary Cases") + + # 1. AudioLibrary initialization boundary parameters + t.log(" --- 1. init boundary parameters ---") + for label, kwargs in ( + ('frequency=0', {'frequency': 0}), + ('frequency=-44100', {'frequency': -44100}), + ('channels=0', {'channels': 0}), + ('channels=9', {'channels': 9}), + ('channels=32', {'channels': 32}), + ('chunksize=0', {'chunksize': 0}), + ('chunksize=-1', {'chunksize': -1}), + ('format=0', {'format': 0}), + ('format=0xFFFF', {'format': 0xFFFF}), + ): + try: + l = AudioLibrary(**kwargs) + l.cleanup_function() + t.check(f"init({label}) accepted", True) + except RuntimeError: + t.check(f"init({label}) rejected by SDL", True) + except Exception as e: + t.check(f"init({label}) no unexpected exception", False, f"{type(e).__name__}: {e}") + + lib = AudioLibrary() + + # 2. loops parameter (play_from_file / play_from_memory) + t.log(" --- 2. loops parameter ---") + aid = lib.play_from_file(WAV_LONG, loops=-1) + t.check("play_from_file(loops=-1) returns AID", isinstance(aid, int), f"got={aid!r}") + if isinstance(aid, int): + time.sleep(0.2) + t.check("loops=-1 still playing", lib.is_music_playing() is True) + lib.stop_audio(aid) + aid = lib.play_from_file(WAV_LONG, loops=2) + t.check("play_from_file(loops=2) returns AID", isinstance(aid, int), f"got={aid!r}") + if isinstance(aid, int): + lib.stop_audio(aid) + aid = lib.play_from_file(WAV_SHORT, loops=-1) + t.check("play_from_file(short WAV, loops=-1) returns AID", isinstance(aid, int), f"got={aid!r}") + if isinstance(aid, int): + lib.stop_audio(aid) + for bad in ('2', None): + try: + r = lib.play_from_file(WAV_LONG, loops=bad) + ok = isinstance(r, (int, tuple)) + t.check(f"play_from_file(loops={bad!r}) no crash", ok, f"got={r!r}") + except Exception as e: + t.check(f"play_from_file(loops={bad!r}) no crash", False, f"{type(e).__name__}: {e}") + aidn = lib.new_aid(WAV_LONG) + if isinstance(aidn, int): + r = lib.play_from_memory(WAV_LONG, loops=-1) + t.check("play_from_memory(loops=-1) returns AID", isinstance(r, int), f"got={r!r}") + if isinstance(r, int): + lib.stop_audio(r) + + # 3. batch show_progress output + t.log(" --- 3. batch show_progress output ---") + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + bl = batch_get_metadata([WAV_SHORT, WAV_LONG], max_workers=2, show_progress=True) + out = buf.getvalue() + t.check("show_progress returns list(2)", isinstance(bl, list) and len(bl) == 2, f"got={len(bl)}") + t.check("show_progress prints 'Batch parse complete'", "Batch parse complete" in out, out[-80:]) + + # 4. Delay method + t.log(" --- 4. Delay method ---") + t0 = time.time() + lib.Delay(100) + dt = time.time() - t0 + t.check("Delay(100) ~100ms", 0.08 <= dt < 0.5, f"{dt:.3f}s") + t0 = time.time() + lib.Delay(0) + t.check("Delay(0) fast", (time.time() - t0) < 0.1) + + # 5. FileType base class via a WAVFile instance + t.log(" --- 5. FileType base class ---") + import ap_ds.audio_parser as ap + f = ap.WAVFile(WAV_LONG) + t.check("FileType.filename", f.filename == WAV_LONG) + t.check("FileType.info is StreamInfo", isinstance(f.info, ap.StreamInfo)) + t.check("FileType.length property", isinstance(f.length, float) and f.length == 10.0, f"{f.length}") + t.check("FileType.sample_rate property", f.sample_rate == 22050, f"{f.sample_rate}") + t.check("FileType.channels property", f.channels == 1, f"{f.channels}") + t.check("FileType.bitrate property", isinstance(f.bitrate, int) and f.bitrate > 0, f"{f.bitrate}") + + # 6. _get_sample_rate / _get_channels + t.log(" --- 6. _get_sample_rate / _get_channels ---") + aid = lib.play_from_file(WAV_LONG) + if isinstance(aid, int): + t.check("_get_sample_rate(AID)=22050", lib._get_sample_rate(aid) == 22050, f"{lib._get_sample_rate(aid)}") + t.check("_get_channels(AID)=1", lib._get_channels(aid) == 1, f"{lib._get_channels(aid)}") + lib.stop_audio(aid) + t.check("_get_sample_rate(invalid)=44100 fallback", lib._get_sample_rate(None) == 44100) + t.check("_get_channels(invalid)=2 fallback", lib._get_channels(None) == 2) + + # 7. _get_aid_for_audio / _get_aid_for_music success paths + t.log(" --- 7. _get_aid_for_* success paths ---") + aid_m = lib.play_from_file(WAV_LONG) + aid_s = lib.play_from_file(WAV_SHORT) + if isinstance(aid_m, int) and isinstance(aid_s, int): + r = lib._get_aid_for_music(WAV_LONG) + t.check("_get_aid_for_music success", r == aid_m, f"got={r}") + r = lib._get_aid_for_audio(WAV_SHORT) + t.check("_get_aid_for_audio success", r == aid_s, f"got={r}") + r = lib._get_aid_for_music(WAV_SHORT) + t.check("_get_aid_for_music(sound file) -> 1002", isinstance(r, tuple) and r[0] == 1002, f"got={r}") + r = lib._get_aid_for_audio(WAV_LONG) + t.check("_get_aid_for_audio(music file) -> 1002", isinstance(r, tuple) and r[0] == 1002, f"got={r}") + lib.stop_audio(aid_m) + lib.stop_audio(aid_s) + + lib.cleanup_function() + + +# ============================================================================ +# [N] Listening Tests (interactive) +# ============================================================================ +def test_listen(t): + t.section("[N] Listening Tests (please put on headphones / turn on speakers)") + if not MP3_FILE or not os.path.exists(MP3_FILE): + t.skip("Listening tests", "no MP3 file provided") + return + + def ask(q): + while True: + try: + ans = input(f" >>> {q} (y/n): ").strip().lower() + except EOFError: + print(" ! Cannot interact; skipping") + return True + if ans in ('y', 'n'): + return ans == 'y' + print(" Please enter y or n") + + lib = AudioLibrary() + try: + print("\n Now playing at default volume 128 for 3 seconds...") + aid = lib.play_from_file(MP3_FILE) + time.sleep(3) + ok = ask("Did you hear clear, normal-volume music?") + t.check("Listening 1: normal playback audible", ok) + lib.stop_audio(aid) + + print("\n Volume set to 40/128, playing for 2 seconds...") + aid = lib.play_from_file(MP3_FILE) + lib.set_volume(aid, 40) + time.sleep(2) + ok = ask("Was the volume clearly lower (very quiet)?") + t.check("Listening 2: volume lowered", ok) + + print("\n Volume restored to 128, playing for 1 second...") + lib.set_volume(aid, 128) + time.sleep(1) + ok = ask("Did the volume return to loud?") + t.check("Listening 3: volume restored", ok) + + print("\n Pausing for 2 seconds (should be silent)...") + lib.pause_audio(aid) + time.sleep(2) + ok = ask("Was it completely silent while paused?") + t.check("Listening 4: pause is silent", ok) + + print("\n Resuming for 2 seconds...") + lib.play_audio(aid) + time.sleep(2) + ok = ask("Did playback resume from where it paused?") + t.check("Listening 5: resume works", ok) + + print("\n Fade-in test: fadein(2000ms), playing for 3 seconds...") + lib.fadein_music(aid, ms=2000) + time.sleep(3) + ok = ask("Did the sound fade in from silence to full volume?") + t.check("Listening 6: fade-in effect", ok) + + print("\n Fade-out test: fadeout(2000ms), waiting 3 seconds...") + lib.fadeout_music(2000) + time.sleep(3) + ok = ask("Did the sound fade out gradually to silence?") + t.check("Listening 7: fade-out effect", ok) + + print("\n Seek test: jump to 90s, playing for 2 seconds...") + lib.fadein_music(aid, ms=0) + time.sleep(0.3) + lib.seek_audio(aid, 90.0) + time.sleep(2) + ok = ask("Did you hear the middle of the song (not the beginning)?") + t.check("Listening 8: seek positioning", ok) + + lib.stop_audio(aid) + finally: + lib.cleanup_function() + print("\n Listening tests finished") + + +# ============================================================================ +# Main +# ============================================================================ +def main(): + global MP3_FILE + mode = sys.argv[1].lower().lstrip('-') if len(sys.argv) > 1 else 'full' + if mode not in ('auto', 'listen', 'full'): + print(f"Unknown mode: {mode}, using full") + mode = 'full' + MP3_FILE = _prompt_mp3() + + print(f"\n Python: {sys.version.split()[0]} | Platform: {sys.platform} | Mode: {mode}") + print(f" ap_ds version: {ap_ds.__version__} | Path: {os.path.dirname(ap_ds.__file__)}") + print(f" MP3 test file: {MP3_FILE} (exists={bool(MP3_FILE) and os.path.exists(MP3_FILE)})") + + t = CICD() + + if mode in ('auto', 'full'): + test_imports(t) + test_metadata(t) + test_init(t) + lib = AudioLibrary() + test_play(t, lib) + test_control(t, lib) + test_volume(t, lib) + test_seek(t, lib) + test_fade(t, lib) + test_dap(t, lib) + test_metadata_methods(t, lib) + test_helpers(t, lib) + test_resources(t, lib) + test_edge_cases(t) + test_init_module(t) + test_sdl2_bindings(t) + test_parser_deep(t) + test_supplementary(t) + + if mode in ('listen', 'full'): + test_listen(t) + + t.summary() + return 0 if t.failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ap_ds/Test/GUI_TEST.py b/ap_ds/Test/GUI_TEST.py new file mode 100644 index 0000000..6be1fdf --- /dev/null +++ b/ap_ds/Test/GUI_TEST.py @@ -0,0 +1,1615 @@ + +import sys +import os +import random +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QPushButton, QLabel, QSlider, + QFileDialog, QMessageBox, QStyle, QListWidget, + QListWidgetItem, QGroupBox, QMenu, QAction, + QComboBox, QProgressBar, QSplitter, QToolBar, + QStatusBar, QSystemTrayIcon) +from PyQt5.QtCore import Qt, QTimer, QTime, pyqtSignal, QSize, QEvent +from PyQt5.QtGui import QIcon, QFont, QPalette, QColor, QPixmap, QCursor +from player import AudioLibrary +import time + +class ModernAudioPlayer(QMainWindow): + # 自定义信号 + position_changed = pyqtSignal(float) + state_changed = pyqtSignal(str) + volume_changed = pyqtSignal(int) + + # 播放模式常量 + PLAY_MODE_SEQUENTIAL = 0 # 顺序播放 + PLAY_MODE_SHUFFLE = 1 # 随机播放 + PLAY_MODE_SINGLE = 2 # 单曲循环 + PLAY_MODE_LOOP = 3 # 列表循环 + + def __init__(self): + super().__init__() + + # 初始化音频库 + try: + self.audio_lib = AudioLibrary() + print("音频库初始化成功") + except Exception as e: + QMessageBox.critical(self, "错误", f"音频库初始化失败: {e}") + self.audio_lib = None + return + + # 初始化变量 + self.current_aid = None + self.current_file = None + self.current_file_name = "" + self.playlist = [] + self.current_index = -1 + self.is_playing = False + self.is_paused = False + self.total_duration = 0 + self.playback_start_time = 0 + self.current_position = 0 + self.volume = 80 + self.is_muted = False + self.last_volume = 80 + + # 播放模式相关 + self.play_mode = self.PLAY_MODE_SEQUENTIAL + self.shuffled_indices = [] # 随机播放时的播放顺序 + self.is_shuffle_active = False + + # 更新定时器 + self.update_timer = QTimer() + + # 初始化UI + self.init_ui() + + # 连接信号和槽 + self.setup_connections() + + # 启动更新定时器 + self.update_timer.start(100) # 100ms更新一次 + + # 初始化系统托盘 + self.init_system_tray() + + def init_ui(self): + """初始化UI""" + self.setWindowTitle("Modern Audio Player - Advanced") + self.setGeometry(100, 100, 1000, 700) + + # 设置应用程序图标 + self.setWindowIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + + # 设置深色主题 + self.set_dark_theme() + + # 创建中央部件 + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # 主布局 + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(15, 15, 15, 15) + main_layout.setSpacing(10) + + # 1. 顶部工具栏 + self.create_toolbar() + + # 2. 使用分割器创建左右布局 + splitter = QSplitter(Qt.Horizontal) + + # 左侧:播放控制和信息面板 + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + + # 当前播放信息 + self.create_current_info_panel(left_layout) + + # 播放进度控制 + self.create_progress_panel(left_layout) + + # 播放控制按钮 + self.create_control_panel(left_layout) + + # 音量控制 + self.create_volume_panel(left_layout) + + # 播放模式选择 + self.create_playmode_panel(left_layout) + + # 右侧:播放列表 + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(5) + + # 播放列表标题和操作按钮 + playlist_header = QWidget() + playlist_header_layout = QHBoxLayout(playlist_header) + playlist_header_layout.setContentsMargins(0, 0, 0, 0) + + playlist_label = QLabel("🎵 播放列表") + playlist_label.setFont(QFont("Arial", 12, QFont.Bold)) + playlist_label.setStyleSheet("color: #ecf0f1; padding: 5px;") + + # 播放列表操作按钮 + playlist_actions = QWidget() + playlist_actions_layout = QHBoxLayout(playlist_actions) + playlist_actions_layout.setContentsMargins(0, 0, 0, 0) + + self.playlist_clear_button = QPushButton("清空") + self.playlist_remove_button = QPushButton("移除") + self.playlist_save_button = QPushButton("保存") + self.playlist_load_button = QPushButton("加载") + + for btn in [self.playlist_clear_button, self.playlist_remove_button, + self.playlist_save_button, self.playlist_load_button]: + btn.setFixedSize(60, 25) + btn.setFont(QFont("Arial", 8)) + + playlist_actions_layout.addWidget(self.playlist_clear_button) + playlist_actions_layout.addWidget(self.playlist_remove_button) + playlist_actions_layout.addWidget(self.playlist_save_button) + playlist_actions_layout.addWidget(self.playlist_load_button) + playlist_actions_layout.addStretch() + + playlist_header_layout.addWidget(playlist_label) + playlist_header_layout.addWidget(playlist_actions) + + right_layout.addWidget(playlist_header) + + # 播放列表 + self.playlist_widget = QListWidget() + self.playlist_widget.setStyleSheet(""" + QListWidget { + background-color: #2c3e50; + border: 2px solid #34495e; + border-radius: 8px; + padding: 5px; + color: #ecf0f1; + font-size: 11px; + } + QListWidget::item { + padding: 8px; + border-bottom: 1px solid #34495e; + border-radius: 4px; + margin: 2px; + } + QListWidget::item:selected { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, + stop: 0 #3498db, stop: 1 #2980b9 + ); + color: white; + border: 1px solid #2980b9; + } + QListWidget::item:hover { + background-color: #34495e; + border: 1px solid #7f8c8d; + } + QListWidget::item:alternate { + background-color: #2d3e50; + } + """) + self.playlist_widget.setAlternatingRowColors(True) + right_layout.addWidget(self.playlist_widget) + + # 播放列表信息 + self.playlist_info_label = QLabel("共 0 首歌曲 | 总时长: 00:00") + self.playlist_info_label.setFont(QFont("Arial", 9)) + self.playlist_info_label.setStyleSheet("color: #7f8c8d; padding: 5px;") + self.playlist_info_label.setAlignment(Qt.AlignCenter) + right_layout.addWidget(self.playlist_info_label) + + # 设置分割器 + splitter.addWidget(left_widget) + splitter.addWidget(right_widget) + splitter.setSizes([400, 600]) + + main_layout.addWidget(splitter) + + # 3. 底部状态栏 + self.create_status_bar() + + # 设置按钮样式 + self.set_button_styles() + + # 初始化菜单 + self.create_context_menus() + + def create_toolbar(self): + """创建工具栏""" + toolbar = QToolBar() + toolbar.setIconSize(QSize(24, 24)) + toolbar.setMovable(False) + self.addToolBar(toolbar) + + # 文件操作 + self.open_file_action = QAction( + self.style().standardIcon(QStyle.SP_DirOpenIcon), + "打开文件", self + ) + self.open_folder_action = QAction( + self.style().standardIcon(QStyle.SP_DirIcon), + "打开文件夹", self + ) + + toolbar.addAction(self.open_file_action) + toolbar.addAction(self.open_folder_action) + toolbar.addSeparator() + + # 播放控制 + self.play_action = QAction( + self.style().standardIcon(QStyle.SP_MediaPlay), + "播放", self + ) + self.pause_action = QAction( + self.style().standardIcon(QStyle.SP_MediaPause), + "暂停", self + ) + self.stop_action = QAction( + self.style().standardIcon(QStyle.SP_MediaStop), + "停止", self + ) + self.prev_action = QAction( + self.style().standardIcon(QStyle.SP_MediaSkipBackward), + "上一首", self + ) + self.next_action = QAction( + self.style().standardIcon(QStyle.SP_MediaSkipForward), + "下一首", self + ) + + toolbar.addAction(self.play_action) + toolbar.addAction(self.pause_action) + toolbar.addAction(self.stop_action) + toolbar.addAction(self.prev_action) + toolbar.addAction(self.next_action) + toolbar.addSeparator() + + # 播放模式 + self.playmode_combo = QComboBox() + self.playmode_combo.addItem("顺序播放", self.PLAY_MODE_SEQUENTIAL) + self.playmode_combo.addItem("随机播放", self.PLAY_MODE_SHUFFLE) + self.playmode_combo.addItem("单曲循环", self.PLAY_MODE_SINGLE) + self.playmode_combo.addItem("列表循环", self.PLAY_MODE_LOOP) + self.playmode_combo.setFixedWidth(120) + toolbar.addWidget(self.playmode_combo) + + def create_current_info_panel(self, layout): + """创建当前播放信息面板""" + info_group = QGroupBox("当前播放") + info_group.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #3498db; + border-radius: 10px; + margin-top: 5px; + padding-top: 15px; + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, + stop: 0 #2c3e50, stop: 1 #34495e + ); + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #3498db; + font-size: 12px; + } + """) + + info_layout = QVBoxLayout() + info_layout.setSpacing(8) + + # 专辑封面(占位符) + album_frame = QWidget() + album_frame.setFixedHeight(150) + album_frame.setStyleSheet(""" + background-color: #2c3e50; + border: 2px dashed #3498db; + border-radius: 8px; + """) + album_layout = QVBoxLayout(album_frame) + album_layout.setAlignment(Qt.AlignCenter) + + album_icon = QLabel("🎵") + album_icon.setFont(QFont("Arial", 48)) + album_icon.setAlignment(Qt.AlignCenter) + album_layout.addWidget(album_icon) + + info_layout.addWidget(album_frame) + + # 歌曲信息 + self.current_file_label = QLabel("未选择歌曲") + self.current_file_label.setFont(QFont("Arial", 11, QFont.Bold)) + self.current_file_label.setStyleSheet("color: #ecf0f1; padding: 5px;") + self.current_file_label.setAlignment(Qt.AlignCenter) + self.current_file_label.setWordWrap(True) + + self.song_info_label = QLabel("艺术家: 未知 | 专辑: 未知") + self.song_info_label.setFont(QFont("Arial", 9)) + self.song_info_label.setStyleSheet("color: #bdc3c7; padding: 3px;") + self.song_info_label.setAlignment(Qt.AlignCenter) + + info_layout.addWidget(self.current_file_label) + info_layout.addWidget(self.song_info_label) + + info_group.setLayout(info_layout) + layout.addWidget(info_group) + + def create_progress_panel(self, layout): + """创建进度控制面板""" + progress_group = QGroupBox("播放进度") + progress_group.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #2ecc71; + border-radius: 10px; + margin-top: 5px; + padding-top: 15px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #2ecc71; + font-size: 12px; + } + """) + + progress_layout = QVBoxLayout() + progress_layout.setSpacing(8) + + # 时间显示 + time_layout = QHBoxLayout() + + self.current_time_label = QLabel("00:00") + self.current_time_label.setFont(QFont("Arial", 11)) + self.current_time_label.setStyleSheet("color: #ecf0f1;") + + self.total_time_label = QLabel("00:00") + self.total_time_label.setFont(QFont("Arial", 11)) + self.total_time_label.setStyleSheet("color: #ecf0f1;") + self.total_time_label.setAlignment(Qt.AlignRight) + + time_layout.addWidget(self.current_time_label) + time_layout.addStretch() + time_layout.addWidget(self.total_time_label) + progress_layout.addLayout(time_layout) + + # 进度条 + self.position_slider = QSlider(Qt.Horizontal) + self.position_slider.setRange(0, 1000) + self.position_slider.setStyleSheet(""" + QSlider::groove:horizontal { + border: 1px solid #27ae60; + background: #2c3e50; + height: 8px; + border-radius: 4px; + } + QSlider::sub-page:horizontal { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, + stop: 0 #2ecc71, stop: 1 #27ae60 + ); + border: 1px solid #27ae60; + height: 8px; + border-radius: 4px; + } + QSlider::add-page:horizontal { + background: #34495e; + border: 1px solid #27ae60; + height: 8px; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 1, + stop: 0 #ecf0f1, stop: 1 #bdc3c7 + ); + width: 18px; + height: 18px; + margin: -5px 0; + border-radius: 9px; + border: 1px solid #7f8c8d; + } + QSlider::handle:horizontal:hover { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 1, + stop: 0 #ffffff, stop: 1 #ecf0f1 + ); + border: 2px solid #2ecc71; + } + """) + progress_layout.addWidget(self.position_slider) + progress_group.setLayout(progress_layout) + layout.addWidget(progress_group) + + def create_control_panel(self, layout): + """创建控制按钮面板""" + control_group = QGroupBox("播放控制") + control_group.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #e74c3c; + border-radius: 10px; + margin-top: 5px; + padding-top: 15px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #e74c3c; + font-size: 12px; + } + """) + + control_layout = QHBoxLayout() + control_layout.setSpacing(15) + + # 创建控制按钮 + buttons = [ + ("⏮", "上一首", self.play_previous, 50, 50), + ("⏪", "快退10秒", lambda: self.seek_relative(-10), 45, 45), + ("▶", "播放", self.play_pause, 60, 60), + ("⏩", "快进10秒", lambda: self.seek_relative(10), 45, 45), + ("⏭", "下一首", self.play_next, 50, 50), + ("⏹", "停止", self.stop, 45, 45), + ] + + for icon, tooltip, slot, w, h in buttons: + btn = QPushButton(icon) + btn.setFont(QFont("Arial", 14)) + btn.setFixedSize(w, h) + btn.setToolTip(tooltip) + btn.clicked.connect(slot) + + # 为播放按钮特殊处理 + if icon == "▶": + self.play_button = btn + elif icon == "⏹": + self.stop_button = btn + + control_layout.addWidget(btn) + + control_layout.insertStretch(0, 1) + control_layout.addStretch(1) + + control_group.setLayout(control_layout) + layout.addWidget(control_group) + + def create_volume_panel(self, layout): + """创建音量控制面板""" + volume_group = QGroupBox("音量控制") + volume_group.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #9b59b6; + border-radius: 10px; + margin-top: 5px; + padding-top: 15px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #9b59b6; + font-size: 12px; + } + """) + + volume_layout = QHBoxLayout() + volume_layout.setSpacing(10) + + # 静音按钮 + self.mute_button = QPushButton("🔊") + self.mute_button.setFont(QFont("Arial", 12)) + self.mute_button.setFixedSize(40, 40) + self.mute_button.setToolTip("静音") + + # 音量滑块 + self.volume_slider = QSlider(Qt.Horizontal) + self.volume_slider.setRange(0, 128) + self.volume_slider.setValue(80) + self.volume_slider.setStyleSheet(""" + QSlider::groove:horizontal { + border: 1px solid #8e44ad; + background: #2c3e50; + height: 8px; + border-radius: 4px; + } + QSlider::sub-page:horizontal { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, + stop: 0 #9b59b6, stop: 1 #8e44ad + ); + border: 1px solid #8e44ad; + height: 8px; + border-radius: 4px; + } + QSlider::add-page:horizontal { + background: #34495e; + border: 1px solid #8e44ad; + height: 8px; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 1, + stop: 0 #ecf0f1, stop: 1 #bdc3c7 + ); + width: 18px; + height: 18px; + margin: -5px 0; + border-radius: 9px; + border: 1px solid #7f8c8d; + } + QSlider::handle:horizontal:hover { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 1, + stop: 0 #ffffff, stop: 1 #ecf0f1 + ); + border: 2px solid #9b59b6; + } + """) + + # 音量标签 + self.volume_label = QLabel("80") + self.volume_label.setFont(QFont("Arial", 11)) + self.volume_label.setFixedWidth(30) + self.volume_label.setStyleSheet("color: #ecf0f1;") + + volume_layout.addWidget(self.mute_button) + volume_layout.addWidget(self.volume_slider) + volume_layout.addWidget(self.volume_label) + + volume_group.setLayout(volume_layout) + layout.addWidget(volume_group) + + def create_playmode_panel(self, layout): + """创建播放模式面板""" + playmode_group = QGroupBox("播放模式") + playmode_group.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #f39c12; + border-radius: 10px; + margin-top: 5px; + padding-top: 15px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #f39c12; + font-size: 12px; + } + """) + + playmode_layout = QVBoxLayout() + playmode_layout.setSpacing(8) + + # 播放模式描述 + self.playmode_desc_label = QLabel("顺序播放: 按列表顺序播放,播放完停止") + self.playmode_desc_label.setFont(QFont("Arial", 9)) + self.playmode_desc_label.setStyleSheet("color: #bdc3c7;") + self.playmode_desc_label.setWordWrap(True) + + # 模式按钮组 + mode_buttons_layout = QHBoxLayout() + + self.mode_sequential = QPushButton("顺序") + self.mode_shuffle = QPushButton("随机") + self.mode_single = QPushButton("单曲") + self.mode_loop = QPushButton("列表") + + for btn in [self.mode_sequential, self.mode_shuffle, + self.mode_single, self.mode_loop]: + btn.setFixedSize(60, 30) + btn.setCheckable(True) + + # 设置顺序播放为默认选中 + self.mode_sequential.setChecked(True) + + mode_buttons_layout.addWidget(self.mode_sequential) + mode_buttons_layout.addWidget(self.mode_shuffle) + mode_buttons_layout.addWidget(self.mode_single) + mode_buttons_layout.addWidget(self.mode_loop) + mode_buttons_layout.addStretch() + + playmode_layout.addWidget(self.playmode_desc_label) + playmode_layout.addLayout(mode_buttons_layout) + + playmode_group.setLayout(playmode_layout) + layout.addWidget(playmode_group) + + # 添加弹簧使布局更紧凑 + layout.addStretch() + + def create_status_bar(self): + """创建状态栏""" + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + + # 状态标签 + self.status_label = QLabel("就绪") + self.status_label.setFont(QFont("Arial", 9)) + self.status_label.setStyleSheet("color: #7f8c8d;") + + # 播放状态 + self.play_status_label = QLabel("已停止") + self.play_status_label.setFont(QFont("Arial", 9)) + self.play_status_label.setStyleSheet("color: #3498db;") + + # 时间显示 + self.time_status_label = QLabel("00:00 / 00:00") + self.time_status_label.setFont(QFont("Arial", 9)) + self.time_status_label.setStyleSheet("color: #2ecc71;") + + self.status_bar.addWidget(self.status_label, 1) + self.status_bar.addPermanentWidget(self.play_status_label) + self.status_bar.addPermanentWidget(self.time_status_label) + + def create_context_menus(self): + """创建上下文菜单""" + # 播放列表右键菜单 + self.playlist_widget.setContextMenuPolicy(Qt.CustomContextMenu) + self.playlist_widget.customContextMenuRequested.connect(self.show_playlist_context_menu) + + def set_dark_theme(self): + """设置深色主题""" + palette = QPalette() + palette.setColor(QPalette.Window, QColor(40, 44, 52)) + palette.setColor(QPalette.WindowText, QColor(220, 220, 220)) + palette.setColor(QPalette.Base, QColor(30, 33, 40)) + palette.setColor(QPalette.AlternateBase, QColor(35, 38, 45)) + palette.setColor(QPalette.ToolTipBase, QColor(220, 220, 220)) + palette.setColor(QPalette.ToolTipText, QColor(220, 220, 220)) + palette.setColor(QPalette.Text, QColor(220, 220, 220)) + palette.setColor(QPalette.Button, QColor(50, 54, 63)) + palette.setColor(QPalette.ButtonText, QColor(220, 220, 220)) + palette.setColor(QPalette.BrightText, Qt.red) + palette.setColor(QPalette.Link, QColor(66, 139, 202)) + palette.setColor(QPalette.Highlight, QColor(66, 139, 202)) + palette.setColor(QPalette.HighlightedText, Qt.black) + self.setPalette(palette) + + def set_button_styles(self): + """设置按钮样式""" + # 控制按钮样式 + control_button_style = """ + QPushButton { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #34495e, stop: 1 #2c3e50 + ); + color: #ecf0f1; + border: 2px solid #3498db; + border-radius: 25px; + font-weight: bold; + } + QPushButton:hover { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #3498db, stop: 1 #2980b9 + ); + border: 2px solid #2980b9; + color: white; + } + QPushButton:pressed { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #2980b9, stop: 1 #3498db + ); + } + QPushButton:checked { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #2ecc71, stop: 1 #27ae60 + ); + border: 2px solid #27ae60; + color: white; + } + """ + + # 播放按钮特殊样式 + play_button_style = """ + QPushButton { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #2ecc71, stop: 1 #27ae60 + ); + color: white; + border: 2px solid #27ae60; + border-radius: 30px; + font-weight: bold; + } + QPushButton:hover { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #27ae60, stop: 1 #2ecc71 + ); + border: 2px solid #2ecc71; + } + QPushButton:pressed { + background-color: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #229954, stop: 1 #27ae60 + ); + } + """ + + # 设置播放按钮样式 + self.play_button.setStyleSheet(play_button_style) + + # 设置其他控制按钮样式 + for btn in [self.stop_button, self.mute_button]: + btn.setStyleSheet(control_button_style) + + # 模式按钮样式 + mode_button_style = """ + QPushButton { + background-color: #34495e; + color: #ecf0f1; + border: 1px solid #f39c12; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #f39c12; + color: white; + } + QPushButton:checked { + background-color: #e67e22; + color: white; + border: 2px solid #d35400; + } + """ + + for btn in [self.mode_sequential, self.mode_shuffle, + self.mode_single, self.mode_loop]: + btn.setStyleSheet(mode_button_style) + + # 播放列表按钮样式 + playlist_button_style = """ + QPushButton { + background-color: #2c3e50; + color: #ecf0f1; + border: 1px solid #3498db; + border-radius: 3px; + font-weight: bold; + } + QPushButton:hover { + background-color: #3498db; + color: white; + } + """ + + for btn in [self.playlist_clear_button, self.playlist_remove_button, + self.playlist_save_button, self.playlist_load_button]: + btn.setStyleSheet(playlist_button_style) + + def setup_connections(self): + """连接信号和槽""" + # 工具栏动作连接 + self.open_file_action.triggered.connect(self.open_file) + self.open_folder_action.triggered.connect(self.open_folder) + self.play_action.triggered.connect(self.play_pause) + self.pause_action.triggered.connect(self.pause) + self.stop_action.triggered.connect(self.stop) + self.prev_action.triggered.connect(self.play_previous) + self.next_action.triggered.connect(self.play_next) + + # 播放模式按钮连接 + self.mode_sequential.clicked.connect(lambda: self.set_play_mode(self.PLAY_MODE_SEQUENTIAL)) + self.mode_shuffle.clicked.connect(lambda: self.set_play_mode(self.PLAY_MODE_SHUFFLE)) + self.mode_single.clicked.connect(lambda: self.set_play_mode(self.PLAY_MODE_SINGLE)) + self.mode_loop.clicked.connect(lambda: self.set_play_mode(self.PLAY_MODE_LOOP)) + + # 播放模式组合框连接 + self.playmode_combo.currentIndexChanged.connect(self.on_playmode_changed) + + # 播放列表按钮连接 + self.playlist_clear_button.clicked.connect(self.clear_playlist) + self.playlist_remove_button.clicked.connect(self.remove_selected_items) + self.playlist_save_button.clicked.connect(self.save_playlist) + self.playlist_load_button.clicked.connect(self.load_playlist) + + # 滑块连接 + self.position_slider.sliderMoved.connect(self.seek_position) + self.volume_slider.valueChanged.connect(self.set_volume) + + # 播放列表连接 + self.playlist_widget.itemDoubleClicked.connect(self.playlist_item_double_clicked) + + # 静音按钮 + self.mute_button.clicked.connect(self.toggle_mute) + + # 定时器连接 + self.update_timer.timeout.connect(self.update_ui) + + def init_system_tray(self): + """初始化系统托盘""" + if QSystemTrayIcon.isSystemTrayAvailable(): + self.tray_icon = QSystemTrayIcon(self) + self.tray_icon.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + + # 创建托盘菜单 + tray_menu = QMenu() + + play_action = QAction("播放/暂停", self) + play_action.triggered.connect(self.play_pause) + + stop_action = QAction("停止", self) + stop_action.triggered.connect(self.stop) + + next_action = QAction("下一首", self) + next_action.triggered.connect(self.play_next) + + show_action = QAction("显示窗口", self) + show_action.triggered.connect(self.show_normal) + + quit_action = QAction("退出", self) + quit_action.triggered.connect(self.quit_application) + + tray_menu.addAction(play_action) + tray_menu.addAction(stop_action) + tray_menu.addAction(next_action) + tray_menu.addSeparator() + tray_menu.addAction(show_action) + tray_menu.addSeparator() + tray_menu.addAction(quit_action) + + self.tray_icon.setContextMenu(tray_menu) + self.tray_icon.show() + + # 托盘图标点击事件 + self.tray_icon.activated.connect(self.on_tray_icon_activated) + + def show_playlist_context_menu(self, position): + """显示播放列表右键菜单""" + menu = QMenu() + + play_action = QAction("播放", self) + play_action.triggered.connect(lambda: self.play_selected_item()) + + remove_action = QAction("移除", self) + remove_action.triggered.connect(self.remove_selected_items) + + clear_action = QAction("清空列表", self) + clear_action.triggered.connect(self.clear_playlist) + + menu.addAction(play_action) + menu.addAction(remove_action) + menu.addSeparator() + menu.addAction(clear_action) + + menu.exec_(self.playlist_widget.mapToGlobal(position)) + + def open_file(self): + """打开单个文件""" + file_path, _ = QFileDialog.getOpenFileName( + self, "选择音频文件", "", + "音频文件 (*.mp3 *.wav *.ogg *.flac *.m4a *.aac);;所有文件 (*.*)" + ) + + if file_path: + self.add_to_playlist(file_path) + if len(self.playlist) == 1: # 如果是第一个文件,自动加载 + self.load_file(file_path) + + def open_folder(self): + """打开文件夹并添加所有音频文件""" + folder_path = QFileDialog.getExistingDirectory(self, "选择文件夹") + + if folder_path: + # 清空当前播放列表 + self.playlist.clear() + self.playlist_widget.clear() + self.shuffled_indices.clear() + + # 遍历文件夹中的音频文件 + audio_extensions = ['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac'] + for root, dirs, files in os.walk(folder_path): + for file in files: + if any(file.lower().endswith(ext) for ext in audio_extensions): + file_path = os.path.join(root, file) + self.add_to_playlist(file_path) + + if self.playlist: + self.status_label.setText(f"已添加 {len(self.playlist)} 个文件到播放列表") + self.update_playlist_info() + + # 如果当前没有在播放,自动加载第一个文件 + if not self.is_playing: + self.load_file(self.playlist[0]) + + def add_to_playlist(self, file_path): + """添加文件到播放列表""" + if file_path not in self.playlist: + self.playlist.append(file_path) + file_name = os.path.basename(file_path) + + # 获取文件信息 + try: + duration_result = self.audio_lib.get_audio_duration(file_path, is_file=True) + if isinstance(duration_result, tuple): + duration = 0 + else: + duration = int(duration_result) if duration_result else 0 + + duration_str = self.format_time(duration) + display_text = f"{file_name} ({duration_str})" + except: + display_text = file_name + + item = QListWidgetItem(f"🎵 {display_text}") + item.setData(Qt.UserRole, file_path) + item.setToolTip(file_path) + self.playlist_widget.addItem(item) + + # 设置高亮当前播放的项目 + if self.current_file == file_path: + item.setSelected(True) + self.playlist_widget.scrollToItem(item) + + # 更新播放列表信息 + self.update_playlist_info() + + def update_playlist_info(self): + """更新播放列表信息""" + count = len(self.playlist) + + # 计算总时长 + total_seconds = 0 + for file_path in self.playlist: + try: + duration_result = self.audio_lib.get_audio_duration(file_path, is_file=True) + if isinstance(duration_result, tuple): + continue + total_seconds += int(duration_result) if duration_result else 0 + except: + continue + + total_time = self.format_time(total_seconds) + self.playlist_info_label.setText(f"共 {count} 首歌曲 | 总时长: {total_time}") + + def remove_selected_items(self): + """移除选中的播放列表项""" + selected_items = self.playlist_widget.selectedItems() + if not selected_items: + return + + reply = QMessageBox.question( + self, "确认", f"确定要移除选中的 {len(selected_items)} 个项目吗?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + + if reply == QMessageBox.Yes: + for item in selected_items: + file_path = item.data(Qt.UserRole) + if file_path in self.playlist: + index = self.playlist.index(file_path) + self.playlist.pop(index) + + # 如果移除的是当前播放的文件 + if file_path == self.current_file: + self.stop() + self.current_file = None + self.current_index = -1 + + self.playlist_widget.takeItem(self.playlist_widget.row(item)) + + self.update_playlist_info() + + def save_playlist(self): + """保存播放列表到文件""" + if not self.playlist: + QMessageBox.warning(self, "警告", "播放列表为空,无法保存") + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "保存播放列表", "", "播放列表文件 (*.m3u);;文本文件 (*.txt)" + ) + + if file_path: + try: + with open(file_path, 'w', encoding='utf-8') as f: + for item_path in self.playlist: + f.write(item_path + '\n') + + self.status_label.setText(f"播放列表已保存到: {file_path}") + QMessageBox.information(self, "成功", "播放列表保存成功!") + except Exception as e: + QMessageBox.critical(self, "错误", f"保存播放列表失败: {e}") + + def load_playlist(self): + """从文件加载播放列表""" + file_path, _ = QFileDialog.getOpenFileName( + self, "加载播放列表", "", "播放列表文件 (*.m3u *.txt);;所有文件 (*.*)" + ) + + if file_path: + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # 清空当前播放列表 + self.playlist.clear() + self.playlist_widget.clear() + self.shuffled_indices.clear() + + for line in lines: + line = line.strip() + if line and os.path.exists(line): + self.add_to_playlist(line) + + if self.playlist: + self.status_label.setText(f"已加载 {len(self.playlist)} 个文件") + QMessageBox.information(self, "成功", "播放列表加载成功!") + else: + QMessageBox.warning(self, "警告", "播放列表文件为空或文件路径无效") + + except Exception as e: + QMessageBox.critical(self, "错误", f"加载播放列表失败: {e}") + + def clear_playlist(self): + """清空播放列表""" + if not self.playlist: + return + + reply = QMessageBox.question( + self, "确认", "确定要清空播放列表吗?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.playlist.clear() + self.playlist_widget.clear() + self.shuffled_indices.clear() + self.current_index = -1 + self.current_file = None + self.current_file_label.setText("未选择歌曲") + self.song_info_label.setText("艺术家: 未知 | 专辑: 未知") + self.update_playlist_info() + + # 停止当前播放 + if self.is_playing and self.current_aid: + self.stop() + + def load_file(self, file_path): + """加载音频文件""" + try: + if not os.path.exists(file_path): + raise FileNotFoundError(f"文件不存在: {file_path}") + + # 停止当前播放 + if self.is_playing and self.current_aid: + try: + self.audio_lib.stop_audio(self.current_aid) + except: + pass + + self.is_playing = False + self.is_paused = False + self.play_button.setText("▶") + + # 设置当前文件 + self.current_file = file_path + self.current_file_name = os.path.basename(file_path) + + # 更新显示信息 + self.current_file_label.setText(self.current_file_name) + + # 尝试提取艺术家和专辑信息 + file_dir = os.path.dirname(file_path) + folder_name = os.path.basename(file_dir) + self.song_info_label.setText(f"艺术家: 未知 | 专辑: {folder_name}") + + # 更新当前索引 + if file_path in self.playlist: + self.current_index = self.playlist.index(file_path) + + # 如果是随机播放模式,更新随机索引 + if self.play_mode == self.PLAY_MODE_SHUFFLE: + if not self.shuffled_indices: + self.generate_shuffle_indices() + + # 高亮当前播放的项目 + for i in range(self.playlist_widget.count()): + item = self.playlist_widget.item(i) + if item.data(Qt.UserRole) == file_path: + item.setSelected(True) + self.playlist_widget.scrollToItem(item) + + # 更新项目文本,添加播放标志 + current_text = item.text() + if not current_text.startswith("▶ "): + item.setText("▶ " + current_text.lstrip("🎵 ▶ ")) + else: + item.setSelected(False) + # 移除其他项目的播放标志 + text = item.text() + if text.startswith("▶ "): + item.setText("🎵 " + text[2:]) + + # 获取音频时长 + try: + duration_result = self.audio_lib.get_audio_duration(file_path, is_file=True) + if isinstance(duration_result, tuple): + self.total_duration = 300 # 默认5分钟 + else: + self.total_duration = float(duration_result) if duration_result else 300 + except: + self.total_duration = 300 + + # 更新总时长显示 + self.total_time_label.setText(self.format_time(self.total_duration)) + + # 加载音频到内存 + try: + if self.current_aid: + try: + old_file = self.audio_lib._get_file_path_by_aid(self.current_aid) + if old_file in self.audio_lib._audio_cache: + self.audio_lib._audio_cache.pop(old_file, None) + elif old_file in self.audio_lib._music_cache: + self.audio_lib._music_cache.pop(old_file, None) + except: + pass + + self.current_aid = self.audio_lib.new_aid(file_path) + print(f"加载音频成功,AID: {self.current_aid}") + + # 设置音量和播放速度 + self.audio_lib.set_volume(self.current_aid, self.volume) + + + self.status_label.setText(f"已加载: {self.current_file_name}") + self.play_status_label.setText("已加载") + + except Exception as e: + raise Exception(f"加载音频失败: {e}") + + except Exception as e: + QMessageBox.warning(self, "警告", f"加载文件失败: {e}") + self.status_label.setText(f"加载失败: {e}") + + def play_selected_item(self): + """播放选中的项目""" + selected_items = self.playlist_widget.selectedItems() + if selected_items: + item = selected_items[0] + file_path = item.data(Qt.UserRole) + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def playlist_item_double_clicked(self, item): + """播放列表项双击事件""" + file_path = item.data(Qt.UserRole) + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def play_pause(self): + """播放/暂停""" + if not self.current_file or not self.current_aid: + if self.playlist: + # 如果没有当前文件,但有播放列表,播放第一个 + self.load_file(self.playlist[0]) + if self.current_aid: + self.start_playback() + else: + self.status_label.setText("请先选择音频文件") + return + + try: + if not self.is_playing: + self.start_playback() + else: + if self.is_paused: + self.resume_playback() + else: + self.pause_playback() + + except Exception as e: + print(f"播放控制失败: {e}") + self.status_label.setText(f"播放失败: {e}") + + def start_playback(self): + """开始播放""" + print(f"开始播放,AID: {self.current_aid}") + try: + self.current_aid = self.audio_lib.play_from_memory(self.current_file) + except Exception as e: + print(f"从内存播放失败,尝试从文件播放: {e}") + self.current_aid = self.audio_lib.play_from_file(self.current_file) + + self.is_playing = True + self.is_paused = False + self.play_button.setText("⏸") + self.playback_start_time = time.time() + self.current_position = 0 + + # 设置音量和播放速度 + self.audio_lib.set_volume(self.current_aid, self.volume) + + self.status_label.setText(f"正在播放: {self.current_file_name}") + self.play_status_label.setText("播放中") + self.state_changed.emit("playing") + + def pause_playback(self): + """暂停播放""" + print("暂停播放") + self.audio_lib.pause_audio(self.current_aid) + self.is_paused = True + self.play_button.setText("▶") + self.current_position = time.time() - self.playback_start_time + self.status_label.setText(f"已暂停: {self.current_file_name}") + self.play_status_label.setText("已暂停") + self.state_changed.emit("paused") + + def resume_playback(self): + """恢复播放""" + print("恢复播放") + self.audio_lib.play_audio(self.current_aid) + self.is_paused = False + self.play_button.setText("⏸") + self.playback_start_time = time.time() - self.current_position + self.status_label.setText(f"正在播放: {self.current_file_name}") + self.play_status_label.setText("播放中") + self.state_changed.emit("playing") + + def pause(self): + """暂停播放(工具栏专用)""" + if self.is_playing and not self.is_paused: + self.pause_playback() + + def stop(self): + """停止播放""" + if self.is_playing and self.current_aid: + try: + print("停止播放") + played_time = self.audio_lib.stop_audio(self.current_aid) + print(f"已停止播放,播放时长: {played_time:.2f}秒") + + self.is_playing = False + self.is_paused = False + self.play_button.setText("▶") + self.position_slider.setValue(0) + self.current_position = 0 + self.current_time_label.setText("00:00") + + self.status_label.setText(f"已停止: {self.current_file_name}") + self.play_status_label.setText("已停止") + self.state_changed.emit("stopped") + + except Exception as e: + print(f"停止播放失败: {e}") + self.status_label.setText(f"停止失败: {e}") + + def play_previous(self): + """播放上一首""" + if not self.playlist: + return + + if self.current_index >= 0: + if self.play_mode == self.PLAY_MODE_SHUFFLE: + self.play_previous_shuffle() + else: + if self.current_index > 0: + self.current_index -= 1 + elif self.play_mode == self.PLAY_MODE_LOOP: + self.current_index = len(self.playlist) - 1 + else: + return + + file_path = self.playlist[self.current_index] + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def play_previous_shuffle(self): + """随机播放模式下的上一首""" + if not self.shuffled_indices: + self.generate_shuffle_indices() + + current_shuffle_index = self.get_current_shuffle_index() + if current_shuffle_index > 0: + new_index = self.shuffled_indices[current_shuffle_index - 1] + self.current_index = new_index + file_path = self.playlist[self.current_index] + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def play_next(self): + """播放下一首""" + if not self.playlist: + return + + if self.play_mode == self.PLAY_MODE_SINGLE: + # 单曲循环,重新播放当前歌曲 + if self.current_file: + self.load_file(self.current_file) + if not self.is_playing: + self.play_pause() + return + + if self.current_index >= 0: + if self.play_mode == self.PLAY_MODE_SHUFFLE: + self.play_next_shuffle() + else: + if self.current_index < len(self.playlist) - 1: + self.current_index += 1 + elif self.play_mode == self.PLAY_MODE_LOOP: + self.current_index = 0 + else: + # 顺序播放到最后一首,停止播放 + self.stop() + return + + file_path = self.playlist[self.current_index] + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def play_next_shuffle(self): + """随机播放模式下的下一首""" + if not self.shuffled_indices: + self.generate_shuffle_indices() + + current_shuffle_index = self.get_current_shuffle_index() + if current_shuffle_index < len(self.shuffled_indices) - 1: + new_index = self.shuffled_indices[current_shuffle_index + 1] + self.current_index = new_index + file_path = self.playlist[self.current_index] + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + else: + # 随机播放列表结束,重新生成或停止 + if self.play_mode == self.PLAY_MODE_SHUFFLE: + self.generate_shuffle_indices() + if self.shuffled_indices: + self.current_index = self.shuffled_indices[0] + file_path = self.playlist[self.current_index] + self.load_file(file_path) + if not self.is_playing: + self.play_pause() + + def generate_shuffle_indices(self): + """生成随机播放的索引列表""" + if not self.playlist: + return + + indices = list(range(len(self.playlist))) + random.shuffle(indices) + + # 确保当前播放的歌曲不在第一个位置(如果可能) + if self.current_index in indices and indices[0] == self.current_index and len(indices) > 1: + indices[0], indices[1] = indices[1], indices[0] + + self.shuffled_indices = indices + + def get_current_shuffle_index(self): + """获取当前歌曲在随机播放列表中的位置""" + if self.current_index in self.shuffled_indices: + return self.shuffled_indices.index(self.current_index) + return -1 + + def set_play_mode(self, mode): + """设置播放模式""" + self.play_mode = mode + + # 更新按钮状态 + self.mode_sequential.setChecked(mode == self.PLAY_MODE_SEQUENTIAL) + self.mode_shuffle.setChecked(mode == self.PLAY_MODE_SHUFFLE) + self.mode_single.setChecked(mode == self.PLAY_MODE_SINGLE) + self.mode_loop.setChecked(mode == self.PLAY_MODE_LOOP) + + # 更新组合框 + self.playmode_combo.setCurrentIndex(mode) + + # 更新模式描述 + descriptions = [ + "顺序播放: 按列表顺序播放,播放完停止", + "随机播放: 随机播放列表中的歌曲", + "单曲循环: 重复播放当前歌曲", + "列表循环: 按列表顺序循环播放" + ] + self.playmode_desc_label.setText(descriptions[mode]) + + # 如果是随机播放模式,生成随机索引 + if mode == self.PLAY_MODE_SHUFFLE: + self.generate_shuffle_indices() + + self.status_label.setText(f"播放模式已切换: {self.playmode_combo.currentText()}") + + def on_playmode_changed(self, index): + """播放模式组合框变化""" + mode = self.playmode_combo.currentData() + self.set_play_mode(mode) + + def toggle_mute(self): + """切换静音""" + if not self.is_muted: + self.last_volume = self.volume_slider.value() + self.volume_slider.setValue(0) + self.mute_button.setText("🔇") + self.is_muted = True + else: + self.volume_slider.setValue(self.last_volume) + self.mute_button.setText("🔊") + self.is_muted = False + + def set_volume(self, value): + """设置音量""" + self.volume = value + self.volume_label.setText(str(value)) + + if self.current_aid and self.is_playing: + try: + self.audio_lib.set_volume(self.current_aid, value) + except Exception as e: + print(f"设置音量失败: {e}") + + # 更新静音状态 + if value == 0: + self.is_muted = True + self.mute_button.setText("🔇") + else: + self.is_muted = False + self.mute_button.setText("🔊") + + self.volume_changed.emit(value) + + def on_speed_changed(self, index): + """播放速度变化""" + pass + def seek_position(self, value): + """跳转到指定位置""" + if self.total_duration > 0: + position = (value / 1000.0) * self.total_duration + if self.is_playing and self.current_aid: + try: + self.audio_lib.seek_audio(self.current_aid, position) + self.playback_start_time = time.time() - position + self.current_position = position + except Exception as e: + print(f"跳转失败: {e}") + + def seek_relative(self, seconds): + """相对跳转(快进/快退)""" + if self.is_playing and self.current_aid and self.total_duration > 0: + current_time = time.time() - self.playback_start_time + new_position = max(0, min(self.total_duration, current_time + seconds)) + + try: + self.audio_lib.seek_audio(self.current_aid, new_position) + self.playback_start_time = time.time() - new_position + self.current_position = new_position + except Exception as e: + print(f"跳转失败: {e}") + + def update_ui(self): + """更新UI""" + try: + if self.is_playing and not self.is_paused and self.current_aid: + if self.playback_start_time > 0: + current_time = time.time() - self.playback_start_time + self.current_position = current_time + + # 防止超出总时长 + if self.total_duration > 0 and current_time >= self.total_duration: + current_time = self.total_duration + # 歌曲播放结束,根据播放模式处理 + self.on_track_end() + + if not self.is_playing: + return + + # 更新时间显示 + self.current_time_label.setText(self.format_time(current_time)) + self.time_status_label.setText( + f"{self.format_time(current_time)} / {self.format_time(self.total_duration)}" + ) + + # 更新进度条 + if self.total_duration > 0: + progress_value = int((current_time / self.total_duration) * 1000) + self.position_slider.setValue(progress_value) + + except Exception as e: + print(f"更新UI时出错: {e}") + + def on_track_end(self): + """处理歌曲播放结束""" + if self.play_mode == self.PLAY_MODE_SINGLE: + # 单曲循环,重新播放 + self.load_file(self.current_file) + self.start_playback() + else: + # 其他模式,播放下一首 + self.play_next() + + def format_time(self, seconds): + """格式化时间显示""" + if not seconds: + seconds = 0 + minutes = int(seconds // 60) + secs = int(seconds % 60) + return f"{minutes:02d}:{secs:02d}" + + def show_normal(self): + """显示窗口""" + self.show() + self.activateWindow() + self.raise_() + + def on_tray_icon_activated(self, reason): + """托盘图标激活""" + if reason == QSystemTrayIcon.DoubleClick: + self.show_normal() + + def quit_application(self): + """退出应用程序""" + self.cleanup() + QApplication.quit() + + def closeEvent(self, event): + """关闭窗口事件""" + if hasattr(self, 'tray_icon') and self.tray_icon.isVisible(): + # 如果有托盘图标,最小化到托盘 + self.hide() + event.ignore() + self.tray_icon.showMessage( + "Modern Audio Player", + "程序已最小化到系统托盘", + QSystemTrayIcon.Information, + 2000 + ) + else: + # 否则正常退出 + self.cleanup() + event.accept() + + def cleanup(self): + """清理资源""" + try: + if self.audio_lib: + print("清理音频库资源...") + if self.current_aid and self.is_playing: + try: + self.audio_lib.stop_audio(self.current_aid) + except: + pass + self.audio_lib.clear_memory_cache() + except Exception as e: + print(f"清理资源时出错: {e}") + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("Modern Audio Player") + app.setApplicationDisplayName("Modern Audio Player") + + # 设置应用程序样式 + app.setStyle('Fusion') + + # 设置应用程序图标 + app.setWindowIcon(QIcon(":icons/media-play")) + + player = ModernAudioPlayer() + player.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/ap_ds/Test/IMPORT_TEST.py b/ap_ds/Test/IMPORT_TEST.py new file mode 100644 index 0000000..77d7dc8 --- /dev/null +++ b/ap_ds/Test/IMPORT_TEST.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_all_apis.py - 测试 ap_ds 文档中所有 API 是否存在 +包括 AudioLibrary 类的所有方法 +""" + +import sys +import inspect + +print("=" * 60) +print("🧪 Testing All ap_ds API Exports") +print("=" * 60) + +# ============================================================ +# 1. 测试顶级函数导入 +# ============================================================ + +TOP_LEVEL_APIS = [ + "AudioLibrary", + "batch_get_metadata", + "batch_get_duration", + "batch_get_metadata_by_type", + "get_audio_duration", + "get_audio_metadata", + "auto_check_runtime", + "check_runtime_mode", + "show_tech_manual", +] + +print("\n📦 Testing top-level imports:") +print("-" * 40) + +passed = 0 +failed = 0 +missing = [] + +for name in TOP_LEVEL_APIS: + try: + exec(f"from ap_ds import {name}") + print(f" ✅ {name}") + passed += 1 + except ImportError as e: + print(f" ❌ {name}: {e}") + failed += 1 + missing.append(name) + +# ============================================================ +# 2. 测试 AudioLibrary 类的所有方法 +# ============================================================ + +AUDIOLIBRARY_METHODS = [ + # 初始化 + "__init__", + + # 播放方法 + "play_from_file", + "play_from_memory", + "new_aid", + + # 控制方法 + "play_audio", + "pause_audio", + "stop_audio", + "seek_audio", + + # 音量方法 + "set_volume", + "get_volume", + + # 淡入淡出与过渡方法 + "fadein_music", + "fadein_music_pos", + "fadeout_music", + "is_music_playing", + "is_music_paused", + "get_music_fading", + + # 元数据方法 + "get_audio_duration", + "get_audio_metadata", + "get_audio_metadata_by_path", + "get_audio_metadata_by_aid", + + # 批量解析方法 + "batch_get_metadata", + "batch_get_duration", + "batch_get_metadata_by_type", + + # DAP 系统方法 + "save_dap_to_json", + "get_dap_recordings", + "clear_dap_recordings", + + # 资源管理 + "clear_memory_cache", + "cleanup_function", + + # 内部辅助方法 (文档中列出但通常是私有的) + "_find_channel_by_aid", + "_get_file_path_by_aid", + "_is_music_file", + "_seek_audio", + "_get_duration_by_filepath", + "_get_file_duration", +] + +print("\n" + "-" * 40) +print("🎯 Testing AudioLibrary methods:") +print("-" * 40) + +try: + from ap_ds import AudioLibrary + + # 获取 AudioLibrary 类的所有方法 + lib_methods = [m for m in dir(AudioLibrary) if not m.startswith('__') or m == '__init__'] + + for method_name in AUDIOLIBRARY_METHODS: + if hasattr(AudioLibrary, method_name): + print(f" ✅ AudioLibrary.{method_name}") + passed += 1 + else: + print(f" ❌ AudioLibrary.{method_name} (NOT FOUND)") + failed += 1 + missing.append(f"AudioLibrary.{method_name}") + +except ImportError as e: + print(f" ❌ Cannot import AudioLibrary: {e}") + failed += 1 + +# ============================================================ +# 3. 检查文档中可能遗漏的额外 API +# ============================================================ + +EXTRA_APIS = [ + "is_full_performance", + "get_runtime_info", +] + +print("\n" + "-" * 40) +print("🔍 Checking extra APIs (mentioned in docs but maybe not exported):") +print("-" * 40) + +for name in EXTRA_APIS: + try: + exec(f"from ap_ds import {name}") + print(f" ✅ {name} (exists!)") + passed += 1 + except ImportError: + print(f" ❌ {name} (NOT FOUND - remove from docs or add to __init__.py)") + failed += 1 + missing.append(name) + +# ============================================================ +# 4. 汇总 +# ============================================================ + +print("\n" + "=" * 60) +print("📊 FINAL SUMMARY") +print("=" * 60) + +if failed == 0: + print("🎉 ALL APIs EXIST! Documentation is accurate.") +else: + print(f"⚠️ {failed} API(s) missing:") + for name in missing: + print(f" - {name}") + print("\n💡 Fix:") + print(" Either remove these from documentation, or add them to __init__.py") + +print("=" * 60) +print(f"✅ Passed: {passed}") +print(f"❌ Failed: {failed}") diff --git a/ap_ds/Test/OPUS_TEST.py b/ap_ds/Test/OPUS_TEST.py new file mode 100644 index 0000000..e41c72a --- /dev/null +++ b/ap_ds/Test/OPUS_TEST.py @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r""" +============================================================================ + AP_DS 4.0.1 - Opus Specialized Test Suite + Audio Library By DVS +============================================================================ + +Automated + interactive test coverage for the Opus playback support +(opusplayer.py + _opusdll.py) integrated into AudioLibrary. + +Sections: + [OP-1] Opus Module Imports / Constants / Error Codes + [OP-2] _opusdll.py DLL Loading & Auto-Download + [OP-3] Opus Metadata Parsing (basic + extended) + [OP-4] Opus Playback (play_from_file / new_aid / play_from_memory) + [OP-5] Opus Playback Control (pause / resume / stop) + [OP-6] Opus Volume Control + [OP-7] Opus Seek + [OP-8] Opus Fade In / Out + [OP-9] Opus vs Original Distinction + [OP-10] Opus Batch Parsing + [OP-11] Opus Error Codes (normal / boundary / error) + [OP-12] Opus AID Mapping (1:1) + [OP-13] Opus Resource Management + [OP-L] Listening Tests (interactive, requires ears) + +Usage: + python OPUS_TEST.py --auto Run automated tests only + python OPUS_TEST.py --listen Run interactive listening tests only + python OPUS_TEST.py --full Run everything (default) + +The suite verifies Opus-specific error codes (2001-2010), normal operation, +boundary values, and error paths. +============================================================================ +""" +import os +import sys +import io +import time +import struct +import hashlib +import contextlib + +# ============================================================================ +# Environment +# ============================================================================ +os.environ.setdefault('AP_DS_SKIP_AUTO_CHECK', '1') +os.environ.setdefault('AP_DS_SUPPRESS_WARNINGS', '1') + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if os.path.basename(_HERE) == 'ap_ds': + sys.path.insert(0, os.path.dirname(_HERE)) +else: + sys.path.insert(0, _HERE) + +import ap_ds +from ap_ds import AudioLibrary +import ap_ds.player as player +import ap_ds.opusplayer as opusplayer +import ap_ds._opusdll as opusdll + +# ============================================================================ +# Test resources +# ============================================================================ +TMP_DIR = os.path.join(_HERE, 'opus_tmp') +os.makedirs(TMP_DIR, exist_ok=True) + +# Opus test file (use the real test.opus if available, else search package) +OPUS_FILE = r"D:\DVS开发目录\ap_ds opus支持 开发目录\test.opus" +if not os.path.exists(OPUS_FILE): + pkg_dir = os.path.dirname(os.path.abspath(opusplayer.__file__)) + for f in os.listdir(pkg_dir): + if f.endswith('.opus'): + OPUS_FILE = os.path.join(pkg_dir, f) + break + +def _prompt_opus(): + """Prompt the user for an Opus file path. Returns default if empty/EOF.""" + print("\n" + "=" * 60) + print(" Opus Test Suite - Test Audio Selection") + print("=" * 60) + default = OPUS_FILE + try: + answer = input( + "Enter path to an Opus file for tests\n" + f"(or press Enter to use default: {default})\n> " + ).strip().strip('"').strip("'") + if answer and os.path.exists(answer): + return answer + if answer and not os.path.exists(answer): + print(f" ! File not found: {answer}, using default") + return default + except EOFError: + # Non-interactive execution -> use default + return default + + +# Original (non-Opus) test file - a WAV +WAV_FILE = os.path.join(TMP_DIR, 'orig.wav') +def _make_wav(path, seconds=2, sr=22050, ch=1): + import wave + n = sr * seconds * ch + with wave.open(path, 'w') as w: + w.setnchannels(ch) + w.setsampwidth(2) + w.setframerate(sr) + w.writeframes(b''.join(struct.pack('0", meta.get('duration', 0) > 0, f"got={meta.get('duration')}") + t.check("sample_rate=48000", meta.get('sample_rate') == 48000, f"got={meta.get('sample_rate')}") + t.check("channels>0", meta.get('channels', 0) > 0, f"got={meta.get('channels')}") + t.check("bitrate>0", meta.get('bitrate', 0) > 0, f"got={meta.get('bitrate')}") + t.check("fields complete", all(k in meta for k in ('path', 'format', 'duration', 'length', 'sample_rate', 'channels', 'bitrate'))) + dur = lib.get_audio_duration(OPUS_FILE, is_file=True) + t.check("get_audio_duration>0", isinstance(dur, int) and dur > 0, f"got={dur}") + sr = lib._get_sample_rate(OPUS_FILE) + t.check("_get_sample_rate=48000", sr == 48000, f"got={sr}") + ch = lib._get_channels(OPUS_FILE) + t.check("_get_channels>0", ch > 0, f"got={ch}") + ext = lib.get_audio_extended_metadata(OPUS_FILE) + t.check("extended metadata is dict", isinstance(ext, dict), f"got={type(ext).__name__}") + if isinstance(ext, dict): + t.check("extended has title/album/artist", 'title' in ext or 'album' in ext or 'artist' in ext, f"keys={list(ext.keys())[:5]}") + t.check("extended has vendor", 'vendor' in ext, f"vendor={ext.get('vendor')}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-4] Opus Playback +# ============================================================================ +def test_opus_play(t): + t.section("[OP-4] Opus Playback") + if not os.path.exists(OPUS_FILE): + t.skip("Opus playback", "no Opus file available") + return + lib = opusplayer.OpusAudio() + aid = lib.play_from_file(OPUS_FILE) + t.check("play_from_file returns int AID", isinstance(aid, int), f"got={aid}") + if isinstance(aid, int): + time.sleep(0.3) + t.check("is_music_playing", lib.is_music_playing() is True) + lib.stop_audio(aid) + aid2 = lib.new_aid(OPUS_FILE) + t.check("new_aid returns int AID", isinstance(aid2, int), f"got={aid2}") + r = lib.play_from_memory(OPUS_FILE) + t.check("play_from_memory returns int", isinstance(r, int), f"got={r}") + if isinstance(r, int): + lib.stop_audio(r) + r = lib.play_from_file(os.path.join(TMP_DIR, 'missing.opus')) + t.check("play_from_file(missing) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}") + r = lib.play_from_file(None) + t.check("play_from_file(None) -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}") + r = lib.play_from_file(BAD_OPUS) + t.check("play_from_file(corrupted) -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}") + lib.cleanup_function() + + + +# ============================================================================ +# [OP-5] Opus Playback Control +# ============================================================================ +def test_opus_control(t): + t.section("[OP-5] Opus Playback Control") + if not os.path.exists(OPUS_FILE): + t.skip("Opus control", "no Opus file available") + return + lib = opusplayer.OpusAudio() + aid = lib.play_from_file(OPUS_FILE) + if not isinstance(aid, int): + t.check("Play opus ok", False, f"got={aid}") + lib.cleanup_function() + return + time.sleep(0.3) + r = lib.pause_audio(aid) + t.check("pause_audio ok", r[0] == 0, f"got={r}") + time.sleep(0.2) + t.check("is_music_paused=True", lib.is_music_paused() is True) + r = lib.play_audio(aid) + t.check("play_audio resume ok", r[0] == 0, f"got={r}") + time.sleep(0.2) + t.check("is_music_playing=True after resume", lib.is_music_playing() is True) + r = lib.stop_audio(aid) + t.check("stop_audio returns float", isinstance(r, float), f"got={r}") + # Error paths + r = lib.pause_audio(99999) + t.check("pause_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.play_audio(99999) + t.check("play_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.stop_audio(99999) + t.check("stop_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-6] Opus Volume Control +# ============================================================================ +def test_opus_volume(t): + t.section("[OP-6] Opus Volume Control") + if not os.path.exists(OPUS_FILE): + t.skip("Opus volume", "no Opus file available") + return + lib = opusplayer.OpusAudio() + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + # Valid volumes + for vol in (0, 1, 64, 100, 128): + r = lib.set_volume(aid, vol) + t.check(f"set_volume({vol}) ok", r[0] == 0, f"got={r}") + g = lib.get_volume(aid) + t.check("get_volume is int", isinstance(g, int), f"got={g}") + t.check("get_volume in range 0-128", 0 <= g <= 128, f"got={g}") + # Invalid volumes + for vol in (-1, 129, 200): + r = lib.set_volume(aid, vol) + t.check(f"set_volume({vol}) -> 1015", r[0] == 1015, f"got={r}") + # Invalid types + for vol in (None, '50', 1.5): + r = lib.set_volume(aid, vol) + t.check(f"set_volume({vol!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r}") + lib.stop_audio(aid) + # Invalid AID + r = lib.set_volume(99999, 50) + t.check("set_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.get_volume(99999) + t.check("get_volume(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-7] Opus Seek +# ============================================================================ +def test_opus_seek(t): + t.section("[OP-7] Opus Seek") + if not os.path.exists(OPUS_FILE): + t.skip("Opus seek", "no Opus file available") + return + lib = opusplayer.OpusAudio() + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + time.sleep(0.2) + # Valid seeks + for pos in (0.0, 1.0, 30.0, 100.5): + r = lib.seek_audio(aid, pos) + t.check(f"seek_audio({pos}) ok", r[0] == 0, f"got={r}") + t.check("still playing after seek", lib.is_music_playing() is True) + # Invalid types + for pos in (None, '5', [], {}): + r = lib.seek_audio(aid, pos) + t.check(f"seek_audio({pos!r}) -> tuple", isinstance(r, tuple), f"got={r}") + lib.stop_audio(aid) + r = lib.seek_audio(99999, 1.0) + t.check("seek_audio(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-8] Opus Fade In / Out +# ============================================================================ +def test_opus_fade(t): + t.section("[OP-8] Opus Fade In / Out") + if not os.path.exists(OPUS_FILE): + t.skip("Opus fade", "no Opus file available") + return + lib = opusplayer.OpusAudio() + # Fade-in play (via new_aid) + aid = lib.new_aid(OPUS_FILE) + r = lib.fadein_music(aid, ms=1000) + t.check("fadein_music ok", r[0] == 0, f"got={r}") + if r[0] == 0: + time.sleep(0.3) + fading = lib.get_music_fading() + t.check("get_music_fading=1 (fading in)", fading == 1, f"got={fading}") + t.check("playing during fadein", lib.is_music_playing() is True) + time.sleep(1.5) # wait for fade-in to complete + t.check("fading=0 after fadein", lib.get_music_fading() == 0, f"got={lib.get_music_fading()}") + # Fade-out + r = lib.fadeout_music(ms=1000) + t.check("fadeout_music ok", r[0] == 0, f"got={r}") + time.sleep(3) + t.check("stopped after fadeout", not lib.is_music_playing()) + # Fade-in from position + aid2 = lib.new_aid(OPUS_FILE) + r = lib.fadein_music_pos(aid2, ms=500, position=30.0) + t.check("fadein_music_pos ok", r[0] == 0, f"got={r}") + if r[0] == 0: + time.sleep(2) + lib.stop_audio(aid2) + # Error paths + r = lib.fadein_music(99999) + t.check("fadein_music(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + r = lib.fadein_music_pos(99999, ms=500) + t.check("fadein_music_pos(invalid AID) -> 1002", r[0] == 1002, f"got={r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-9] Opus vs Original Distinction +# ============================================================================ +def test_opus_distinction(t): + t.section("[OP-9] Opus vs Original Distinction") + # _is_opus_file detection + t.check("_is_opus_file(opus)=True", player._is_opus_file(OPUS_FILE) is True) + t.check("_is_opus_file(wav)=False", player._is_opus_file(WAV_FILE) is False) + t.check("_is_opus_file(.mp3)=False", player._is_opus_file('x.mp3') is False) + t.check("_is_opus_file(.ogg)=False", player._is_opus_file('x.ogg') is False) + # AudioLibrary routes opus to OpusAudio + lib = AudioLibrary() + opus_aid = lib.play_from_file(OPUS_FILE) + t.check("AudioLibrary play opus returns int", isinstance(opus_aid, int), f"got={opus_aid}") + if isinstance(opus_aid, int): + t.check("opus AID mapped", lib._is_opus_aid(opus_aid), f"mapping={lib._aid_to_opus_aid}") + lib.stop_audio(opus_aid) + # AudioLibrary routes wav to SDL2 + wav_aid = lib.play_from_file(WAV_FILE) + t.check("AudioLibrary play wav returns int", isinstance(wav_aid, int), f"got={wav_aid}") + if isinstance(wav_aid, int): + t.check("wav NOT mapped to opus", not lib._is_opus_aid(wav_aid), "wav uses SDL2") + lib.stop_audio(wav_aid) + # Metadata distinction + meta_opus = lib.get_audio_metadata_by_path(OPUS_FILE) + meta_wav = lib.get_audio_metadata_by_path(WAV_FILE) + t.check("opus metadata format=opus", isinstance(meta_opus, dict) and meta_opus.get('format') == 'opus', f"got={meta_opus.get('format') if isinstance(meta_opus,dict) else meta_opus}") + t.check("wav metadata format=wav", isinstance(meta_wav, dict) and meta_wav.get('format') == 'wav', f"got={meta_wav.get('format') if isinstance(meta_wav,dict) else meta_wav}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-10] Opus Batch Parsing +# ============================================================================ +def test_opus_batch(t): + t.section("[OP-10] Opus Batch Parsing") + if not os.path.exists(OPUS_FILE): + t.skip("Opus batch", "no Opus file available") + return + lib = opusplayer.OpusAudio() + files = [OPUS_FILE] + bl = lib.batch_get_metadata(files) + t.check("batch_get_metadata returns 1", len(bl) == 1, f"got={len(bl)}") + if bl: + t.check("batch metadata format=opus", bl[0].get('format') == 'opus', f"got={bl[0].get('format')}") + bd = lib.batch_get_duration(files) + t.check("batch_get_duration returns dict", isinstance(bd, dict), f"got={type(bd).__name__}") + t.check("batch_get_duration has opus", OPUS_FILE in bd, f"keys={list(bd.keys())}") + bt = lib.batch_get_metadata_by_type(files, 'opus') + t.check("batch_by_type opus -> 1", len(bt) == 1, f"got={len(bt)}") + bt2 = lib.batch_get_metadata_by_type(files, 'wav') + t.check("batch_by_type wav -> 0", len(bt2) == 0, f"got={len(bt2)}") + # Mixed batch via AudioLibrary (test opus and wav separately to avoid + # multiprocessing spawn limitations in test environments) + lib2 = AudioLibrary() + # Opus files in batch use opusplayer + opus_batch = lib2.batch_get_metadata([OPUS_FILE]) + t.check("AudioLibrary batch opus -> 1", len(opus_batch) == 1, f"got={len(opus_batch)}") + if opus_batch: + t.check("AudioLibrary batch opus format", opus_batch[0].get('format') == 'opus', f"got={opus_batch[0].get('format')}") + # WAV files in batch use audio_parser + wav_batch = lib2.batch_get_metadata([WAV_FILE]) + t.check("AudioLibrary batch wav -> 1", len(wav_batch) == 1, f"got={len(wav_batch)}") + if wav_batch: + t.check("AudioLibrary batch wav format", wav_batch[0].get('format') == 'wav', f"got={wav_batch[0].get('format')}") + lib2.cleanup_function() + lib.cleanup_function() + + +# ============================================================================ +# [OP-11] Opus Error Codes (normal / boundary / error) +# ============================================================================ +def test_opus_errors(t): + t.section("[OP-11] Opus Error Codes (normal / boundary / error)") + # Error code constants + err_codes = { + 'AP_DS_ERR_OPUS_LIB_LOAD_FAILED': 2001, + 'AP_DS_ERR_OPUS_DLL_DEPENDENCY': 2002, + 'AP_DS_ERR_OPUS_OPEN_FAILED': 2003, + 'AP_DS_ERR_OPUS_HEADER_CORRUPT': 2004, + 'AP_DS_ERR_OPUS_TAGS_PARSE_FAILED': 2005, + 'AP_DS_ERR_OPUS_DECODE_FAILED': 2006, + 'AP_DS_ERR_OPUS_SEEK_FAILED': 2007, + 'AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE': 2008, + 'AP_DS_ERR_OPUS_NOT_SEEKABLE': 2009, + 'AP_DS_ERR_OPUS_CHANNEL_INVALID': 2010, + } + for name, code in err_codes.items(): + t.check(f"{name}={code}", getattr(opusplayer, name, None) == code, f"got={getattr(opusplayer, name, None)}") + # OPUS_ERR_INFO table + t.check("OPUS_ERR_INFO defined", hasattr(opusplayer, 'OPUS_ERR_INFO')) + if hasattr(opusplayer, 'OPUS_ERR_INFO'): + for code in err_codes.values(): + t.check(f"OPUS_ERR_INFO[{code}]", code in opusplayer.OPUS_ERR_INFO) + # Normal error paths (corrupted file -> 2003) + lib = opusplayer.OpusAudio() + r = lib.get_audio_metadata_by_path(BAD_OPUS) + t.check("corrupted metadata -> 2003", isinstance(r, tuple) and r[0] == 2003, f"got={r}") + # Invalid source type + r = lib.get_audio_metadata(1.5) + t.check("invalid source type -> 1014", isinstance(r, tuple) and r[0] == 1014, f"got={r}") + # Missing file + r = lib.get_audio_metadata_by_path(os.path.join(TMP_DIR, 'missing.opus')) + t.check("missing file -> 1001", isinstance(r, tuple) and r[0] == 1001, f"got={r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-12] Opus AID Mapping (1:1) +# ============================================================================ +def test_opus_aid_mapping(t): + t.section("[OP-12] Opus AID Mapping (1:1)") + if not os.path.exists(OPUS_FILE): + t.skip("Opus AID mapping", "no Opus file available") + return + lib = AudioLibrary() + main_aid = lib.play_from_file(OPUS_FILE) + t.check("main AID is int", isinstance(main_aid, int), f"got={main_aid}") + if isinstance(main_aid, int): + t.check("AID mapped to opus", lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}") + opus_aid = lib._get_opus_aid(main_aid) + t.check("opus AID is int", isinstance(opus_aid, int), f"got={opus_aid}") + t.check("mapping is 1:1", isinstance(main_aid, int) and isinstance(opus_aid, int)) + r = lib.pause_audio(main_aid) + t.check("pause via main AID -> opus", r[0] == 0, f"got={r}") + r = lib.play_audio(main_aid) + t.check("resume via main AID -> opus", r[0] == 0, f"got={r}") + r = lib.set_volume(main_aid, 80) + t.check("set_volume via main AID -> opus", r[0] == 0, f"got={r}") + g = lib.get_volume(main_aid) + t.check("get_volume via main AID -> opus", isinstance(g, int), f"got={g}") + r = lib.seek_audio(main_aid, 30.0) + t.check("seek via main AID -> opus", r[0] == 0, f"got={r}") + r = lib.stop_audio(main_aid) + t.check("stop via main AID -> opus", isinstance(r, float), f"got={r}") + t.check("mapping removed after stop", not lib._is_opus_aid(main_aid), f"mapping={lib._aid_to_opus_aid}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-13] Opus Resource Management +# ============================================================================ +def test_opus_resources(t): + t.section("[OP-13] Opus Resource Management") + if not os.path.exists(OPUS_FILE): + t.skip("Opus resources", "no Opus file available") + return + lib = opusplayer.OpusAudio() + lib.play_from_file(OPUS_FILE) + t.check("playing before cleanup", lib.is_music_playing() is True) + lib.cleanup_function() + t.check("not playing after cleanup", not lib.is_music_playing()) + aid = lib.play_from_file(OPUS_FILE) + t.check("reusable after cleanup", isinstance(aid, int), f"got={aid}") + if isinstance(aid, int): + lib.stop_audio(aid) + lib.cleanup_function() + + + +# ============================================================================ +# [OP-14] Opus Boundary & Error Tests (reference CI/CD test_edge_cases) +# ============================================================================ +def test_opus_boundary(t): + t.section("[OP-14] Opus Boundary & Error Tests") + lib = opusplayer.OpusAudio() + + # --- B1: invalid argument types -> error tuples, never crash --- + t.log(" --- B1 invalid argument types ---") + for bad in (None, 1.5, [], {}, ('a',)): + r = lib.play_from_file(bad) + ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001 + t.check(f"play_from_file({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}") + for bad in (None, 1.5, []): + r = lib.new_aid(bad) + ok = isinstance(r, tuple) and len(r) == 3 and r[0] == 1001 + t.check(f"new_aid({type(bad).__name__}) -> tuple(1001)", ok, f"got={r!r}") + + # --- B2: seek / volume / fade boundary values --- + t.log(" --- B2 boundary values ---") + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + # Seek boundary + for pos in (None, '5', [], {}, -1.0, 0.0, 999999.0): + r = lib.seek_audio(aid, pos) + ok = isinstance(r, tuple) + t.check(f"seek_audio({pos!r}) -> tuple", ok, f"got={r!r}") + r = lib.seek_audio(aid, 0.0) + t.check("seek_audio(0.0) -> success", isinstance(r, tuple) and r[0] == 0, f"got={r!r}") + # Volume boundary + for v in (None, '50', 1.5, [], {}): + r = lib.set_volume(aid, v) + t.check(f"set_volume({v!r}) -> 1015", isinstance(r, tuple) and r[0] == 1015, f"got={r!r}") + for v in (0, 1, 64, 100, 128): + r = lib.set_volume(aid, v) + t.check(f"set_volume({v}) boundary -> success", r[0] == 0, f"got={r}") + for v in (-1, 129, 200, 1000): + r = lib.set_volume(aid, v) + t.check(f"set_volume({v}) out of range -> 1015", r[0] == 1015, f"got={r}") + # Fade boundary + r = lib.fadein_music(aid, ms=None) + t.check("fadein_music(ms=None) -> tuple", isinstance(r, tuple), f"got={r!r}") + r = lib.fadein_music_pos(aid, ms=100, position=None) + t.check("fadein_music_pos(position=None) -> tuple", isinstance(r, tuple), f"got={r!r}") + lib.stop_audio(aid) + + # --- B3: exact error codes --- + t.log(" --- B3 exact error codes ---") + cases = { + '1001 missing file': (lambda: lib.play_from_file(os.path.join(TMP_DIR, 'no_such.opus')), 1001), + '1002 invalid AID': (lambda: lib.pause_audio(99999), 1002), + '1014 invalid source type': (lambda: lib.get_audio_metadata(1.5), 1014), + '2003 corrupted opus': (lambda: lib.get_audio_metadata_by_path(BAD_OPUS), 2003), + } + for name, (fn, expect_code) in cases.items(): + r = fn() + ok = isinstance(r, tuple) and r[0] == expect_code + t.check(f"{name} -> {expect_code}", ok, f"got={r!r}") + lib.cleanup_function() + + +# ============================================================================ +# [OP-15] Opus DLL Specialized Tests (reference CI/CD test_sdl2_bindings) +# ============================================================================ +def test_opus_dll_deep(t): + t.section("[OP-15] Opus DLL Specialized Tests") + # DLL file presence and sizes + pkg = os.path.dirname(os.path.abspath(opusdll.__file__)) + dll_info = { + 'libopusfile-0.dll': 55884, + 'libopus-0.dll': 500112, + 'libogg-0.dll': 40580, + 'libopusurl-0.dll': 76772, + } + for dll, size in dll_info.items(): + path = os.path.join(pkg, dll) + t.check(f"{dll} exists", os.path.exists(path)) + if os.path.exists(path): + actual = os.path.getsize(path) + t.check(f"{dll} size={size}", actual == size, f"got={actual}") + # DLL hash verification + for dll in dll_info: + path = os.path.join(pkg, dll) + if os.path.exists(path): + with open(path, 'rb') as f: + content = f.read() + h = hashlib.sha256(content).hexdigest() + expected = opusdll.OPUS_DLL_HASHES.get(dll) + t.check(f"{dll} hash matches", h == expected, f"got={h[:16]}...") + # import_opus idempotent + r1 = opusdll.import_opus() + r2 = opusdll.import_opus() + t.check("import_opus idempotent", r1 == r2 == True, f"r1={r1} r2={r2}") + # opusfile handle + t.check("opusfile handle valid", opusdll.opusfile is not None) + # OPUS_DLL_FILES structure + t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4) + for dll in opusdll.OPUS_DLL_FILES: + t.check(f"DLL {dll['filename']} has url", 'url' in dll and dll['url'].startswith('http')) + t.check(f"DLL {dll['filename']} has size", 'size' in dll and dll['size'] > 0) + # check_opus_dll + ok, msg = opusplayer.check_opus_dll() + t.check("check_opus_dll ok", ok is True, f"msg={msg}") + + +# ============================================================================ +# [OP-16] Opus Error Code Trigger Tests (all 2001-2010) +# ============================================================================ +def test_opus_error_triggers(t): + t.section("[OP-16] Opus Error Code Trigger Tests") + lib = opusplayer.OpusAudio() + + # 2003: corrupted file open failure + r = lib.play_from_file(BAD_OPUS) + t.check("2003 OPEN_FAILED trigger", isinstance(r, tuple) and r[0] == 2003, f"got={r}") + + # 2004: HEADER_CORRUPT (monkey-patch op_head to return empty) + import ctypes + orig_head = opusdll.opusfile.op_head + opusdll.opusfile.op_head = lambda of, li: ctypes.POINTER(opusdll.OpusHead)() + r = opusplayer._get_opus_metadata(OPUS_FILE) + t.check("2004 HEADER_CORRUPT trigger", isinstance(r, tuple) and r[0] == 2004, f"got={r}") + opusdll.opusfile.op_head = orig_head + + # 2008: BITRATE_UNAVAILABLE (monkey-patch op_bitrate to return 0) + orig_bitrate = opusdll.opusfile.op_bitrate + opusdll.opusfile.op_bitrate = lambda of, li: 0 + r = opusplayer._get_opus_metadata(OPUS_FILE) + t.check("2008 BITRATE_UNAVAILABLE trigger", isinstance(r, tuple) and r[0] == 2008, f"got={r}") + opusdll.opusfile.op_bitrate = orig_bitrate + + # 2010: CHANNEL_INVALID (monkey-patch op_channel_count to return 0) + orig_channels = opusdll.opusfile.op_channel_count + opusdll.opusfile.op_channel_count = lambda of, li: 0 + r = opusplayer._get_opus_metadata(OPUS_FILE) + t.check("2010 CHANNEL_INVALID trigger", isinstance(r, tuple) and r[0] == 2010, f"got={r}") + opusdll.opusfile.op_channel_count = orig_channels + + # 2009: NOT_SEEKABLE (monkey-patch op_seekable to return 0) + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + orig_seekable = opusdll.opusfile.op_seekable + opusdll.opusfile.op_seekable = lambda of: 0 + r = lib.seek_audio(aid, 10.0) + t.check("2009 NOT_SEEKABLE trigger", isinstance(r, tuple) and r[0] == 2009, f"got={r}") + opusdll.opusfile.op_seekable = orig_seekable + lib.stop_audio(aid) + + # 2007: SEEK_FAILED (monkey-patch op_pcm_seek to return -1) + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + orig_seek = opusdll.opusfile.op_pcm_seek + opusdll.opusfile.op_pcm_seek = lambda of, pos: -1 + r = lib.seek_audio(aid, 10.0) + t.check("2007 SEEK_FAILED trigger", isinstance(r, tuple) and r[0] == 2007, f"got={r}") + opusdll.opusfile.op_pcm_seek = orig_seek + lib.stop_audio(aid) + + # 2005: TAGS_PARSE_FAILED (monkey-patch op_tags to raise) + class BadTags: + @property + def contents(self): + raise ValueError("corrupt tags") + orig_tags = opusdll.opusfile.op_tags + opusdll.opusfile.op_tags = lambda of, li: BadTags() + r = opusplayer._get_opus_extended_metadata(OPUS_FILE) + t.check("2005 TAGS_PARSE_FAILED trigger", isinstance(r, tuple) and r[0] == 2005, f"got={r}") + opusdll.opusfile.op_tags = orig_tags + + # 2006: DECODE_FAILED (monkey-patch op_read_stereo to return -1) + orig_read = opusdll.opusfile.op_read_stereo + opusdll.opusfile.op_read_stereo = lambda of, pcm, size: -1 + aid = lib.play_from_file(OPUS_FILE) + time.sleep(0.5) + r = lib._decode_error + t.check("2006 DECODE_FAILED trigger", r is not None and r[0] == 2006, f"got={r}") + opusdll.opusfile.op_read_stereo = orig_read + lib.stop_audio(aid) + + lib.cleanup_function() + + +# ============================================================================ +# [OP-L] Listening Tests (interactive, requires ears) +# ============================================================================ +def test_opus_listen(t): + t.section("[OP-L] Opus Listening Tests (interactive)") + if not os.path.exists(OPUS_FILE): + t.skip("Opus listening", "no Opus file available") + return + try: + ans = input("\nRun Opus listening tests? (y/n): ").strip().lower() + if ans != 'y': + t.skip("Opus listening", "user skipped") + return + except EOFError: + t.skip("Opus listening", "non-interactive") + return + + lib = opusplayer.OpusAudio() + t.log("\n--- Listening 1: Normal playback ---") + t.log(" Playing Opus at normal volume...") + aid = lib.play_from_file(OPUS_FILE) + if isinstance(aid, int): + time.sleep(3) + try: + ans = input(" Did you hear audio? (y/n): ").strip().lower() + t.check("normal playback audible", ans == 'y') + except EOFError: + t.skip("normal playback audible", "no input") + lib.stop_audio(aid) + + t.log("\n--- Listening 2: Fade-in ---") + t.log(" Fading in over 3 seconds...") + aid = lib.new_aid(OPUS_FILE) + r = lib.fadein_music(aid, ms=3000) + if r[0] == 0: + time.sleep(3) + try: + ans = input(" Did volume gradually increase? (y/n): ").strip().lower() + t.check("fade-in audible", ans == 'y') + except EOFError: + t.skip("fade-in audible", "no input") + time.sleep(1) + lib.stop_audio(aid) + + t.log("\n--- Listening 3: Fade-out ---") + t.log(" Playing at normal volume for 3 seconds, then fading out...") + aid = lib.play_from_file(OPUS_FILE) + time.sleep(3) # Play at normal volume first + r = lib.fadeout_music(ms=3000) + if r[0] == 0: + time.sleep(3) + try: + ans = input(" Did volume gradually decrease then stop? (y/n): ").strip().lower() + t.check("fade-out audible", ans == 'y') + except EOFError: + t.skip("fade-out audible", "no input") + time.sleep(1) + + t.log("\n--- Listening 4: Volume change ---") + t.log(" Playing at normal volume for 2s, then volume 128 -> 0 -> 128...") + aid = lib.play_from_file(OPUS_FILE) + time.sleep(2) # Play at normal volume first + lib.set_volume(aid, 128) + time.sleep(1) + lib.set_volume(aid, 0) + time.sleep(1) + try: + ans = input(" Did sound become silent at volume 0? (y/n): ").strip().lower() + t.check("volume 0 silent", ans == 'y') + except EOFError: + t.skip("volume 0 silent", "no input") + lib.set_volume(aid, 100) + time.sleep(1) + lib.stop_audio(aid) + + t.log("\n--- Listening 5: Seek position ---") + t.log(" Playing for 3s, then seeking to 30s, 60s, 120s...") + aid = lib.play_from_file(OPUS_FILE) + time.sleep(3) # Play at normal position first + for pos in (30, 60, 120): + lib.seek_audio(aid, pos) + time.sleep(1) + try: + ans = input(f" After seek to {pos}s, did position change? (y/n): ").strip().lower() + t.check(f"seek to {pos}s audible", ans == 'y') + except EOFError: + t.skip(f"seek to {pos}s audible", "no input") + lib.stop_audio(aid) + + lib.cleanup_function() + + +# ============================================================================ +# main() +# ============================================================================ +def main(): + global OPUS_FILE + mode = sys.argv[1].lower().lstrip('-') if len(sys.argv) > 1 else 'full' + if mode not in ('auto', 'listen', 'full'): + print(f"Unknown mode: {mode}, using full") + mode = 'full' + + # Prompt for Opus file path (use default if empty) + OPUS_FILE = _prompt_opus() + + print(f"\n Python: {sys.version.split()[0]} | Platform: {sys.platform} | Mode: {mode}") + print(f" ap_ds version: {ap_ds.__version__} | Path: {os.path.dirname(ap_ds.__file__)}") + print(f" Opus test file: {OPUS_FILE} (exists={os.path.exists(OPUS_FILE)})") + + t = OpusTest() + + if mode in ('auto', 'full'): + test_opus_imports(t) + test_opus_dll(t) + test_opus_metadata(t) + test_opus_play(t) + test_opus_control(t) + test_opus_volume(t) + test_opus_seek(t) + test_opus_fade(t) + test_opus_distinction(t) + test_opus_batch(t) + test_opus_errors(t) + test_opus_aid_mapping(t) + test_opus_resources(t) + test_opus_boundary(t) + test_opus_dll_deep(t) + test_opus_error_triggers(t) + + if mode in ('listen', 'full'): + test_opus_listen(t) + + t.summary() + return 0 if t.failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ap_ds/Test/__init__.py b/ap_ds/Test/__init__.py new file mode 100644 index 0000000..e96c016 --- /dev/null +++ b/ap_ds/Test/__init__.py @@ -0,0 +1,9 @@ +""" +ap_ds Test Suite +================ +CI/CD, GUI and Import tests for the ap_ds audio library. + +Note: Test scripts are included in the package for developers to run +after installation. They do not run automatically on import. +""" +__all__ = [] diff --git a/ap_ds/__init__.py b/ap_ds/__init__.py new file mode 100644 index 0000000..c839cf1 --- /dev/null +++ b/ap_ds/__init__.py @@ -0,0 +1,772 @@ +# __init__.py - Package entry point + +import os +import sys +import warnings + +try: + from ._version import __version__ +except ImportError: + try: + from _version import __version__ + except ImportError: + __version__ = "unknown" + + +# ============================================================ +# Export top-level functions from audio_parser +# ============================================================ + +try: + from .audio_parser import ( + batch_get_metadata, + batch_get_duration, + batch_get_metadata_by_type, + get_audio_duration, + get_audio_metadata, + ) +except ImportError: + try: + from audio_parser import ( + batch_get_metadata, + batch_get_duration, + batch_get_metadata_by_type, + get_audio_duration, + get_audio_metadata, + ) + except ImportError: + # Define as None if audio_parser not available + batch_get_metadata = None + batch_get_duration = None + batch_get_metadata_by_type = None + get_audio_duration = None + get_audio_metadata = None + +def is_full_performance() -> bool: + """Check if running in full performance mode.""" + info = _auto_check_runtime() + return info.get('is_full_performance', False) if info else False + +def get_runtime_info() -> dict: + """Get runtime information dictionary.""" + info = _auto_check_runtime() + return info.copy() if info else {} +# ============================================================ +# Banner +# ============================================================ + +if os.environ.get('AP_DS_HIDE_SUPPORT_PROMPT') != '1': + print(f"AP_DS © - Audio Library By DVS v{__version__} | https://apds.top") + + +# ============================================================ +# Runtime Environment Detection +# ============================================================ + +SUPPRESS_WARNINGS = os.environ.get('AP_DS_SUPPRESS_WARNINGS', '').lower() in ('1', 'true', 'yes', 'on') +SHOW_CONGRATS = os.environ.get('AP_DS_SHOW_CONGRATS', '').lower() not in ('0', 'false', 'no', 'off') +_AUTO_CHECK_SKIP = os.environ.get('AP_DS_SKIP_AUTO_CHECK', '1').lower() in ('1', 'true', 'yes', 'on') +# ============================================================ +# Show Technical Manual (User-Initiated) +# ============================================================ + +def show_tech_manual() -> None: + """ + Display the complete AP_DS 4.0.1 Technical Manual. + + This function prints a comprehensive technical reference including: + - Library architecture + - Supported audio formats + - Core components description + - API reference + - Performance tuning + - Environment variables + - Cross-platform notes + - Troubleshooting guide + + User must call this function explicitly. It will NOT be called automatically. + + Examples: + >>> from ap_ds import show_tech_manual + >>> show_tech_manual() + """ + manual = r""" +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ AP_DS 4.1.0 TECHNICAL MANUAL ║ +║ Audio Library By DVS - https://apds.top ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 1. OVERVIEW │ +└───────────────────────────────────────────────────────────────────────────────┘ + +AP_DS (Audio Playback & Data Service) is a cross-platform, high-performance +audio library for Python applications. Built on SDL2 and SDL2_mixer, it provides: + + • Low-latency audio playback + • Accurate metadata parsing (pure Python, no external dependencies) + • Smart WAV handling with automatic mode switching + • DAP (Dvs Audio Playlist) recording with O(1) deduplication + • Batch metadata extraction with multi-core parallelism + • Fade in/out controls with position seeking + • Memory-efficient caching with automatic cleanup + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 2. SUPPORTED AUDIO FORMATS │ +└───────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────┬─────────────┬─────────────────────────────────────────┐ + │ Format │ Extension │ Notes │ + ├──────────────┼─────────────┼─────────────────────────────────────────┤ + │ MP3 │ .mp3 │ Frame-by-frame scanning, >98% accuracy │ + │ WAV │ .wav │ RIFF chunk parsing, 100% accuracy │ + │ FLAC │ .flac │ STREAMINFO block, 100% accuracy │ + │ OGG Vorbis │ .ogg │ Granule position, 99.99% accuracy │ + │ AAC (ADTS) │ .aac │ ADTS frame parsing, >99% accuracy │ + │ OPUS │ .opus │ libopusfile decode, waveOut playback │ + └──────────────┴─────────────┴─────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 2.1 OPUS SUPPORT (NEW in 4.1.0) │ +└───────────────────────────────────────────────────────────────────────────────┘ + +AP_DS 4.1.0 adds native Opus playback support via libopusfile + winmm waveOut. +This is a separate playback path from SDL2, automatically selected when the +audio file is an Opus (.opus) file. + + Key Features: + • Automatic detection: Opus files are routed to OpusAudio sub-player + • AID mapping: Main library AID <-> Opus sub-library AID (1:1) + • Auto-download: Opus DLLs downloaded from https://dvsyun.top + • Hash verification: SHA256 verified after download + • Full control: play / pause / resume / stop / seek / volume / fade + + Modules: + • opusplayer.py - OpusAudio class (Opus playback engine) + • _opusdll.py - DLL loader + auto-download + hash verification + + OpusAudio class: + class OpusAudio(frequency=48000, channels=2, volume_pct=80) + Same API as AudioLibrary for Opus files: + play_from_file / new_aid / play_from_memory + pause_audio / play_audio / stop_audio / seek_audio + set_volume / get_volume + fadein_music / fadein_music_pos / fadeout_music + get_audio_metadata / get_audio_duration / batch_* + + Usage (automatic routing through AudioLibrary): + from ap_ds import AudioLibrary + lib = AudioLibrary() + aid = lib.play_from_file("song.opus") # auto-routed to OpusAudio + + Direct usage: + from ap_ds import OpusAudio + opus = OpusAudio() + aid = opus.play_from_file("song.opus") + + Opus DLLs (auto-downloaded from https://dvsyun.top/ap_ds/download/): + • libopusfile-0.dll (Opus file decoding) + • libopus-0.dll (Opus codec core) + • libogg-0.dll (Ogg container) + • libopusurl-0.dll (Opus URL streaming) + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 3. CORE COMPONENTS │ +└───────────────────────────────────────────────────────────────────────────────┘ + + 3.1 AudioLibrary (player.py) + ──────────────────────────── + Main class providing all audio playback and management functionality. + + Methods: + • play_from_file(file_path, loops=0, start_pos=0.0) -> int + Play audio directly from file, returns AID + + • play_from_memory(file_path, loops=0, start_pos=0.0) -> int + Play audio from memory cache, returns AID + + • new_aid(file_path) -> int + Generate AID without playing (preloads to cache) + + • pause_audio(aid) -> None + Pause audio playback + + • stop_audio(aid) -> float + Stop playback and return played duration + + • seek_audio(aid, position) -> None + Seek to specified position in seconds + + • set_volume(aid, volume) -> bool + Set volume (0-128) + + • get_volume(aid) -> int + Get current volume + + • fadein_music(aid, loops=-1, ms=0) -> bool + Fade in music + + • fadein_music_pos(aid, loops=-1, ms=0, position=0.0) -> bool + Fade in music from position + + • fadeout_music(ms=0) -> bool + Fade out music + + • clear_memory_cache() -> None + Clear all cached audio data + + • save_dap_to_json(save_path) -> bool + Save DAP recordings to .ap-ds-dap file + + • get_dap_recordings() -> List[Dict] + Get current DAP recordings + + • clear_dap_recordings() -> None + Clear all DAP recordings + + + 3.2 Metadata Parsers (audio_parser.py) + ────────────────────────────────────── + Pure-Python parsers for audio metadata extraction. + + Functions: + • get_audio_duration(file_path) -> int + Get duration in seconds + + • get_audio_metadata(file_path) -> Dict + Get complete metadata (duration, sample_rate, channels, bitrate) + + • batch_get_metadata(file_paths, max_workers=None, show_progress=False) -> List[Dict] + Parse multiple files in parallel + + • batch_get_duration(file_paths, max_workers=None) -> Dict[str, int] + Get durations for multiple files + + • batch_get_metadata_by_type(file_paths, file_type, max_workers=None) -> List[Dict] + Filter results by format + + + 3.3 SDL2 Loader (_sdl2.py) + ────────────────────────── + Cross-platform SDL2 library loader with automatic fallback. + + Loading order (Linux): + 1. Package directory + 2. User config (~/.config/ap_ds/sdl_paths.conf) + 3. System libraries + 4. Auto-install via package manager + 5. Interactive setup + + Loading order (Windows/macOS): + 1. Package directory + 2. System path + 3. Automatic download from CDN + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 4. DAP (Dvs Audio Playlist) SYSTEM │ +└───────────────────────────────────────────────────────────────────────────────┘ + +The DAP system automatically records every audio file that is played or loaded +through the AudioLibrary. Features: + + • O(1) Deduplication: Uses Python set for fast duplicate checking + • Fallback O(n): Linear scan if set deduplication fails + • Persistent Storage: Save to .ap-ds-dap JSON files + • Memory Efficient: Stores only metadata, not audio data + +Record Structure: + { + "path": "/path/to/audio.mp3", + "duration": 240, + "bitrate": 320000, + "channels": 2 + } + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 5. WAV SMART MODE │ +└───────────────────────────────────────────────────────────────────────────────┘ + +WAV files are automatically handled differently based on duration: + + ┌────────────────────┬─────────────────┬────────────────────────────────┐ + │ Duration │ Mode │ SDL2 API Used │ + ├────────────────────┼─────────────────┼────────────────────────────────┤ + │ < WAV_THRESHOLD │ Sound Effect │ Mix_PlayChannel (memory) │ + │ >= WAV_THRESHOLD │ Music │ Mix_PlayMusic (streaming) │ + └────────────────────┴─────────────────┴────────────────────────────────┘ + +Default threshold: 6 seconds +Configure via: AP_DS_WAV_THRESHOLD environment variable + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 6. ENVIRONMENT VARIABLES │ +└───────────────────────────────────────────────────────────────────────────────┘ + + AP_DS_WAV_THRESHOLD + ───────────────── + WAV mode switching threshold in seconds. + Default: 6 + Range: 0-29 (values >=30 reset to 6) + Example: AP_DS_WAV_THRESHOLD=10 + + AP_DS_SUPPRESS_WARNINGS + ───────────────────── + Suppress GIL warning messages. + Default: 0 (warnings enabled) + Values: 1, true, yes, on + Example: AP_DS_SUPPRESS_WARNINGS=1 + + AP_DS_SHOW_CONGRATS + ───────────────── + Show congratulations message when GIL is disabled. + Default: 1 (show) + Values: 0, false, no, off (to hide) + Example: AP_DS_SHOW_CONGRATS=0 + + AP_DS_SKIP_AUTO_CHECK + ─────────────────── + Skip runtime self-check on import. + Default: 1 (skip) + Values: 1, true, yes, on (to skip) + Example: AP_DS_SKIP_AUTO_CHECK=0 # Show self-check + + AP_DS_HIDE_SUPPORT_PROMPT + ────────────────────── + Hide the support prompt banner. + Default: 0 (show banner) + Values: 1 + Example: AP_DS_HIDE_SUPPORT_PROMPT=1 + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 7. PERFORMANCE OPTIMIZATION │ +└───────────────────────────────────────────────────────────────────────────────┘ + + 7.1 Free-Threading Support + ────────────────────────── + AP_DS 4.0.1 is optimized for Python 3.15t (free-threading mode). + When running with GIL disabled, performance improves significantly: + + • Batch metadata parsing uses ProcessPoolExecutor + • Multiple audio operations can run in parallel + • Lower latency for concurrent playback + + To enable free-threading: + Download Python 3.15t from: + https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip + + + 7.2 Batch Processing + ──────────────────── + Use batch APIs for processing multiple files: + + metadata = batch_get_metadata(directory, max_workers=4, show_progress=True) + + Workers default to CPU count. Adjust based on: + • I/O bound: Use more workers (CPU count * 2) + • CPU bound: Use CPU count (or CPU count - 1 on 4+ cores) + + + 7.3 Memory Management + ───────────────────── + Audio data is cached in memory. To manage memory: + + • Use new_aid() to preload without playing + • Call clear_memory_cache() periodically for long-running apps + • WAV files under 6 seconds are cached as Mix_Chunk in memory + • WAV files over 6 seconds stream via Mix_Music + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 8. CROSS-PLATFORM NOTES │ +└───────────────────────────────────────────────────────────────────────────────┘ + + 8.1 Windows + ─────────── + • DLLs automatically downloaded from CDN + • SDL2.dll and SDL2_mixer.dll placed in package directory + • os.add_dll_directory() used for modern Windows + • PATH environment variable updated automatically + + 8.2 macOS + ───────── + • Frameworks downloaded as DMG and auto-extracted + • SDL2.framework and SDL2_mixer.framework + • DYLD_FRAMEWORK_PATH updated automatically + • Supports both Intel (x64) and Apple Silicon (ARM) + + 8.3 Linux + ───────── + • No automatic download (distribution compatibility) + • Uses system package manager when possible + • Manual installation instructions provided + • Supports: Ubuntu/Debian (apt), Fedora (dnf), Arch (pacman) + • LD_LIBRARY_PATH updated when loading from package + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 9. TROUBLESHOOTING │ +└───────────────────────────────────────────────────────────────────────────────┘ + + 9.1 "Failed to load music file" + ────────────────────────────── + • Verify file exists and is readable + • Check if SDL2_mixer supports the format + • For WAV files > 6s, ensure file is valid PCM + + 9.2 "SDL initialization failed" + ──────────────────────────────── + • SDL2 library not loaded properly + • On Windows, check antivirus isn't blocking DLLs + • On Linux, install SDL2 development packages + + 9.3 "audio_parser not available" + ────────────────────────────────── + • audio_parser.py missing from package + • Reinstall ap_ds: pip install --upgrade ap_ds + + 9.4 "GIL is enabled" warning + ────────────────────────────── + • Running on standard Python (non-free-threading) + • Upgrade to Python 3.15t for full performance + • Or suppress with AP_DS_SUPPRESS_WARNINGS=1 + + 9.5 DAP recordings not saving + ────────────────────────────── + • Check file extension: must be .ap-ds-dap + • Verify write permissions on save location + • Ensure at least one file was played/loaded + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 10. API REFERENCE │ +└───────────────────────────────────────────────────────────────────────────────┘ + + 10.1 AudioLibrary Class + ─────────────────────── + class AudioLibrary(frequency=44100, format=MIX_DEFAULT_FORMAT, + channels=2, chunksize=2048) + + 参数: + frequency: Audio sample rate (Hz) + format: Audio format (MIX_DEFAULT_FORMAT) + channels: Number of channels (1=mono, 2=stereo) + chunksize: Audio buffer size + + 10.2 AID (Audio ID) System + ────────────────────────── + Every audio playback/load returns a unique AID. + Use AID to control playback: + aid = lib.play_from_file("song.mp3") + lib.pause_audio(aid) + lib.seek_audio(aid, 30.0) + lib.stop_audio(aid) + + 10.3 Channel vs Music + ───────────────────── + Sound Effect Mode (Mix_PlayChannel): + • Up to 8 simultaneous sounds + • Loaded into memory (Mix_Chunk) + • Low latency + • Best for short sounds (<6s) + + Music Mode (Mix_PlayMusic): + • One at a time + • Streamed from disk (Mix_Music) + • Supports seeking and fading + • Best for long tracks (>=6s) + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 10.4 Opus Error Codes (NEW in 4.1.0) │ +└───────────────────────────────────────────────────────────────────────────────┘ + + Opus-specific error codes (2000+): + ┌──────────┬────────────────────────────────────────────┬──────────────────────────────────┐ + │ Code │ Name │ Description │ + ├──────────┼────────────────────────────────────────────┼──────────────────────────────────┤ + │ 2001 │ AP_DS_ERR_OPUS_LIB_LOAD_FAILED │ libopusfile-0.dll load failed │ + │ 2002 │ AP_DS_ERR_OPUS_DLL_DEPENDENCY │ DLL dependency missing │ + │ 2003 │ AP_DS_ERR_OPUS_OPEN_FAILED │ Opus file open failed │ + │ 2004 │ AP_DS_ERR_OPUS_HEADER_CORRUPT │ OpusHead header corrupt │ + │ 2005 │ AP_DS_ERR_OPUS_TAGS_PARSE_FAILED │ OpusTags tag parse failed │ + │ 2006 │ AP_DS_ERR_OPUS_DECODE_FAILED │ Opus decode failed │ + │ 2007 │ AP_DS_ERR_OPUS_SEEK_FAILED │ Opus seek failed │ + │ 2008 │ AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE │ Bitrate unavailable │ + │ 2009 │ AP_DS_ERR_OPUS_NOT_SEEKABLE │ Stream not seekable │ + │ 2010 │ AP_DS_ERR_OPUS_CHANNEL_INVALID │ Invalid channel count │ + └──────────┴────────────────────────────────────────────┴──────────────────────────────────┘ + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 11. VERSION HISTORY │ +└───────────────────────────────────────────────────────────────────────────────┘ + + Version 4.1.0 (Current) + ──────────────────────── + • Opus playback support (libopusfile + waveOut) + • OpusAudio class with automatic routing + • Opus DLL auto-download + SHA256 verification + • Opus-specific error codes (2001-2010) + • Opus batch parsing + • Opus vs original format distinction + + Version 4.0.1 + ───────────── + • Python 3.15t free-threading support + • Lazy imports for Python 3.15+ + • DAP O(1) deduplication + • Batch metadata extraction + • Smart WAV mode switching + • Audio metadata parsers (pure Python) + • Cross-platform SDL2 loader + + Version 3.x + ─────────── + • Initial SDL2 bindings + • Audio playback and control + • Volume control + • Basic metadata support + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 12. CONTRIBUTING & SUPPORT │ +└───────────────────────────────────────────────────────────────────────────────┘ + + Website: https://apds.top + Source Code: https://gitcode.com/dvsxt/ap_ds + Documentation: https://apds.top/docs + Issues: https://gitcode.com/dvsxt/ap_ds/issues + License: MIT + + Author: DVS + Email: support@apds.top + +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ END OF MANUAL ║ +║ AP_DS 4.1.0 - August 2026 ║ +║ ║ +║ 📖 For detailed Markdown documentation, visit: ║ +║ https://apds.top ║ +║ https://gitcode.com/dvsxt/ap_ds ║ +║ ║ +║ 📝 View source code: ║ +║ https://gitcode.com/dvsxt/ap_ds ║ +║ ║ +║ 💬 Report issues: ║ +║ https://gitcode.com/dvsxt/ap_ds/issues ║ +║ ║ +║ 💡 Quick start: ║ +║ from ap_ds import AudioLibrary ║ +║ lib = AudioLibrary() ║ +║ aid = lib.play_from_file("music.mp3") ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ +""" + print(manual) +def _check_runtime_mode(): + """ + Check GIL status and notify the user accordingly. + + Returns: + bool: True if GIL is enabled, False if disabled (free-threading) + """ + try: + gil_enabled = sys._is_gil_enabled() + except AttributeError: + gil_enabled = True + + if not gil_enabled: + if SHOW_CONGRATS: + print("🎉 ap_ds: GIL disabled (free-threading mode)") + else: + if not SUPPRESS_WARNINGS: + warnings.warn( + "⚠️ ap_ds: GIL is enabled (multi-core parallelism limited).\n" + " For full performance, upgrade to Python 3.15t:\n" + " https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip\n" + " To suppress this warning, set AP_DS_SUPPRESS_WARNINGS=1", + RuntimeWarning, + stacklevel=2 + ) + return gil_enabled +def _auto_check_runtime(): + """ + Automatic runtime self-check on library import. + + Prints diagnostic information including: + - Python version + - GIL status + - Profiling availability + - Performance mode + - CPU cores + - Platform + - Library info (name, version, install path, website, author) + + Can be disabled by setting environment variable: + AP_DS_SKIP_AUTO_CHECK=1 + + Returns: + dict: Runtime information dictionary + """ + if _AUTO_CHECK_SKIP: + return None + + print("\n" + "=" * 60) + print("🔍 ap_ds Runtime Self-Check") + print("=" * 60) + + # Library Info + print(f"📚 Library: AP_DS (Audio Library By DVS)") + print(f"📌 Version: {__version__}") + print(f"📂 Install Path: {os.path.dirname(os.path.abspath(__file__))}") + print(f"🌐 Website: https://apds.top") + print(f"📦 PyPI: https://pypi.org/project/ap-ds/") + print(f"📦 Mirror: https://pypi.tuna.tsinghua.edu.cn/simple/ap-ds/") + print() + print("📥 Installation:") + print(" pip install ap-ds==4.0.1") + print(" pip install ap-ds==4.0.1 -i https://pypi.tuna.tsinghua.edu.cn/simple") + print(" pip install /path/to/ap-ds-4.0.1-py3-none-any.whl") + print(f"👤 Author: DVS") + print() + print("📖 Description:") + print(" AP_DS (Audio Playback & Data Service) is a cross-platform audio") + print(" library built on SDL2 and SDL2_mixer, designed for Python applications") + print(" requiring high-performance audio playback and metadata management.") + print() + print(" Core Features:") + print(" • Audio Playback: MP3, WAV, FLAC, OGG, AAC, and more") + print(" • Smart WAV Handling: Auto-switch between music/sound effect mode") + print(" • Metadata Parsing: Duration, sample rate, channels, bitrate") + print(" • DAP Recording: O(1) deduplication playlist generation") + print(" • Batch Processing: Multi-core parallel metadata extraction") + print(" • Fade Control: Fade in/out with position seeking support") + print(" • Memory Management: Efficient caching with automatic cleanup") + print() + print(" Performance:") + print(" • Native SDL2 bindings with zero-copy audio processing") + print(" • Free-threading support (Python 3.15t) for maximum parallelism") + print(" • ProcessPoolExecutor for CPU-bound batch operations") + print() + print(" Platform Support:") + print(" • Windows (x64) • macOS (x64/ARM) • Linux (x64/ARM)") + print() + print(" Documentation: https://apds.top/docs") + print(" Source Code: https://gitcode.com/dvsxt/ap_ds") + print(" License: DVS Audio Library (ap_ds) Open Source License Version 2.0") + print() + print(f"🐍 Python: {sys.version.split()[0]} ({sys.implementation.name})") + + try: + gil_enabled = sys._is_gil_enabled() + print(f"🔒 GIL: {'Enabled' if gil_enabled else 'Disabled (Free-Threading)! 🎉'}") + except AttributeError: + gil_enabled = True + print(f"🔒 GIL: Unknown (pre-3.14, assumed Enabled)") + + try: + import profiling + has_profiling = True + print(f"📊 Profiling: Available (Python 3.15+)") + except ImportError: + has_profiling = False + print(f"📊 Profiling: Not available (requires Python 3.15+)") + + is_full = has_profiling and not gil_enabled + print(f"🚀 Full Performance Mode: {'✅ YES! (3.15t)' if is_full else '❌ No (degraded mode)'}") + + print(f"💻 CPU Cores: {os.cpu_count() or 0}") + print(f"🖥️ Platform: {sys.platform}") + print("=" * 60) + + if not is_full: + print("💡 Tip: Upgrade to Python 3.15t for full performance:") + print(" https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip") + print(" To suppress this auto-check, set AP_DS_SKIP_AUTO_CHECK=1") + else: + print("🎉 You're running in full performance mode! Enjoy!") + + print("=" * 60 + "\n") + + return { + "library_name": "AP_DS", + "library_version": __version__, + "library_install_path": os.path.dirname(os.path.abspath(__file__)), + "library_website": "https://apds.top", + "library_author": "DVS", + "python_version": sys.version.split()[0], + "gil_enabled": gil_enabled, + "has_profiling": has_profiling, + "is_full_performance": is_full, + "cpu_count": os.cpu_count() or 0, + "platform": sys.platform, + } +# ============================================================ +# Runtime Self-Check on Import +# ============================================================ + +_RUNTIME_CHECKED = False + +def ensure_runtime_checked(): + """Ensure runtime check is performed only once.""" + global _RUNTIME_CHECKED + if not _RUNTIME_CHECKED: + _check_runtime_mode() + _RUNTIME_CHECKED = True + +ensure_runtime_checked() + +# Execute auto self-check on import (user can call again later) +_auto_check_runtime() + + +# ============================================================ +# Import Player Module (AudioLibrary and all core functions) +# ============================================================ + +try: + from .player import * +except ImportError: + from player import * + +# ============================================================ +# Opus Support (opusplayer.py) +# ============================================================ +try: + from .opusplayer import OpusAudio +except ImportError: + try: + from opusplayer import OpusAudio + except ImportError: + OpusAudio = None + +# ============================================================ +# Export Self-Check Functions (users can call manually) +# ============================================================ + +try: + # Try direct assignment first (functions already defined in this module) + auto_check_runtime = _auto_check_runtime + check_runtime_mode = _check_runtime_mode +except Exception: + # Fallback: import from current package + try: + from . import _auto_check_runtime as auto_check_runtime + from . import _check_runtime_mode as check_runtime_mode + except Exception: + # Final fallback: define as None + auto_check_runtime = None + check_runtime_mode = None + +# ============================================================ +# Public API +# ============================================================ +# __init__.py +__all__ = [ + "__version__", + "AudioLibrary", + "OpusAudio", + "get_audio_duration", + "get_audio_metadata", + "batch_get_metadata", + "batch_get_duration", + "batch_get_metadata_by_type", + "auto_check_runtime", + "check_runtime_mode", + "show_tech_manual", +] diff --git a/ap_ds/_opusdll.py b/ap_ds/_opusdll.py new file mode 100644 index 0000000..a91f716 --- /dev/null +++ b/ap_ds/_opusdll.py @@ -0,0 +1,946 @@ +# _opusdll.py - Opus DLL constants, structures, bindings and auto-download loader +# Reference: ap_ds/_sdl2.py structure + +import os +import sys +import ssl +import hashlib +import tempfile +import shutil +import urllib.request +import ctypes +import ctypes.wintypes as wt +from ctypes import * + + +# ============================================================ +# Opus DLL Library Loader +# ============================================================ + +# Global library handles +opusfile = None # libopusfile-0.dll +winmm = None # winmm.dll (system) +kernel32 = None # kernel32.dll (system) +_opus_dll_error = None # Records DLL load failure reason + +# DLL file list (from the download API) +OPUS_DLL_FILES = [ + { + "filename": "libopusurl-0.dll", + "url": "https://dvsyun.top/ap_ds/download/libopusurl-0.dll", + "size": 76772, + }, + { + "filename": "libopus-0.dll", + "url": "https://dvsyun.top/ap_ds/download/libopus-0.dll", + "size": 500112, + }, + { + "filename": "libogg-0.dll", + "url": "https://dvsyun.top/ap_ds/download/libogg-0.dll", + "size": 40580, + }, + { + "filename": "libopusfile-0.dll", + "url": "https://dvsyun.top/ap_ds/download/libopusfile-0.dll", + "size": 55884, + }, +] + +# DLL SHA256 hashes for verification (after download) +OPUS_DLL_HASHES = { + "libopusfile-0.dll": "fc8ff75c5e0180e73b0528dc78c51ed0fb493741375cdc227f50c2a33cabf727", + "libopus-0.dll": "90aa25a0a6525d7da48a7ae8dd3306e45b0c28ce09a73d2a02b56cd95418d5be", + "libogg-0.dll": "3038ce8d161324a6349bf7c83b78493857ff6a3501e3adb3d541c6a07bd94a57", + "libopusurl-0.dll": "a6cde968a23f2d0067332a13718c52e265653a2c35d65862e8dff4cf2a0346d9", +} + + +def _get_package_dir(): + """Get the directory containing this module.""" + return os.path.dirname(os.path.abspath(__file__)) + + +def _check_opus_libraries_exist(directory): + """Check if all Opus DLLs exist in the given directory.""" + required = ["libopusfile-0.dll", "libopus-0.dll", "libogg-0.dll"] + for dll in required: + if not os.path.exists(os.path.join(directory, dll)): + return False + return True + + +def _load_from_directory(directory): + """Load Opus libraries from the specified directory. + + Supports both Windows (.dll) and Linux (.so) library names. + + Returns: + bool: True if the Opus library loaded successfully + """ + global opusfile + platform = sys.platform + + # Determine library filename based on platform + if platform == "win32": + libopusfile_path = os.path.join(directory, "libopusfile-0.dll") + elif platform.startswith("linux"): + # Try multiple Linux .so naming conventions + names_to_try = [ + "libopusfile.so", + "libopusfile.so.0", + "libopusfile-0.so", + ] + libopusfile_path = None + for name in names_to_try: + candidate = os.path.join(directory, name) + if os.path.exists(candidate): + libopusfile_path = candidate + break + if libopusfile_path is None: + return False + else: + # macOS (not added yet) or other + libopusfile_path = os.path.join(directory, "libopusfile-0.dll") + if not os.path.exists(libopusfile_path): + return False + + if not os.path.exists(libopusfile_path): + return False + + try: + # Add directory to library search path + if platform == "win32": + if hasattr(os, 'add_dll_directory'): + os.add_dll_directory(directory) + os.environ['PATH'] = directory + os.pathsep + os.environ.get('PATH', '') + elif platform.startswith("linux"): + if 'LD_LIBRARY_PATH' not in os.environ: + os.environ['LD_LIBRARY_PATH'] = directory + else: + os.environ['LD_LIBRARY_PATH'] = directory + ':' + os.environ['LD_LIBRARY_PATH'] + + opusfile = ctypes.CDLL(libopusfile_path) + return True + except Exception as e: + _opus_dll_error = f"Opus library load error: {e}" + return False + + +def _load_from_system(): + """Try loading libopusfile from system paths.""" + global opusfile + try: + import ctypes.util + found = ctypes.util.find_library("opusfile") + if found: + opusfile = ctypes.CDLL(found) + return True + except Exception: + pass + return False + + +def _load_user_config(): + """Load user-saved Opus library paths from config file.""" + global opusfile + try: + config_file = os.path.expanduser('~/.config/ap_ds/opus_paths.conf') + if os.path.exists(config_file): + with open(config_file, 'r') as f: + opusfile_path = None + for line in f: + if line.startswith('OPUSFILE_PATH='): + opusfile_path = line.strip().split('=', 1)[1] + if opusfile_path and os.path.exists(opusfile_path): + opusfile = ctypes.CDLL(opusfile_path) + return True + except Exception: + pass + return False + + +def _run_sudo_command(cmd, packages): + """Run a sudo command with interactive password input. + + First tries without password (if already root or passwordless sudo). + If that fails, prompts for the sudo password interactively. + + Args: + cmd: Base command list (e.g. ['apt-get', 'install', '-y']) + packages: Package names to install + + Returns: + bool: True if command succeeded + """ + import subprocess + import getpass + + full_cmd = cmd + packages + + # Try without password first (if already root / passwordless sudo) + try: + result = subprocess.run( + ['sudo', '-n'] + full_cmd, + capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + return True + except Exception: + pass + + # Prompt for sudo password interactively + print("🔑 sudo password required for package installation") + try: + password = getpass.getpass("Enter sudo password: ") + except (EOFError, KeyboardInterrupt): + print("\n❌ Password input cancelled") + return False + + # Use sudo -S to read password from stdin + try: + result = subprocess.run( + ['sudo', '-S'] + full_cmd, + input=password + '\n', + capture_output=True, text=True, timeout=180 + ) + if result.returncode == 0: + print("✅ Packages installed successfully") + return True + else: + print(f"❌ Installation failed: {result.stderr.strip()}") + return False + except Exception as e: + print(f"❌ Installation error: {e}") + return False + + +def _linux_auto_install(): + """Try automatic package manager installation on Linux. + + Uses interactive sudo password input to avoid hanging. + """ + try: + import subprocess + import shutil + + if shutil.which('apt-get'): + print("📦 Detected apt-based system (Ubuntu/Debian)") + if _run_sudo_command(['apt-get', 'install', '-y'], + ['libopusfile-dev', 'libopus-dev', 'libogg-dev']): + if _load_from_system(): + print("✅ Opus libraries installed and loaded") + return True + + elif shutil.which('dnf'): + print("📦 Detected dnf-based system (Fedora)") + if _run_sudo_command(['dnf', 'install', '-y'], + ['opusfile-devel', 'opus-devel', 'libogg-devel']): + if _load_from_system(): + print("✅ Opus libraries installed and loaded") + return True + + elif shutil.which('pacman'): + print("📦 Detected pacman-based system (Arch)") + if _run_sudo_command(['pacman', '-S', '--noconfirm'], + ['opusfile', 'opus', 'libogg']): + if _load_from_system(): + print("✅ Opus libraries installed and loaded") + return True + + except Exception as e: + print(f"⚠️ Automatic installation failed: {e}") + return False + +def _linux_interactive_setup(): + """Linux interactive setup for Opus libraries.""" + global opusfile + print("\n" + "=" * 70) + print("Linux Opus Library Loading") + print("=" * 70) + print("Options:") + print("1. Use system-installed libraries (re-check)") + print("2. Specify path to your compiled .so files") + print("3. Show installation instructions") + print("=" * 70) + + while True: + choice = input("\nChoose option (1/2/3): ").strip() + + if choice == "1": + if _load_from_system(): + print("✅ Opus libraries loaded from system") + return True + print("❌ System libraries not found") + continue + + elif choice == "2": + opusfile_path = input("Enter full path to libopusfile.so: ").strip() + if os.path.exists(opusfile_path): + try: + opusfile = ctypes.CDLL(opusfile_path) + # Save for future + try: + config_dir = os.path.expanduser('~/.config/ap_ds') + os.makedirs(config_dir, exist_ok=True) + with open(os.path.join(config_dir, 'opus_paths.conf'), 'w') as f: + f.write(f"OPUSFILE_PATH={opusfile_path}\n") + except Exception: + pass + print("✅ Libraries loaded from user-specified paths") + return True + except Exception as e: + print(f"❌ Failed to load: {e}") + else: + print("❌ File not found") + continue + + elif choice == "3": + print("\n📚 Installation instructions:") + print("=" * 50) + print("For Ubuntu/Debian:") + print(" sudo apt-get install libopusfile-dev libopus-dev libogg-dev") + print("\nFor Fedora:") + print(" sudo dnf install opusfile-devel opus-devel libogg-devel") + print("\nFor Arch:") + print(" sudo pacman -S opusfile opus libogg") + print("=" * 50) + continue + + else: + print("❌ Invalid choice. Please enter 1, 2, or 3.") + + +def _macos_apology(): + """Print a humble apology about missing Opus precompiled packages on macOS.""" + print("\n" + "=" * 70) + print("⚠️ macOS Opus Support Notice") + print("=" * 70) + print("We sincerely apologize.") + print("On macOS, SDL2 libraries can be downloaded automatically, but we") + print("could NOT find any precompiled Opus framework packages for macOS.") + print("This is a limitation of the Opus ecosystem, not of ap_ds.") + print("") + print("We recommend using a package manager to install the Opus libraries.") + print("ap_ds will first try to auto-install them for you.") + print("If that fails, we will guide you through manual installation.") + print("=" * 70 + "\n") + + +def _macos_detect_system(): + """Detect if Opus libraries are already present on macOS system paths.""" + global opusfile + try: + import ctypes.util + found = ctypes.util.find_library("opusfile") + if found: + opusfile = ctypes.CDLL(found) + return True + except Exception: + pass + # Check common macOS library paths + for path in [ + "/opt/homebrew/lib/libopusfile.dylib", # Apple Silicon Homebrew + "/usr/local/lib/libopusfile.dylib", # Intel Homebrew + "/opt/local/lib/libopusfile.dylib", # MacPorts + ]: + if os.path.exists(path): + try: + opusfile = ctypes.CDLL(path) + return True + except Exception: + pass + return False + + +def _macos_install_macports(): + """Try installing Opus libraries via MacPorts.""" + import subprocess + import shutil + + if shutil.which('port'): + print("📦 Detected MacPorts") + try: + result = subprocess.run( + ['sudo', '-n', 'port', 'install', 'opus', 'opusfile', 'libogg'], + capture_output=True, text=True, timeout=180 + ) + if result.returncode == 0: + if _macos_detect_system(): + print("✅ Opus libraries installed via MacPorts") + return True + except Exception: + pass + # If sudo -n failed (needs password), prompt for password + import getpass + print("🔑 sudo password required for MacPorts installation") + try: + password = getpass.getpass("Enter sudo password: ") + result = subprocess.run( + ['sudo', '-S', 'port', 'install', 'opus', 'opusfile', 'libogg'], + input=password + '\n', capture_output=True, text=True, timeout=300 + ) + if result.returncode == 0 and _macos_detect_system(): + print("✅ Opus libraries installed via MacPorts") + return True + except Exception as e: + print(f"❌ MacPorts installation failed: {e}") + return False + + +def _macos_install_homebrew(): + """Try installing Opus libraries via Homebrew.""" + import subprocess + import shutil + + if shutil.which('brew'): + print("📦 Detected Homebrew") + try: + result = subprocess.run( + ['brew', 'install', 'opus', 'opusfile', 'libogg'], + capture_output=True, text=True, timeout=300 + ) + if result.returncode == 0: + if _macos_detect_system(): + print("✅ Opus libraries installed via Homebrew") + return True + except Exception as e: + print(f"❌ Homebrew installation failed: {e}") + return False + + +def _macos_guide_manual_install(): + """Guide the user through manual installation of Opus libraries.""" + print("\n" + "=" * 70) + print("📚 Manual Installation Guide (macOS)") + print("=" * 70) + print("We could not auto-install the Opus libraries. Please install them") + print("using one of the following methods:") + print("") + print("Method 1: Install Homebrew (if not installed)") + print(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"") + print(" Then: brew install opus opusfile libogg") + print("") + print("Method 2: Install MacPorts") + print(" https://www.macports.org/install.php") + print(" Then: sudo port install opus opusfile libogg") + print("") + print("Method 3: Compile from source") + print(" Download from https://opus-codec.org/downloads/") + print(" opus-1.6.1.tar.gz, opusfile-0.12.tar.gz, libogg-1.3.6.tar.gz") + print(" Compile each with: ./configure && make && sudo make install") + print("=" * 70 + "\n") + + +def _macos_auto_install(): + """Try to auto-install Opus libraries on macOS. + + Order: detect system -> MacPorts -> Homebrew -> manual guide. + """ + _macos_apology() + + # Step 1: Detect if already present + if _macos_detect_system(): + print("✅ Opus libraries already present on system") + return True + + # Step 2: Try MacPorts + print("\n📦 Attempting MacPorts installation...") + if _macos_install_macports(): + return True + + # Step 3: Try Homebrew + print("\n📦 Attempting Homebrew installation...") + if _macos_install_homebrew(): + return True + + # Step 4: Guide manual installation + _macos_guide_manual_install() + return False + + +def _check_opus_libraries_exist_linux(directory): + """Check if Opus .so libraries exist in directory (Linux).""" + return os.path.exists(os.path.join(directory, "libopusfile.so")) + + +def verify_file_hash(file_path, expected_hash): + """Verify the SHA256 hash of a file. + + Args: + file_path: Path to the file to verify + expected_hash: Expected SHA256 hash (hex string) + + Returns: + bool: True if hash matches (or no hash configured) + """ + if not expected_hash: + print(f" ⚠️ No hash configured for {os.path.basename(file_path)}, skipping verification") + return True + + try: + with open(file_path, 'rb') as f: + content = f.read() + file_hash = hashlib.sha256(content).hexdigest() + print(f" Existing file SHA256: {file_hash}") + if file_hash.lower() == expected_hash.lower(): + print(f" ✅ Hash verification passed") + return True + else: + print(f" ❌ Hash verification failed! Expected: {expected_hash}, Got: {file_hash}") + return False + except Exception as e: + print(f" ❌ Error verifying hash: {e}") + return False + + +def download_opus_libraries(): + """Download all Opus DLLs to the package directory with auto-download and hash verification. + + Returns: + bool: True if all DLLs downloaded successfully + """ + current_dir = _get_package_dir() + print(f"Package directory: {current_dir}") + + def download_file(url, filename, expected_hash=None): + """Download a single file with SSL fallback and hash verification.""" + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + try: + print(f" Downloading {filename} from {url}...") + try: + # Try with SSL verification first + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=30) as response: + content = response.read() + print(f" ✅ Download successful with SSL verification") + except (urllib.error.URLError, ssl.SSLError) as e: + print(f" SSL verification failed: {e}") + print(f" Retrying without SSL verification...") + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=30, context=context) as response: + content = response.read() + print(f" ✅ Download successful without SSL verification") + + with open(temp_file.name, 'wb') as f: + f.write(content) + print(f" Downloaded {len(content)} bytes") + + # Verify hash before moving + if expected_hash: + file_hash = hashlib.sha256(content).hexdigest() + print(f" Downloaded file SHA256: {file_hash}") + if file_hash.lower() != expected_hash.lower(): + print(f" ❌ Hash verification failed! Expected: {expected_hash}, Got: {file_hash}") + try: + os.unlink(temp_file.name) + except Exception: + pass + return False + print(f" ✅ Hash verification passed") + + # Move to package directory + dest_path = os.path.join(current_dir, filename) + shutil.move(temp_file.name, dest_path) + print(f" ✅ Saved to {dest_path}") + return True + except Exception as e: + print(f" ❌ Download failed for {filename}: {e}") + try: + os.unlink(temp_file.name) + except Exception: + pass + return False + + # Download each DLL with hash verification + success_count = 0 + for dll_info in OPUS_DLL_FILES: + filename = dll_info["filename"] + expected_hash = OPUS_DLL_HASHES.get(filename) + file_path = os.path.join(current_dir, filename) + if os.path.exists(file_path): + print(f"\n📁 {filename} already exists, verifying hash...") + if verify_file_hash(file_path, expected_hash): + print(f"✅ {filename} is valid, skipping download") + success_count += 1 + continue + else: + print(f"⚠️ {filename} hash mismatch, re-downloading...") + try: + os.remove(file_path) + except Exception: + pass + print(f"\n📥 Downloading {filename}...") + if download_file(dll_info["url"], filename, expected_hash): + success_count += 1 + + print(f"\n✅ Downloaded {success_count}/{len(OPUS_DLL_FILES)} DLLs") + return success_count == len(OPUS_DLL_FILES) + + +def import_opus(): + """Main function: Import Opus libraries with cross-platform support. + + Returns: + bool: True if Opus libraries loaded successfully + """ + global opusfile + + # Already loaded? + if opusfile is not None: + return True + + current_dir = _get_package_dir() + platform = sys.platform + + # Windows: use DLL + auto-download + if platform == "win32": + # Layer 1: Load from current directory + if _load_from_directory(current_dir): + return True + # Layer 2: Load from system + if _load_from_system(): + return True + # Layer 3: Auto-download DLLs + print("Opus DLLs not found, downloading...") + if download_opus_libraries(): + if _load_from_directory(current_dir): + print("✅ Opus DLLs loaded after download") + return True + + # Linux: use system .so libraries (reference _sdl2.py) + # NOTE: The package directory contains Windows .dll files only. + # Linux Opus libraries (.so) are installed via system package manager. + elif platform.startswith("linux"): + # Layer 1: User config (custom .so paths) + if _load_user_config(): + print("✅ Opus loaded from user config") + return True + # Layer 2: System libraries + if _load_from_system(): + print("✅ Opus loaded from system") + return True + # Layer 3: Auto install via package manager + if _linux_auto_install(): + return True + # Layer 4: Interactive setup + if _linux_interactive_setup(): + return True + + # macOS: no precompiled Opus framework, use package manager + # Order: detect system -> MacPorts -> Homebrew -> manual guide + elif platform == "darwin": + if _macos_auto_install(): + return True + + global _opus_dll_error + if not _opus_dll_error: + _opus_dll_error = "Failed to load Opus libraries" + return False + + +def check_opus_dll(): + """Check whether the Opus DLL can be loaded normally. + + Returns: + (bool, str): (whether normal, error message) + """ + if not import_opus(): + return (False, _opus_dll_error or "Opus DLL load failed") + try: + if not hasattr(opusfile, 'op_open_file'): + return (False, "libopusfile-0.dll not loaded correctly (missing op_open_file)") + return (True, "Opus DLL OK") + except Exception as e: + return (False, f"Opus DLL check failed: {e}") + + +# ============================================================ +# Opus structures +# ============================================================ + +class OpusHead(Structure): + _fields_ = [ + ("version", c_int), + ("channel_count", c_int), + ("pre_skip", c_uint), + ("input_sample_rate", c_uint), + ("output_gain", c_int), + ("mapping_family", c_int), + ("stream_count", c_int), + ("coupled_count", c_int), + ("mapping", c_ubyte * 255), + ] + + +class OpusTags(Structure): + _fields_ = [ + ("user_comments", POINTER(c_char_p)), + ("comment_lengths", POINTER(c_int)), + ("comments", c_int), + ("vendor", c_char_p), + ] + + +# ============================================================ +# Windows Wave API structures and constants +# ============================================================ + +WAVE_FORMAT_PCM = 1 +WAVE_MAPPER = 0xFFFFFFFF +CALLBACK_EVENT = 0x00050000 +WHDR_DONE = 0x1 +MMSYSERR_NOERROR = 0 +WAIT_OBJECT_0 = 0 + + +class WAVEFORMATEX(Structure): + _fields_ = [ + ("wFormatTag", wt.WORD), + ("nChannels", wt.WORD), + ("nSamplesPerSec", wt.DWORD), + ("nAvgBytesPerSec", wt.DWORD), + ("nBlockAlign", wt.WORD), + ("wBitsPerSample", wt.WORD), + ("cbSize", wt.WORD), + ] + + +class WAVEHDR(Structure): + pass +WAVEHDR._fields_ = [ + ("lpData", wt.LPSTR), + ("dwBufferLength", wt.DWORD), + ("dwBytesRecorded", wt.DWORD), + ("dwUser", c_void_p), # DWORD_PTR (8 bytes) + ("dwFlags", wt.DWORD), + ("dwLoops", wt.DWORD), + ("lpNext", POINTER(WAVEHDR)), + ("reserved", c_void_p), # DWORD_PTR (8 bytes) +] + + +# ============================================================ +# Opus file function bindings (wrapper functions) +# ============================================================ + +def op_open_file(path, error): + """Open an Opus file. Returns handle or None.""" + return opusfile.op_open_file(path, error) + + +def op_free(of): + """Free an Opus file handle.""" + opusfile.op_free(of) + + +def op_head(of, li): + """Get OpusHead for a link.""" + return opusfile.op_head(of, li) + + +def op_tags(of, li): + """Get OpusTags for a link.""" + return opusfile.op_tags(of, li) + + +def op_channel_count(of, li): + """Get channel count.""" + return opusfile.op_channel_count(of, li) + + +def op_pcm_total(of, li): + """Get total PCM samples.""" + return opusfile.op_pcm_total(of, li) + + +def op_bitrate(of, li): + """Get average bitrate.""" + return opusfile.op_bitrate(of, li) + + +def op_seekable(of): + """Check if stream is seekable.""" + return opusfile.op_seekable(of) + + +def op_link_count(of): + """Get number of links.""" + return opusfile.op_link_count(of) + + +def op_read_stereo(of, pcm, buf_size): + """Read decoded stereo PCM.""" + return opusfile.op_read_stereo(of, pcm, buf_size) + + +def op_pcm_seek(of, pos): + """Seek to PCM sample position.""" + return opusfile.op_pcm_seek(of, pos) + + +def op_pcm_tell(of): + """Get current PCM position.""" + return opusfile.op_pcm_tell(of) + + +# ============================================================ +# Windows Wave API function bindings +# ============================================================ + +def waveOutOpen(phwo, device_id, fmt, callback, instance, flags): + return winmm.waveOutOpen(phwo, device_id, fmt, callback, instance, flags) + + +def waveOutPrepareHeader(hwo, pwh, cbwh): + return winmm.waveOutPrepareHeader(hwo, pwh, cbwh) + + +def waveOutWrite(hwo, pwh, cbwh): + return winmm.waveOutWrite(hwo, pwh, cbwh) + + +def waveOutUnprepareHeader(hwo, pwh, cbwh): + return winmm.waveOutUnprepareHeader(hwo, pwh, cbwh) + + +def waveOutClose(hwo): + return winmm.waveOutClose(hwo) + + +def waveOutSetVolume(hwo, volume): + return winmm.waveOutSetVolume(hwo, volume) + + +def waveOutGetVolume(hwo, volume): + return winmm.waveOutGetVolume(hwo, volume) + + +def waveOutPause(hwo): + return winmm.waveOutPause(hwo) + + +def waveOutRestart(hwo): + return winmm.waveOutRestart(hwo) + + +def waveOutReset(hwo): + return winmm.waveOutReset(hwo) + + +def waveOutGetErrorTextW(code, buf, size): + return winmm.waveOutGetErrorTextW(code, buf, size) + + +# ============================================================ +# Kernel32 function bindings +# ============================================================ + +def CreateEventW(lp_attrs, b_manual, b_initial, name): + return kernel32.CreateEventW(lp_attrs, b_manual, b_initial, name) + + +def WaitForSingleObject(handle, ms): + return kernel32.WaitForSingleObject(handle, ms) + + +def ResetEvent(handle): + return kernel32.ResetEvent(handle) + + +def CloseHandle(handle): + return kernel32.CloseHandle(handle) + + +# ============================================================ +# Function prototypes (set after libraries are loaded) +# ============================================================ + +def _setup_prototypes(): + """Set up function prototypes for opusfile, winmm, and kernel32.""" + # --- opusfile prototypes --- + opusfile.op_open_file.restype = c_void_p + opusfile.op_open_file.argtypes = [c_char_p, POINTER(c_int)] + opusfile.op_free.restype = None + opusfile.op_free.argtypes = [c_void_p] + opusfile.op_head.restype = POINTER(OpusHead) + opusfile.op_head.argtypes = [c_void_p, c_int] + opusfile.op_tags.restype = POINTER(OpusTags) + opusfile.op_tags.argtypes = [c_void_p, c_int] + opusfile.op_channel_count.restype = c_int + opusfile.op_channel_count.argtypes = [c_void_p, c_int] + opusfile.op_pcm_total.restype = c_longlong + opusfile.op_pcm_total.argtypes = [c_void_p, c_int] + opusfile.op_bitrate.restype = c_int + opusfile.op_bitrate.argtypes = [c_void_p, c_int] + opusfile.op_seekable.restype = c_int + opusfile.op_seekable.argtypes = [c_void_p] + opusfile.op_link_count.restype = c_int + opusfile.op_link_count.argtypes = [c_void_p] + opusfile.op_read_stereo.restype = c_int + opusfile.op_read_stereo.argtypes = [c_void_p, POINTER(c_int16), c_int] + opusfile.op_pcm_seek.restype = c_int + opusfile.op_pcm_seek.argtypes = [c_void_p, c_longlong] + opusfile.op_pcm_tell.restype = c_longlong + opusfile.op_pcm_tell.argtypes = [c_void_p] + + # --- winmm prototypes (Windows only) --- + if winmm is None: + return + winmm.waveOutOpen.restype = wt.DWORD + winmm.waveOutOpen.argtypes = [ + POINTER(wt.HANDLE), wt.UINT, POINTER(WAVEFORMATEX), + wt.DWORD, wt.DWORD, wt.DWORD] + winmm.waveOutPrepareHeader.restype = wt.DWORD + winmm.waveOutPrepareHeader.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT] + winmm.waveOutWrite.restype = wt.DWORD + winmm.waveOutWrite.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT] + winmm.waveOutUnprepareHeader.restype = wt.DWORD + winmm.waveOutUnprepareHeader.argtypes = [wt.HANDLE, POINTER(WAVEHDR), wt.UINT] + winmm.waveOutClose.restype = wt.DWORD + winmm.waveOutClose.argtypes = [wt.HANDLE] + winmm.waveOutSetVolume.restype = wt.DWORD + winmm.waveOutSetVolume.argtypes = [wt.HANDLE, wt.DWORD] + winmm.waveOutGetVolume.restype = wt.DWORD + winmm.waveOutGetVolume.argtypes = [wt.HANDLE, POINTER(wt.DWORD)] + winmm.waveOutPause.restype = wt.DWORD + winmm.waveOutPause.argtypes = [wt.HANDLE] + winmm.waveOutRestart.restype = wt.DWORD + winmm.waveOutRestart.argtypes = [wt.HANDLE] + winmm.waveOutReset.restype = wt.DWORD + winmm.waveOutReset.argtypes = [wt.HANDLE] + winmm.waveOutGetErrorTextW.restype = wt.DWORD + winmm.waveOutGetErrorTextW.argtypes = [wt.DWORD, wt.LPWSTR, wt.UINT] + + # --- kernel32 prototypes --- + kernel32.CreateEventW.restype = wt.HANDLE + kernel32.CreateEventW.argtypes = [c_void_p, wt.BOOL, wt.BOOL, wt.LPCWSTR] + kernel32.WaitForSingleObject.restype = wt.DWORD + kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] + kernel32.ResetEvent.restype = wt.BOOL + kernel32.ResetEvent.argtypes = [wt.HANDLE] + kernel32.CloseHandle.restype = wt.BOOL + kernel32.CloseHandle.argtypes = [wt.HANDLE] + + +# ============================================================ +# Initialize: Load system DLLs and Opus DLLs +# ============================================================ + +# Windows-specific system DLLs (winmm/kernel32) are only available on Windows. +# On Linux/macOS, these are set to None; Opus decoding still works via opusfile, +# but waveOut playback is Windows-only. +if sys.platform == "win32": + winmm = ctypes.WinDLL("winmm") + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +else: + winmm = None + kernel32 = None + +# Load Opus libraries (with auto-download on Windows, system .so on Linux) +if import_opus(): + _setup_prototypes() diff --git a/ap_ds/_sdl2.py b/ap_ds/_sdl2.py new file mode 100644 index 0000000..3f676c2 --- /dev/null +++ b/ap_ds/_sdl2.py @@ -0,0 +1,1053 @@ +# sdl2.py - SDL2 constants, structures, bindings and cross-platform loader + +import os +import sys +import tempfile +import shutil +import subprocess +import ssl +import hashlib +import urllib.request +from ctypes import * + + +# ============================================================ +# SDL2 Library Loader +# ============================================================ + +_sdl_lib = None +_mix_lib = None + + +def _load_from_directory(directory): + """Load SDL2 libraries from specified directory""" + global _sdl_lib, _mix_lib + platform = sys.platform + + if platform == "win32": + sdl2_path = os.path.join(directory, "SDL2.dll") + sdl2_mixer_path = os.path.join(directory, "SDL2_mixer.dll") + if os.path.exists(sdl2_path) and os.path.exists(sdl2_mixer_path): + if hasattr(os, 'add_dll_directory'): + os.add_dll_directory(directory) + os.environ['PATH'] = directory + os.pathsep + os.environ.get('PATH', '') + _sdl_lib = CDLL(sdl2_path) + _mix_lib = CDLL(sdl2_mixer_path) + return True + return False + + elif platform == "darwin": + sdl2_path = os.path.join(directory, "SDL2.framework", "SDL2") + sdl2_mixer_path = os.path.join(directory, "SDL2_mixer.framework", "SDL2_mixer") + if os.path.exists(sdl2_path) and os.path.exists(sdl2_mixer_path): + framework_dir = os.path.dirname(os.path.dirname(sdl2_path)) + if 'DYLD_FRAMEWORK_PATH' not in os.environ: + os.environ['DYLD_FRAMEWORK_PATH'] = framework_dir + else: + os.environ['DYLD_FRAMEWORK_PATH'] = framework_dir + ':' + os.environ['DYLD_FRAMEWORK_PATH'] + _sdl_lib = CDLL(sdl2_path) + _mix_lib = CDLL(sdl2_mixer_path) + return True + return False + + elif platform.startswith("linux"): + names_to_try = [ + ("libSDL2.so", "libSDL2_mixer.so"), + ("SDL2.so", "SDL2_mixer.so"), + ] + for sdl_name, mixer_name in names_to_try: + sdl_path = os.path.join(directory, sdl_name) + mixer_path = os.path.join(directory, mixer_name) + if os.path.exists(sdl_path) and os.path.exists(mixer_path): + if 'LD_LIBRARY_PATH' not in os.environ: + os.environ['LD_LIBRARY_PATH'] = directory + else: + os.environ['LD_LIBRARY_PATH'] = directory + ':' + os.environ['LD_LIBRARY_PATH'] + _sdl_lib = CDLL(sdl_path) + _mix_lib = CDLL(mixer_path) + return True + return False + + return False + + +def _load_from_system(): + """Try loading from system paths""" + global _sdl_lib, _mix_lib + platform = sys.platform + + try: + import ctypes.util + sdl_path = ctypes.util.find_library("SDL2") + mixer_path = ctypes.util.find_library("SDL2_mixer") + if sdl_path and mixer_path: + _sdl_lib = CDLL(sdl_path) + _mix_lib = CDLL(mixer_path) + return True + except: + pass + return False + + +def _load_user_config(): + """Load user-saved SDL2 paths from config file""" + try: + config_file = os.path.expanduser('~/.config/ap_ds/sdl_paths.conf') + if os.path.exists(config_file): + with open(config_file, 'r') as f: + sdl_path = None + mixer_path = None + for line in f: + if line.startswith('SDL2_PATH='): + sdl_path = line.strip().split('=', 1)[1] + elif line.startswith('SDL2_MIXER_PATH='): + mixer_path = line.strip().split('=', 1)[1] + if sdl_path and mixer_path and os.path.exists(sdl_path) and os.path.exists(mixer_path): + global _sdl_lib, _mix_lib + _sdl_lib = CDLL(sdl_path) + _mix_lib = CDLL(mixer_path) + return True + except: + pass + return False + + +def _run_sudo_command(cmd, packages): + """Run a sudo command with interactive password input. + + First tries without password (if already root or passwordless sudo). + If that fails, prompts for the sudo password interactively. + + Args: + cmd: Base command list (e.g. ['apt-get', 'install', '-y']) + packages: Package names to install + + Returns: + bool: True if command succeeded + """ + import subprocess + import getpass + + full_cmd = cmd + packages + + # Try without password first (if already root / passwordless sudo) + try: + result = subprocess.run( + ['sudo', '-n'] + full_cmd, + capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + return True + except Exception: + pass + + # Prompt for sudo password interactively + print("🔑 sudo password required for package installation") + try: + password = getpass.getpass("Enter sudo password: ") + except (EOFError, KeyboardInterrupt): + print("\n❌ Password input cancelled") + return False + + # Use sudo -S to read password from stdin + try: + result = subprocess.run( + ['sudo', '-S'] + full_cmd, + input=password + '\n', + capture_output=True, text=True, timeout=180 + ) + if result.returncode == 0: + print("✅ Packages installed successfully") + return True + else: + print(f"❌ Installation failed: {result.stderr.strip()}") + return False + except Exception as e: + print(f"❌ Installation error: {e}") + return False + + +def _linux_auto_install(): + """Try automatic package manager installation on Linux""" + try: + import subprocess + import shutil + + if shutil.which('apt-get'): + print("📦 Detected apt-based system (Ubuntu/Debian)") + if _run_sudo_command(['apt-get', 'install', '-y'], + ['libsdl2-dev', 'libsdl2-mixer-dev']): + if _load_from_system(): + print("✅ SDL2 libraries installed and loaded") + return True + + elif shutil.which('dnf'): + print("📦 Detected dnf-based system (Fedora)") + if _run_sudo_command(['dnf', 'install', '-y'], + ['SDL2-devel', 'SDL2_mixer-devel']): + if _load_from_system(): + print("✅ SDL2 libraries installed and loaded") + return True + + elif shutil.which('pacman'): + print("📦 Detected pacman-based system (Arch)") + if _run_sudo_command(['pacman', '-S', '--noconfirm'], + ['sdl2', 'sdl2_mixer']): + if _load_from_system(): + print("✅ SDL2 libraries installed and loaded") + return True + + except Exception as e: + print(f"⚠️ Automatic installation failed: {e}") + return False + +def _linux_interactive_setup(): + """Linux interactive setup for SDL2""" + global _sdl_lib, _mix_lib + print("\n" + "="*70) + print("Linux SDL2 Library Loading") + print("="*70) + print("Options:") + print("1. Use system-installed libraries (re-check)") + print("2. Specify path to your compiled .so files") + print("3. Show installation instructions") + print("="*70) + + while True: + choice = input("\nChoose option (1/2/3): ").strip() + + if choice == "1": + if _load_from_system(): + print("✅ SDL2 libraries loaded from system") + return True + print("❌ System libraries not found") + continue + + elif choice == "2": + sdl_path = input("Enter full path to libSDL2.so: ").strip() + mixer_path = input("Enter full path to libSDL2_mixer.so: ").strip() + if os.path.exists(sdl_path) and os.path.exists(mixer_path): + try: + _sdl_lib = CDLL(sdl_path) + _mix_lib = CDLL(mixer_path) + # Save for future + try: + config_dir = os.path.expanduser('~/.config/ap_ds') + os.makedirs(config_dir, exist_ok=True) + with open(os.path.join(config_dir, 'sdl_paths.conf'), 'w') as f: + f.write(f"SDL2_PATH={sdl_path}\n") + f.write(f"SDL2_MIXER_PATH={mixer_path}\n") + except: + pass + print("✅ Libraries loaded from user-specified paths") + return True + except Exception as e: + print(f"❌ Failed to load: {e}") + else: + print("❌ One or both files not found") + continue + + elif choice == "3": + print("\n📚 Installation instructions:") + print("="*50) + print("For Ubuntu/Debian:") + print(" sudo apt-get install libsdl2-dev libsdl2-mixer-dev") + print("\nFor Fedora:") + print(" sudo dnf install SDL2-devel SDL2_mixer-devel") + print("\nFor Arch:") + print(" sudo pacman -S sdl2 sdl2_mixer") + print("\nManual compilation:") + print("1. Download SDL2 from: https://www.libsdl.org/download-2.0.php") + print("2. Download SDL2_mixer from: https://www.libsdl.org/projects/SDL_mixer/") + print("3. Compile: ./configure && make && sudo make install") + print("="*50) + continue + + else: + print("❌ Invalid choice. Please enter 1, 2, or 3.") + + +def _check_sdl_libraries_exist(directory): + """Check if SDL2 libraries exist in directory""" + platform = sys.platform + + if platform == "win32": + return os.path.exists(os.path.join(directory, "SDL2.dll")) and \ + os.path.exists(os.path.join(directory, "SDL2_mixer.dll")) + elif platform == "darwin": + return os.path.exists(os.path.join(directory, "SDL2.framework")) and \ + os.path.exists(os.path.join(directory, "SDL2_mixer.framework")) + elif platform.startswith("linux"): + return os.path.exists(os.path.join(directory, "libSDL2.so")) and \ + os.path.exists(os.path.join(directory, "libSDL2_mixer.so")) + return False + + +def _check_sdl2_loaded(): + """Check if SDL2 is already loaded""" + return _sdl_lib is not None and _mix_lib is not None + + +def download_sdl_libraries(): + """Download SDL2 libraries to package directory based on platform with file hash verification""" + FILE_HASHES = { + "SDL2.dll": "520d0459b91efa32fbccf9027a9ca1fc5aae657e679ce8e90f179f9cf5afd279", + "SDL2_mixer.dll": "2a0fc5e9f72c2eaec3240cb82b7594a58ccda609485981f256b94d0a4dd8d6f8", + "SDL2.dmg": "2bf2cb8f6b44d584b14e8d4ca7437080d1d968fe3962303be27217b336b82249", + "SDL2_mixer.dmg": "d74052391ee4d91836bf1072a060f1d821710f3498a54996c66b9a17c79a72d1", + } + + current_dir = os.path.dirname(os.path.abspath(__file__)) + print(f"Package directory: {current_dir}") + platform = sys.platform + + def download_file(url, expected_hash=None): + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() + + try: + print(f" Attempting download with SSL verification...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=30) as response: + content = response.read() + print(f" ✅ Download successful with SSL verification") + except (urllib.error.URLError, ssl.SSLError, Exception) as e: + print(f" SSL verification failed: {e}") + print(f" Attempting download without SSL verification...") + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=30, context=context) as response: + content = response.read() + print(f" ✅ Download successful without SSL verification") + + with open(temp_file.name, 'wb') as f: + f.write(content) + print(f" Downloaded {len(content)} bytes") + + if expected_hash and expected_hash != "expected_sha256_hash_of_xxx": + file_hash = hashlib.sha256(content).hexdigest() + print(f" File SHA256: {file_hash}") + if file_hash.lower() != expected_hash.lower(): + raise Exception(f"Hash verification failed! Expected: {expected_hash}, Got: {file_hash}") + print(f" ✅ Hash verification passed") + else: + print(f" ⚠️ No hash verification (hash not configured)") + + return content, temp_file.name + + except Exception as e: + print(f" ❌ Download failed: {str(e)}") + try: + os.unlink(temp_file.name) + except: + pass + raise + + def verify_file_hash(file_path, expected_hash): + if not expected_hash or expected_hash == "expected_sha256_hash_of_xxx": + print(f" ⚠️ No hash configured for {os.path.basename(file_path)}, skipping verification") + return True + + try: + with open(file_path, 'rb') as f: + content = f.read() + file_hash = hashlib.sha256(content).hexdigest() + print(f" Existing file SHA256: {file_hash}") + if file_hash.lower() == expected_hash.lower(): + print(f" ✅ Hash verification passed") + return True + else: + print(f" ❌ Hash verification failed! Expected: {expected_hash}, Got: {file_hash}") + return False + except Exception as e: + print(f" ❌ Error verifying hash: {e}") + return False + + # Windows + if platform == "win32": + files = [ + {"url": "https://dvsyun.top/ap_ds/download/SDL2", "filename": "SDL2.dll", "expected_hash": FILE_HASHES.get("SDL2.dll")}, + {"url": "https://dvsyun.top/ap_ds/download/SDL2_M", "filename": "SDL2_mixer.dll", "expected_hash": FILE_HASHES.get("SDL2_mixer.dll")} + ] + for file_info in files: + file_path = os.path.join(current_dir, file_info["filename"]) + if os.path.exists(file_path): + print(f"\n📁 {file_info['filename']} already exists, verifying hash...") + if verify_file_hash(file_path, file_info["expected_hash"]): + print(f"✅ {file_info['filename']} is valid, skipping download") + continue + else: + print(f"⚠️ {file_info['filename']} hash mismatch, re-downloading...") + try: + os.remove(file_path) + except: + pass + print(f"\n📥 Downloading {file_info['filename']}...") + try: + content, temp_path = download_file(file_info["url"], file_info["expected_hash"]) + shutil.move(temp_path, file_path) + print(f"✅ Successfully downloaded {file_info['filename']}") + except Exception as e: + print(f"❌ Failed to download {file_info['filename']}: {str(e)}") + continue + + # macOS + elif platform == "darwin": + frameworks = [ + {"name": "SDL2", "url": "https://dvsyun.top/ap_ds/download/SDL2/MAC", "dmg_filename": "SDL2.dmg", "framework_name": "SDL2.framework", "expected_hash": FILE_HASHES.get("SDL2.dmg")}, + {"name": "SDL2_mixer", "url": "https://dvsyun.top/ap_ds/download/SDL2_M/MAC", "dmg_filename": "SDL2_mixer.dmg", "framework_name": "SDL2_mixer.framework", "expected_hash": FILE_HASHES.get("SDL2_mixer.dmg")} + ] + for framework_info in frameworks: + framework_path = os.path.join(current_dir, framework_info["framework_name"]) + if os.path.exists(framework_path): + print(f"\n📁 {framework_info['framework_name']} already exists, skipping download") + continue + print(f"\n📥 Downloading {framework_info['dmg_filename']}...") + temp_dmg = None + try: + content, temp_dmg = download_file(framework_info["url"], framework_info["expected_hash"]) + print(f"✅ Downloaded {framework_info['dmg_filename']}") + mount_point = tempfile.mkdtemp(prefix=f"{framework_info['name']}_mount_") + try: + cmd = ["hdiutil", "attach", temp_dmg, "-mountpoint", mount_point, "-nobrowse", "-quiet"] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"❌ Failed to mount {framework_info['dmg_filename']}: {result.stderr}") + continue + framework_src = None + for root, dirs, files in os.walk(mount_point): + if framework_info["framework_name"] in dirs: + framework_src = os.path.join(root, framework_info["framework_name"]) + break + if not framework_src: + possible_paths = [ + os.path.join(mount_point, framework_info["framework_name"]), + os.path.join(mount_point, framework_info["name"], framework_info["framework_name"]), + os.path.join(mount_point, "Frameworks", framework_info["framework_name"]), + ] + for path in possible_paths: + if os.path.exists(path): + framework_src = path + break + if not framework_src: + print(f"❌ Could not find {framework_info['framework_name']} in dmg") + subprocess.run(["hdiutil", "detach", mount_point, "-quiet"]) + continue + shutil.copytree(framework_src, framework_path) + print(f"✅ Extracted {framework_info['framework_name']} to package directory") + subprocess.run(["hdiutil", "detach", mount_point, "-quiet"]) + except Exception as e: + print(f"❌ Extraction failed: {e}") + try: + subprocess.run(["hdiutil", "detach", mount_point, "-force", "-quiet"]) + except: + pass + continue + finally: + if temp_dmg and os.path.exists(temp_dmg): + os.unlink(temp_dmg) + if os.path.exists(mount_point): + try: + os.rmdir(mount_point) + except: + pass + except Exception as e: + print(f"❌ Download failed: {str(e)}") + if temp_dmg and os.path.exists(temp_dmg): + try: + os.unlink(temp_dmg) + except: + pass + continue + + # Linux + elif platform.startswith("linux"): + print("\n" + "="*70) + print("⚠️ Linux Support Notice") + print("="*70) + print("For Linux systems, SDL2 libraries are NOT provided via automatic download.") + print("Reason: There are too many Linux distributions and library dependencies.") + print("") + print("To use ap_ds on Linux:") + print("1. Install SDL2 and SDL2_mixer using your package manager:") + print(" - Ubuntu/Debian: sudo apt-get install libsdl2-dev libsdl2-mixer-dev") + print(" - Fedora: sudo dnf install SDL2-devel SDL2_mixer-devel") + print(" - Arch: sudo pacman -S sdl2 sdl2_mixer") + print("2. Or compile from source:") + print(" - Download from: https://www.libsdl.org/") + print(" - Build instructions: https://wiki.libsdl.org/Installation") + print("") + print("After installation, run ap_ds again.") + print("="*70 + "\n") + + response = input("Do you have pre-compiled .so files? (y/n): ").strip().lower() + if response == 'y': + sdl2_path = input("Enter full path to libSDL2.so: ").strip() + sdl2_mixer_path = input("Enter full path to libSDL2_mixer.so: ").strip() + if os.path.exists(sdl2_path) and os.path.exists(sdl2_mixer_path): + shutil.copy2(sdl2_path, os.path.join(current_dir, "libSDL2.so")) + shutil.copy2(sdl2_mixer_path, os.path.join(current_dir, "libSDL2_mixer.so")) + print("✅ Libraries copied to package directory") + else: + print("❌ One or both library files not found") + else: + print("Please install SDL2 libraries and try again.") + return + + print(f"\n📂 Files in package directory: {os.listdir(current_dir)}") + + +def import_sdl2(): + """Main function: Import SDL2 libraries with cross-platform support""" + global _sdl_lib, _mix_lib + + # Already loaded? + if _check_sdl2_loaded(): + return _sdl_lib, _mix_lib + + current_dir = os.path.dirname(os.path.abspath(__file__)) + platform = sys.platform + + # Windows + if platform == "win32": + # Try current directory + if _load_from_directory(current_dir): + print("✅ SDL2 loaded from package directory") + return _sdl_lib, _mix_lib + # Try system path + try: + _sdl_lib = CDLL("SDL2.dll") + _mix_lib = CDLL("SDL2_mixer.dll") + print("✅ SDL2 loaded from system path") + return _sdl_lib, _mix_lib + except: + pass + # Download + print("SDL2 libraries not found, downloading...") + download_sdl_libraries() + if _load_from_directory(current_dir): + print("✅ SDL2 loaded after download") + return _sdl_lib, _mix_lib + raise ImportError("Failed to load SDL2 libraries after download") + + # macOS + elif platform == "darwin": + # Try current directory + if _load_from_directory(current_dir): + print("✅ SDL2 loaded from package directory") + return _sdl_lib, _mix_lib + # Try system framework paths + if _load_from_system(): + print("✅ SDL2 loaded from system") + return _sdl_lib, _mix_lib + # Download + print("SDL2 frameworks not found, downloading...") + download_sdl_libraries() + if _load_from_directory(current_dir): + print("✅ SDL2 loaded after download") + return _sdl_lib, _mix_lib + raise ImportError("Failed to load SDL2 frameworks after download") + + # Linux + elif platform.startswith("linux"): + # Layer 1: Load from current directory + if _load_from_directory(current_dir): + print("✅ SDL2 loaded from package directory") + return _sdl_lib, _mix_lib + + # Layer 2: User config + if _load_user_config(): + print("✅ SDL2 loaded from user config") + return _sdl_lib, _mix_lib + + # Layer 3: System libraries + if _load_from_system(): + print("✅ SDL2 loaded from system") + return _sdl_lib, _mix_lib + + # Layer 4: Auto install + if _linux_auto_install(): + return _sdl_lib, _mix_lib + + # Layer 5: Interactive setup + if _linux_interactive_setup(): + return _sdl_lib, _mix_lib + + raise ImportError("Failed to load SDL2 libraries on Linux") + + else: + raise ImportError(f"Unsupported platform: {platform}") + + +# ============================================================ +# SDL2 Constants +# ============================================================ + +SDL_bool = c_int +SDL_TRUE = 1 +SDL_FALSE = 0 + +SDL_INIT_TIMER = 0x00000001 +SDL_INIT_AUDIO = 0x00000010 +SDL_INIT_VIDEO = 0x00000020 +SDL_INIT_JOYSTICK = 0x00000200 +SDL_INIT_HAPTIC = 0x00001000 +SDL_INIT_GAMECONTROLLER = 0x00002000 +SDL_INIT_EVENTS = 0x00004000 +SDL_INIT_EVERYTHING = (SDL_INIT_TIMER | SDL_INIT_AUDIO | SDL_INIT_VIDEO | + SDL_INIT_JOYSTICK | SDL_INIT_HAPTIC | + SDL_INIT_GAMECONTROLLER | SDL_INIT_EVENTS) + +AUDIO_U8 = 0x0008 +AUDIO_S8 = 0x8008 +AUDIO_U16LSB = 0x0010 +AUDIO_S16LSB = 0x8010 +AUDIO_U16MSB = 0x1010 +AUDIO_S16MSB = 0x9010 +AUDIO_U16 = AUDIO_U16LSB +AUDIO_S16 = AUDIO_S16LSB +AUDIO_S32LSB = 0x8020 +AUDIO_S32MSB = 0x9020 +AUDIO_S32 = AUDIO_S32LSB +AUDIO_F32LSB = 0x8120 +AUDIO_F32MSB = 0x9120 +AUDIO_F32 = AUDIO_F32LSB + +if sys.byteorder == 'little': + AUDIO_U16SYS = AUDIO_U16LSB + AUDIO_S16SYS = AUDIO_S16LSB + AUDIO_S32SYS = AUDIO_S32LSB + AUDIO_F32SYS = AUDIO_F32LSB +else: + AUDIO_U16SYS = AUDIO_U16MSB + AUDIO_S16SYS = AUDIO_S16MSB + AUDIO_S32SYS = AUDIO_S32MSB + AUDIO_F32SYS = AUDIO_F32MSB + +MIX_DEFAULT_FORMAT = AUDIO_S16SYS + +MIX_INIT_FLAC = 0x00000001 +MIX_INIT_MOD = 0x00000002 +MIX_INIT_MP3 = 0x00000008 +MIX_INIT_OGG = 0x00000010 +MIX_INIT_MID = 0x00000020 +MIX_INIT_OPUS = 0x00000040 + +MIX_CHANNEL_POST = -2 +MIX_DEFAULT_CHANNELS = 2 + +MUS_NONE = 0 +MUS_CMD = 1 +MUS_WAV = 2 +MUS_MOD = 3 +MUS_MID = 4 +MUS_OGG = 5 +MUS_MP3 = 6 +MUS_FLAC = 7 +MUS_OPUS = 8 + + +# ============================================================ +# SDL2 Structures +# ============================================================ + +class SDL_AudioSpec(Structure): + _fields_ = [ + ("freq", c_int), + ("format", c_uint16), + ("channels", c_uint8), + ("silence", c_uint8), + ("samples", c_uint16), + ("padding", c_uint16), + ("size", c_uint32), + ("callback", c_void_p), + ("userdata", c_void_p) + ] + + +class Mix_Chunk(Structure): + _fields_ = [ + ("allocated", c_int), + ("abuf", POINTER(c_uint8)), + ("alen", c_uint32), + ("volume", c_uint8) + ] + + +# ============================================================ +# SDL2 Function Bindings (set up AFTER loading) +# ============================================================ + +# These are set up after _sdl_lib and _mix_lib are loaded +# We define them as functions that will use the global _sdl_lib and _mix_lib + + +def SDL_Init(flags): + return _sdl_lib.SDL_Init(flags) + + +def SDL_InitSubSystem(flags): + return _sdl_lib.SDL_InitSubSystem(flags) + + +def SDL_Quit(): + _sdl_lib.SDL_Quit() + + +def SDL_QuitSubSystem(flags): + _sdl_lib.SDL_QuitSubSystem(flags) + + +def SDL_WasInit(flags): + return _sdl_lib.SDL_WasInit(flags) + + +def SDL_GetError(): + return _sdl_lib.SDL_GetError() + + +def SDL_RWFromFile(file, mode): + if isinstance(file, str): + file = file.encode('utf-8') + if isinstance(mode, str): + mode = mode.encode('utf-8') + return _sdl_lib.SDL_RWFromFile(file, mode) + + +def SDL_Delay(ms): + _sdl_lib.SDL_Delay(ms) + + +def Mix_OpenAudio(frequency, format, channels, chunksize): + return _mix_lib.Mix_OpenAudio(frequency, format, channels, chunksize) + + +def Mix_CloseAudio(): + _mix_lib.Mix_CloseAudio() + + +def Mix_QuerySpec(frequency, format, channels): + return _mix_lib.Mix_QuerySpec(byref(frequency), byref(format), byref(channels)) + + +def Mix_LoadWAV(file): + if isinstance(file, str): + file = file.encode('utf-8') + return _mix_lib.Mix_LoadWAV_RW(SDL_RWFromFile(file, b"rb"), 1) + + +def Mix_LoadMUS(file): + if isinstance(file, str): + file = file.encode('utf-8') + return _mix_lib.Mix_LoadMUS_RW(SDL_RWFromFile(file, b"rb"), 1) + + +def Mix_FreeChunk(chunk): + _mix_lib.Mix_FreeChunk(chunk) + + +def Mix_FreeMusic(music): + _mix_lib.Mix_FreeMusic(music) + + +def Mix_PlayChannel(channel, chunk, loops): + return _mix_lib.Mix_PlayChannel(channel, chunk, loops) + + +def Mix_PlayMusic(music, loops): + return _mix_lib.Mix_PlayMusic(music, loops) + + +def Mix_Pause(channel): + _mix_lib.Mix_Pause(channel) + + +def Mix_PauseMusic(): + _mix_lib.Mix_PauseMusic() + + +def Mix_Resume(channel): + _mix_lib.Mix_Resume(channel) + + +def Mix_ResumeMusic(): + _mix_lib.Mix_ResumeMusic() + + +def Mix_HaltChannel(channel): + return _mix_lib.Mix_HaltChannel(channel) + + +def Mix_HaltMusic(): + return _mix_lib.Mix_HaltMusic() + + +def Mix_SetMusicPosition(position): + return _mix_lib.Mix_SetMusicPosition(position) + + +def Mix_MusicDuration(music): + return _mix_lib.Mix_MusicDuration(music) + + +def Mix_Volume(channel, volume): + return _mix_lib.Mix_Volume(channel, volume) + + +def Mix_VolumeMusic(volume): + return _mix_lib.Mix_VolumeMusic(volume) + + +def Mix_AllocateChannels(numchans): + return _mix_lib.Mix_AllocateChannels(numchans) + + +def Mix_GetMusicType(music): + return _mix_lib.Mix_GetMusicType(music) + + +def Mix_FadingMusic(): + return _mix_lib.Mix_FadingMusic() + + +def Mix_FadeInMusic(music, loops, ms): + return _mix_lib.Mix_FadeInMusic(music, loops, ms) + + +def Mix_FadeOutMusic(ms): + return _mix_lib.Mix_FadeOutMusic(ms) + + +def Mix_FadeInChannel(channel, chunk, loops, ms): + return _mix_lib.Mix_FadeInChannel(channel, chunk, loops, ms) + + +def Mix_FadeOutChannel(channel, ms): + return _mix_lib.Mix_FadeOutChannel(channel, ms) + + +def Mix_Playing(channel): + return _mix_lib.Mix_Playing(channel) + + +def Mix_PlayingMusic(): + return _mix_lib.Mix_PlayingMusic() + + +def Mix_Paused(channel): + return _mix_lib.Mix_Paused(channel) + + +def Mix_PausedMusic(): + return _mix_lib.Mix_PausedMusic() + + +def Mix_SetPanning(channel, left, right): + return _mix_lib.Mix_SetPanning(channel, left, right) + + +def Mix_SetDistance(channel, distance): + return _mix_lib.Mix_SetDistance(channel, distance) + + +def Mix_SetPosition(channel, angle, distance): + return _mix_lib.Mix_SetPosition(channel, angle, distance) + + +def Mix_SetReverseStereo(channel, flip): + return _mix_lib.Mix_SetReverseStereo(channel, flip) + + +def Mix_FadeInMusicPos(music, loops, ms, position): + return _mix_lib.Mix_FadeInMusicPos(music, loops, ms, position) + + +# ============================================================ +# Function Prototypes (set after libraries are loaded) +# ============================================================ + +def _setup_prototypes(): + """Set up function prototypes for SDL2 and SDL2_mixer""" + # SDL function prototypes + _sdl_lib.SDL_Init.argtypes = [c_uint32] + _sdl_lib.SDL_Init.restype = c_int + + _sdl_lib.SDL_InitSubSystem.argtypes = [c_uint32] + _sdl_lib.SDL_InitSubSystem.restype = c_int + + _sdl_lib.SDL_Quit.argtypes = [] + _sdl_lib.SDL_Quit.restype = None + + _sdl_lib.SDL_QuitSubSystem.argtypes = [c_uint32] + _sdl_lib.SDL_QuitSubSystem.restype = None + + _sdl_lib.SDL_WasInit.argtypes = [c_uint32] + _sdl_lib.SDL_WasInit.restype = c_uint32 + + _sdl_lib.SDL_GetError.argtypes = [] + _sdl_lib.SDL_GetError.restype = c_char_p + + _sdl_lib.SDL_RWFromFile.argtypes = [c_char_p, c_char_p] + _sdl_lib.SDL_RWFromFile.restype = c_void_p + + _sdl_lib.SDL_Delay.argtypes = [c_uint32] + _sdl_lib.SDL_Delay.restype = None + + # SDL_mixer function prototypes + _mix_lib.Mix_OpenAudio.argtypes = [c_int, c_uint16, c_int, c_int] + _mix_lib.Mix_OpenAudio.restype = c_int + + _mix_lib.Mix_CloseAudio.argtypes = [] + _mix_lib.Mix_CloseAudio.restype = None + + if hasattr(_mix_lib, 'Mix_QuerySpec'): + _mix_lib.Mix_QuerySpec.argtypes = [POINTER(c_int), POINTER(c_uint16), POINTER(c_int)] + _mix_lib.Mix_QuerySpec.restype = c_int + + if hasattr(_mix_lib, 'Mix_LoadWAV_RW'): + _mix_lib.Mix_LoadWAV_RW.argtypes = [c_void_p, c_int] + _mix_lib.Mix_LoadWAV_RW.restype = POINTER(Mix_Chunk) + + if hasattr(_mix_lib, 'Mix_LoadMUS_RW'): + _mix_lib.Mix_LoadMUS_RW.argtypes = [c_void_p, c_int] + _mix_lib.Mix_LoadMUS_RW.restype = c_void_p + + if hasattr(_mix_lib, 'Mix_FreeChunk'): + _mix_lib.Mix_FreeChunk.argtypes = [POINTER(Mix_Chunk)] + _mix_lib.Mix_FreeChunk.restype = None + + if hasattr(_mix_lib, 'Mix_FreeMusic'): + _mix_lib.Mix_FreeMusic.argtypes = [c_void_p] + _mix_lib.Mix_FreeMusic.restype = None + + if hasattr(_mix_lib, 'Mix_FadeInMusicPos'): + _mix_lib.Mix_FadeInMusicPos.argtypes = [c_void_p, c_int, c_int, c_double] + _mix_lib.Mix_FadeInMusicPos.restype = c_int + + if hasattr(_mix_lib, 'Mix_PlayChannel'): + _mix_lib.Mix_PlayChannel.argtypes = [c_int, POINTER(Mix_Chunk), c_int] + _mix_lib.Mix_PlayChannel.restype = c_int + elif hasattr(_mix_lib, 'Mix_PlayChannelTimed'): + _mix_lib.Mix_PlayChannelTimed.argtypes = [c_int, POINTER(Mix_Chunk), c_int, c_int] + _mix_lib.Mix_PlayChannelTimed.restype = c_int + + if hasattr(_mix_lib, 'Mix_PlayMusic'): + _mix_lib.Mix_PlayMusic.argtypes = [c_void_p, c_int] + _mix_lib.Mix_PlayMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_Pause'): + _mix_lib.Mix_Pause.argtypes = [c_int] + _mix_lib.Mix_Pause.restype = None + + if hasattr(_mix_lib, 'Mix_PauseMusic'): + _mix_lib.Mix_PauseMusic.argtypes = [] + _mix_lib.Mix_PauseMusic.restype = None + + if hasattr(_mix_lib, 'Mix_Resume'): + _mix_lib.Mix_Resume.argtypes = [c_int] + _mix_lib.Mix_Resume.restype = None + + if hasattr(_mix_lib, 'Mix_ResumeMusic'): + _mix_lib.Mix_ResumeMusic.argtypes = [] + _mix_lib.Mix_ResumeMusic.restype = None + + if hasattr(_mix_lib, 'Mix_HaltChannel'): + _mix_lib.Mix_HaltChannel.argtypes = [c_int] + _mix_lib.Mix_HaltChannel.restype = c_int + + if hasattr(_mix_lib, 'Mix_HaltMusic'): + _mix_lib.Mix_HaltMusic.argtypes = [] + _mix_lib.Mix_HaltMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_SetMusicPosition'): + _mix_lib.Mix_SetMusicPosition.argtypes = [c_double] + _mix_lib.Mix_SetMusicPosition.restype = c_int + + if hasattr(_mix_lib, 'Mix_MusicDuration'): + _mix_lib.Mix_MusicDuration.argtypes = [c_void_p] + _mix_lib.Mix_MusicDuration.restype = c_float + + if hasattr(_mix_lib, 'Mix_Volume'): + _mix_lib.Mix_Volume.argtypes = [c_int, c_int] + _mix_lib.Mix_Volume.restype = c_int + + if hasattr(_mix_lib, 'Mix_VolumeMusic'): + _mix_lib.Mix_VolumeMusic.argtypes = [c_int] + _mix_lib.Mix_VolumeMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_AllocateChannels'): + _mix_lib.Mix_AllocateChannels.argtypes = [c_int] + _mix_lib.Mix_AllocateChannels.restype = c_int + + if hasattr(_mix_lib, 'Mix_GetMusicType'): + _mix_lib.Mix_GetMusicType.argtypes = [c_void_p] + _mix_lib.Mix_GetMusicType.restype = c_int + + if hasattr(_mix_lib, 'Mix_FadingMusic'): + _mix_lib.Mix_FadingMusic.argtypes = [] + _mix_lib.Mix_FadingMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_FadeInMusic'): + _mix_lib.Mix_FadeInMusic.argtypes = [c_void_p, c_int, c_int] + _mix_lib.Mix_FadeInMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_FadeOutMusic'): + _mix_lib.Mix_FadeOutMusic.argtypes = [c_int] + _mix_lib.Mix_FadeOutMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_FadeInChannel'): + _mix_lib.Mix_FadeInChannel.argtypes = [c_int, POINTER(Mix_Chunk), c_int, c_int] + _mix_lib.Mix_FadeInChannel.restype = c_int + elif hasattr(_mix_lib, 'Mix_FadeInChannelTimed'): + _mix_lib.Mix_FadeInChannelTimed.argtypes = [c_int, POINTER(Mix_Chunk), c_int, c_int, c_int] + _mix_lib.Mix_FadeInChannelTimed.restype = c_int + + if hasattr(_mix_lib, 'Mix_FadeOutChannel'): + _mix_lib.Mix_FadeOutChannel.argtypes = [c_int, c_int] + _mix_lib.Mix_FadeOutChannel.restype = c_int + + if hasattr(_mix_lib, 'Mix_Playing'): + _mix_lib.Mix_Playing.argtypes = [c_int] + _mix_lib.Mix_Playing.restype = c_int + + if hasattr(_mix_lib, 'Mix_PlayingMusic'): + _mix_lib.Mix_PlayingMusic.argtypes = [] + _mix_lib.Mix_PlayingMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_Paused'): + _mix_lib.Mix_Paused.argtypes = [c_int] + _mix_lib.Mix_Paused.restype = c_int + + if hasattr(_mix_lib, 'Mix_PausedMusic'): + _mix_lib.Mix_PausedMusic.argtypes = [] + _mix_lib.Mix_PausedMusic.restype = c_int + + if hasattr(_mix_lib, 'Mix_SetPanning'): + _mix_lib.Mix_SetPanning.argtypes = [c_int, c_uint8, c_uint8] + _mix_lib.Mix_SetPanning.restype = c_int + + if hasattr(_mix_lib, 'Mix_SetDistance'): + _mix_lib.Mix_SetDistance.argtypes = [c_int, c_uint8] + _mix_lib.Mix_SetDistance.restype = c_int + + if hasattr(_mix_lib, 'Mix_SetPosition'): + _mix_lib.Mix_SetPosition.argtypes = [c_int, c_uint16, c_uint8] + _mix_lib.Mix_SetPosition.restype = c_int + + if hasattr(_mix_lib, 'Mix_SetReverseStereo'): + _mix_lib.Mix_SetReverseStereo.argtypes = [c_int, c_int] + _mix_lib.Mix_SetReverseStereo.restype = c_int + + +# ============================================================ +# Initialize: Load SDL2 and set up prototypes +# ============================================================ + +# Load SDL2 libraries +_sdl_lib, _mix_lib = import_sdl2() + +# Set up function prototypes +_setup_prototypes() diff --git a/ap_ds/_version.py b/ap_ds/_version.py new file mode 100644 index 0000000..209a869 --- /dev/null +++ b/ap_ds/_version.py @@ -0,0 +1 @@ +__version__ = "4.1.0rc1" diff --git a/ap_ds/audio_parser.py b/ap_ds/audio_parser.py new file mode 100644 index 0000000..f35bca5 --- /dev/null +++ b/ap_ds/audio_parser.py @@ -0,0 +1,649 @@ +""" +audio_parser.py - Format-Specific Audio Metadata Parsers + +This module provides pure-Python parsers for extracting metadata (duration, +sample rate, channels, bitrate) from various audio formats without external +dependencies. + +Supported formats: + - WAV: 100% accuracy (RIFF chunk parsing) + - FLAC: 100% accuracy (STREAMINFO block) + - MP3: >98% accuracy (frame-by-frame scanning) + - AAC: >99% accuracy (ADTS frame parsing) + - OGG Vorbis: 99.99% accuracy (granule position) + +Python 3.14/3.15 optimizations: + - Batch parsing uses ProcessPoolExecutor for true parallelism + - Runtime mode detection: Warns users when running with GIL enabled + +Environment variables: + AP_DS_SUPPRESS_WARNINGS=1 - Suppress GIL warning + AP_DS_SHOW_CONGRATS=0 - Hide "GIL disabled" congratulations message +""" + +import os +import sys +import struct +import io +import warnings +from concurrent.futures import ProcessPoolExecutor, as_completed +from typing import List, Dict, Optional, Union, Tuple +from pathlib import Path + +# Error codes (mirror player.py values; defined locally to avoid circular import) +AP_DS_ERR_UNKNOWN = 1999 + + +# ============================================================ +# Runtime Environment Detection +# ============================================================ + +SUPPRESS_WARNINGS = os.environ.get('AP_DS_SUPPRESS_WARNINGS', '').lower() in ('1', 'true', 'yes', 'on') +SHOW_CONGRATS = os.environ.get('AP_DS_SHOW_CONGRATS', '').lower() not in ('0', 'false', 'no', 'off') + +_RUNTIME_CHECKED = False + + +def _check_runtime_mode(): + """ + Detect GIL status and notify the user accordingly. + """ + try: + gil_enabled = sys._is_gil_enabled() + except AttributeError: + gil_enabled = True # Pre-3.14 always has GIL + + if not gil_enabled: + if SHOW_CONGRATS: + print("🎉 ap_ds: GIL disabled (free-threading mode)") + else: + if not SUPPRESS_WARNINGS: + warnings.warn( + "⚠️ ap_ds: GIL is enabled (multi-core parallelism limited).\n" + " For full performance, upgrade to Python 3.15t:\n" + " https://mirrors.huaweicloud.com/python/3.15.0/python-3.15.0b4t-amd64.zip\n" + " To suppress this warning, set AP_DS_SUPPRESS_WARNINGS=1", + RuntimeWarning, + stacklevel=2 + ) + + return gil_enabled + + +def _ensure_runtime_checked(): + """Ensure runtime mode check is performed only once per process.""" + global _RUNTIME_CHECKED + if not _RUNTIME_CHECKED: + _check_runtime_mode() + _RUNTIME_CHECKED = True + + +_ensure_runtime_checked() + + +# ============================================================ +# Core Data Structures +# ============================================================ + +class StreamInfo: + """ + Container for audio stream metadata. + + Attributes: + length (float): Duration in seconds + sample_rate (int): Sample rate in Hz + channels (int): Number of audio channels (1=mono, 2=stereo) + bitrate (int): Bitrate in bits per second + """ + __slots__ = ("length", "sample_rate", "channels", "bitrate") + + def __init__(self, length, sample_rate, channels, bitrate): + self.length = float(length) + self.sample_rate = int(sample_rate) + self.channels = int(channels) + self.bitrate = int(bitrate) + + def __repr__(self): + return ( + f"" + ) + + +class FileType: + """ + Base class for format-specific parsers. + + Each subclass must implement _parse() to return a StreamInfo object. + """ + __slots__ = ("filename", "info") + + def __init__(self, filename): + self.filename = filename + self.info = self._parse() + + def _parse(self): + raise ValueError("Invalid audio file") + + @property + def length(self): + return self.info.length + + @property + def sample_rate(self): + return self.info.sample_rate + + @property + def channels(self): + return self.info.channels + + @property + def bitrate(self): + return self.info.bitrate + + +# ============================================================ +# Utility Functions +# ============================================================ + +def open_file(path): + """Open a file in binary read mode.""" + return open(path, "rb") + + +def read_u32_be(f): + """Read a big-endian 32-bit unsigned integer from a file.""" + return struct.unpack(">I", f.read(4))[0] + + +def read_u32_le(f): + """Read a little-endian 32-bit unsigned integer from a file.""" + return struct.unpack("I", b"\x00" + header[1:4])[0] + + if block_type == 0: # STREAMINFO + data = f.read(size) + sample_rate = ( + (data[10] << 12) + | (data[11] << 4) + | (data[12] >> 4) + ) + channels = ((data[12] >> 1) & 0x07) + 1 + total_samples = ( + ((data[13] & 0x0F) << 32) + | (data[14] << 24) + | (data[15] << 16) + | (data[16] << 8) + | data[17] + ) + length = total_samples / sample_rate + bitrate = os.path.getsize(self.filename) * 8 / length + return StreamInfo(length, sample_rate, channels, bitrate) + else: + f.seek(size, io.SEEK_CUR) + + if is_last: + break + + raise ValueError + + +# ============================================================ +# MP3 Parser (frame-by-frame scanning, >98% accuracy) +# ============================================================ + +# MP3 bitrate lookup table (indexed by header bits) +MP3_BITRATES = [ + None, 32, 40, 48, 56, 64, 80, 96, + 112, 128, 160, 192, 224, 256, 320, None +] + +# MP3 sample rate lookup table (indexed by header bits) +MP3_SAMPLE_RATES = [44100, 48000, 32000, None] + + +class MP3File(FileType): + """ + MP3 audio parser using frame-by-frame scanning. + + Scans the file for MP3 frame sync words (0xFF), counts frames, and + accumulates samples. Accuracy: >98% (limited by variable bitrate + and incomplete final frames). + """ + def _parse(self): + filesize = os.path.getsize(self.filename) + total_frames = 0 + + with open_file(self.filename) as f: + while True: + b = f.read(1) + if not b: + break + if b != b"\xff": + continue + + hdr = f.read(3) + if len(hdr) < 3: + break + if hdr[0] & 0xE0 != 0xE0: + f.seek(-3, 1) + continue + + bitrate = MP3_BITRATES[(hdr[1] >> 4) & 0x0F] + sample_rate = MP3_SAMPLE_RATES[(hdr[1] >> 2) & 0x03] + if not bitrate or not sample_rate: + f.seek(-3, 1) + continue + + frame_len = int(144000 * bitrate / sample_rate) + total_frames += 1 + f.seek(frame_len - 4, 1) + + length = total_frames * 1152 / sample_rate + bitrate = filesize * 8 / length + + return StreamInfo(length, sample_rate, 2, bitrate) + + +# ============================================================ +# AAC (ADTS) Parser (frame-by-frame, >99% accuracy) +# ============================================================ + +AAC_SAMPLE_RATES = [ + 96000, 88200, 64000, 48000, 44100, 32000, + 24000, 22050, 16000, 12000, 11025, 8000 +] + + +class AACFile(FileType): + """ + AAC audio parser using ADTS (Audio Data Transport Stream) frame parsing. + + Scans for ADTS sync words (0xFFF), parses frame headers to accumulate + samples. Each AAC frame contains 1024 samples. Accuracy: >99%. + """ + def _parse(self): + total_samples = 0 + + with open_file(self.filename) as f: + while True: + header = f.read(7) + if len(header) < 7: + break + if header[0] != 0xFF or (header[1] & 0xF0) != 0xF0: + break + + sr = AAC_SAMPLE_RATES[(header[2] >> 2) & 0x0F] + channels = ((header[2] & 1) << 2) | ((header[3] >> 6) & 3) + frame_length = ( + ((header[3] & 0x03) << 11) + | (header[4] << 3) + | (header[5] >> 5) + ) + + total_samples += 1024 + f.seek(frame_length - 7, 1) + + length = total_samples / sr + bitrate = os.path.getsize(self.filename) * 8 / length + + return StreamInfo(length, sr, channels, bitrate) + + +# ============================================================ +# OGG Vorbis Parser (granule position, 99.99% accuracy) +# ============================================================ + +class OGGFile(FileType): + """ + OGG Vorbis audio parser using granule position. + + Reads Ogg pages, extracts the granule position (total samples) from + the last page. Also parses the Vorbis identification header for + sample rate and channel count. Accuracy: 99.99%. + """ + def _parse(self): + filesize = os.path.getsize(self.filename) + + with open_file(self.filename) as f: + sample_rate = channels = None + last_granule = 0 + + while True: + header = f.read(27) + if len(header) < 27: + break + if header[:4] != b"OggS": + break + + granule = struct.unpack(" Optional[Dict]: + """ + Parse a single audio file and return metadata as a dictionary. + + Internal helper for batch operations. Returns None on failure. + + Args: + file_path: Path to the audio file + + Returns: + dict or None: Metadata dict with keys: + path, format, duration, length, sample_rate, channels, bitrate + """ + try: + audio = open_audio(file_path) + info = audio.info + + ext = os.path.splitext(file_path)[1].lower().lstrip(".") + + return { + "path": file_path, + "format": ext, + "duration": int(info.length), + "length": float(info.length), + "sample_rate": info.sample_rate, + "channels": info.channels, + "bitrate": info.bitrate, + } + except Exception: + return None + + +# ============================================================ +# Batch Processing API (ProcessPoolExecutor) +# ============================================================ + +def batch_get_metadata( + file_paths: Union[List[str], str], + max_workers: Optional[int] = None, + show_progress: bool = False +) -> List[Dict]: + """ + Parse multiple audio files in parallel using multiprocessing. + + ProcessPoolExecutor avoids file handle contention issues on Windows + with free-threading Python builds. + + Args: + file_paths: List of file paths, or a single directory path string. + If a directory is provided, all supported audio files + in that directory are scanned recursively. + max_workers: Maximum number of worker processes. Defaults to CPU count. + show_progress: If True, prints progress to stdout. + + Returns: + List[Dict]: List of metadata dictionaries. Failed parses are omitted. + + Examples: + >>> results = batch_get_metadata(["song1.mp3", "song2.flac"]) + >>> results = batch_get_metadata("/music/playlist/", show_progress=True) + """ + # If a directory is given, expand to list of files + if isinstance(file_paths, (str, Path)): + dir_path = Path(file_paths) + if dir_path.is_dir(): + supported_exts = {'.mp3', '.wav', '.flac', '.ogg', '.aac'} + file_paths = [ + str(p) for p in dir_path.rglob('*') + if p.suffix.lower() in supported_exts and p.is_file() + ] + else: + file_paths = [str(file_paths)] + + if not file_paths: + return [] + + if max_workers is None: + max_workers = min(os.cpu_count() or 4, len(file_paths)) + + # Invalid max_workers: build the pool and, on failure, return an error + # tuple for the caller to handle (the library does not raise). + try: + executor = ProcessPoolExecutor(max_workers=max_workers) + except (ValueError, TypeError) as e: + return (AP_DS_ERR_UNKNOWN, f"Invalid max_workers: {e}", + "max_workers must be a positive integer or None for automatic") + + results = [] + total = len(file_paths) + completed = 0 + + with executor: + future_to_path = { + executor.submit(_parse_single_file, path): path + for path in file_paths + } + + for future in as_completed(future_to_path): + path = future_to_path[future] + completed += 1 + + if show_progress and completed % 10 == 0: + print(f"Progress: {completed}/{total} files parsed") + + try: + metadata = future.result() + if metadata: + results.append(metadata) + else: + print(f"⚠️ Parse failed: {os.path.basename(path)}") + except Exception as e: + print(f"❌ Parse error [{os.path.basename(path)}]: {type(e).__name__}: {e}") + + if show_progress: + print(f"✅ Batch parse complete: {len(results)}/{total} files successful") + + return results + + +def batch_get_duration( + file_paths: Union[List[str], str], + max_workers: Optional[int] = None +) -> Dict[str, int]: + """ + Get durations for multiple audio files in parallel. + + Args: + file_paths: List of file paths, or a single directory path string. + max_workers: Maximum number of worker processes. Defaults to CPU count. + + Returns: + Dict[str, int]: Mapping of file_path -> duration_in_seconds. + Files that failed to parse are omitted. + + Examples: + >>> durations = batch_get_duration(["song1.mp3", "song2.flac"]) + >>> print(durations["song1.mp3"]) # 240 + >>> durations = batch_get_duration("/music/playlist/") + """ + metadata_list = batch_get_metadata( + file_paths, + max_workers=max_workers, + show_progress=False + ) + return {item["path"]: item["duration"] for item in metadata_list} + + +def batch_get_metadata_by_type( + file_paths: Union[List[str], str], + file_type: str, + max_workers: Optional[int] = None +) -> List[Dict]: + """ + Parse multiple audio files but only return results for a specific format. + + Useful when you only care about MP3 files in a mixed directory. + + Args: + file_paths: List of file paths, or a single directory path string. + file_type: File extension to filter (e.g., "mp3", "flac") + max_workers: Maximum number of worker processes. + + Returns: + List[Dict]: Metadata for files matching the specified type. + """ + file_type = file_type.lower().lstrip(".") + all_results = batch_get_metadata( + file_paths, + max_workers=max_workers, + show_progress=False + ) + return [r for r in all_results if r.get("format", "").lower() == file_type] + +def get_audio_duration(file_path: str) -> int: + """Get duration of a single audio file in seconds.""" + try: + audio = open_audio(file_path) + return int(audio.length) + except Exception: + return 0 + + +def get_audio_metadata(file_path: str) -> Optional[Dict]: + """Get complete metadata for a single audio file.""" + try: + audio = open_audio(file_path) + info = audio.info + ext = os.path.splitext(file_path)[1].lower().lstrip(".") + return { + "path": file_path, + "format": ext, + "duration": int(info.length), + "length": float(info.length), + "sample_rate": info.sample_rate, + "channels": info.channels, + "bitrate": info.bitrate, + } + except Exception: + return None diff --git a/ap_ds/opusplayer.py b/ap_ds/opusplayer.py new file mode 100644 index 0000000..c0ef922 --- /dev/null +++ b/ap_ds/opusplayer.py @@ -0,0 +1,1221 @@ +# -*- 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 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)") + if self._of is None: + return (AP_DS_ERR_AUDIO_NOT_LOADED, "Audio not loaded", "Call play_from_file first") + + position = max(0.0, float(position)) + # Check if stream is seekable + if not op_seekable(self._of): + return (AP_DS_ERR_OPUS_NOT_SEEKABLE, "Opus stream is not seekable", + "This Opus stream does not support seeking") + target_sample = int(position * self.sample_rate) + ret = op_pcm_seek(self._of, target_sample) + if ret != 0: + return (AP_DS_ERR_OPUS_SEEK_FAILED, f"Opus seek failed at position {position}", + "The Opus stream may not support seeking to this position") + with self._lock: + self._played = target_sample + 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") + if self._playing: + return (AP_DS_ERR_PLAYBACK_FAILED, "Already playing", "Stop current playback first") + + 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, ms if ms > 0 else 2000, 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") + 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, ms if ms > 0 else 2000, 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 + # Restore volume_pct to the final volume after fade completes + self.volume_pct = max(0, min(100, int(end_vol))) + if fade_type == MUS_FADING_OUT and not self._fade_stop_flag: + self._stop_flag = True + if self._hwo is not None: + waveOutReset(self._hwo) + + +# ============================================================================ +# TUI main program test +# ============================================================================ diff --git a/ap_ds/player.py b/ap_ds/player.py new file mode 100644 index 0000000..7d13dd4 --- /dev/null +++ b/ap_ds/player.py @@ -0,0 +1,1357 @@ +# player.py - AudioLibrary class only (SDL2 imported from _sdl2.py) + +import os +import sys +import time +import atexit +import warnings +from collections import defaultdict +from typing import * + +# ============================================================ +# Import SDL2 from _sdl2.py (local file, NOT pysdl2 package) +# ============================================================ + +try: + from ._sdl2 import * + from ._sdl2 import _mix_lib, _sdl_lib +except ImportError: + try: + from _sdl2 import * + from _sdl2 import _mix_lib, _sdl_lib + except ImportError: + raise ImportError( + "SDL2 module (_sdl2) not found. " + "Please ensure _sdl2.py is in the ap_ds package." + ) + +# ============================================================ +# Python 3.15+ lazy import support +# ============================================================ +# Check if _IS_PYTHON_315_PLUS already exists (imported from other module) +try: + # Try to get from current module first + _IS_PYTHON_315_PLUS = _IS_PYTHON_315_PLUS + print(f"ℹ️ _IS_PYTHON_315_PLUS already exists: {_IS_PYTHON_315_PLUS} (from other module)") +except NameError: + # Not defined yet, get it now + _IS_PYTHON_315_PLUS = sys.version_info >= (3, 15) + print(f"ℹ️ _IS_PYTHON_315_PLUS defined now: {_IS_PYTHON_315_PLUS} (Python {sys.version_info.major}.{sys.version_info.minor})") + +if _IS_PYTHON_315_PLUS: + try: + # Try to use lazy import (Python 3.15+) + exec(""" +lazy import ctypes +lazy import urllib.request +lazy import struct +lazy import json +lazy import typing +lazy import shutil +lazy import tempfile +lazy import subprocess +lazy import ssl +lazy import hashlib +""") + print("✅ Using lazy imports (Python 3.15+)") + except (SyntaxError, TypeError, NameError) as e: + # Fallback to regular imports if lazy import fails + print(f"⚠️ Lazy import failed ({e}), falling back to regular imports") + import ctypes + import urllib.request + import struct + import json + import typing + import shutil + import tempfile + import subprocess + import ssl + import hashlib +else: + import ctypes + import urllib.request + import struct + import json + import typing + import shutil + import tempfile + import subprocess + import ssl + import hashlib + +# ctypes content still usable (already imported) +from ctypes import * +from typing import * + + +# ============================================================ +# Error Codes +# ============================================================ + +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_SDL_INIT_FAILED = 1005 +AP_DS_ERR_MIXER_INIT_FAILED = 1006 +AP_DS_ERR_UNSUPPORTED_FORMAT = 1007 +AP_DS_ERR_NOT_MUSIC_FILE = 1008 +AP_DS_ERR_DAP_INVALID_EXT = 1009 +AP_DS_ERR_DAP_SAVE_FAILED = 1010 +AP_DS_ERR_METADATA_PARSE_FAILED = 1011 +AP_DS_ERR_FADE_NOT_SUPPORTED = 1012 +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 + + +# ============================================================ +# WAV Threshold +# ============================================================ + +WAV_THRESHOLD = int(os.environ.get('AP_DS_WAV_THRESHOLD', '6')) +if WAV_THRESHOLD >= 30: + print(f"Warning: WAV threshold {WAV_THRESHOLD}s is too large. Using default 6s to prevent memory leaks.") + WAV_THRESHOLD = 6 +elif WAV_THRESHOLD < 0: + print(f"Warning: WAV threshold {WAV_THRESHOLD}s is negative. Using default 6s.") + WAV_THRESHOLD = 6 + +print(f"🎵 WAV playback mode threshold: {WAV_THRESHOLD}s (Files >= {WAV_THRESHOLD}s use music mode, < {WAV_THRESHOLD}s use sound effect mode)") + + +# ============================================================ +# audio_parser import +# ============================================================ + +AUDIO_PARSER_AVAILABLE = False + +try: + from .audio_parser import * + AUDIO_PARSER_AVAILABLE = True +except ImportError: + try: + from audio_parser import * + AUDIO_PARSER_AVAILABLE = True + except ImportError: + print("Warning: audio_parser module not available, using fallback duration methods") + + +# ============================================================ +# Opus Support Integration +# ============================================================ + +OPUS_PLAYER_AVAILABLE = False + +try: + from .opusplayer import OpusAudio as _OpusAudio + OPUS_PLAYER_AVAILABLE = True +except ImportError: + try: + from opusplayer import OpusAudio as _OpusAudio + OPUS_PLAYER_AVAILABLE = True + except ImportError: + _OpusAudio = None + print("Warning: opusplayer module not available, Opus playback disabled") + + +def _is_opus_file(file_path): + """Check if a file is an Opus audio file.""" + ext = os.path.splitext(str(file_path))[1].lower() + return ext == '.opus' + + +# ============================================================ +# AudioLibrary Class +# ============================================================ + +class AudioLibrary: + def __init__(self, frequency: int = 44100, format: int = MIX_DEFAULT_FORMAT, + channels: int = 2, chunksize: int = 2048): + """Initialize the audio library""" + # Initialize SDL audio + if SDL_Init(SDL_INIT_AUDIO) != 0: + raise RuntimeError(f"SDL initialization failed") + + if Mix_OpenAudio(frequency, format, channels, chunksize) != 0: + raise RuntimeError(f"Mixer initialization failed") + + atexit.register(self.cleanup_function) + self.MUS_NO_FADING = 0 + self.MUS_FADING_IN = 1 + self.MUS_FADING_OUT = 2 + # Audio state tracking + self._audio_cache = {} # File path -> Mix_Chunk + self._music_cache = {} # File path -> Mix_Music + self._channel_info = {} # Channel ID -> Playback info + self._aid_to_filepath = {} # Store AID to file mapping + self._aid_counter = 0 + self._sample_rate = frequency + self._format = format + self._channels = channels + + # Initialize DAP recordings list + self._dap_recordings = [] # List of DAP format recordings + self._dap_records_set = set() # O(1) deduplication set + + # Opus playback support + self._opus_audio = None # OpusAudio instance (lazy init) + self._aid_to_opus_aid = {} # Main AID -> Opus sub-AID mapping + + def _get_opus_player(self): + """Get or create the OpusAudio sub-player instance.""" + if self._opus_audio is None: + if not OPUS_PLAYER_AVAILABLE: + return None + self._opus_audio = _OpusAudio() + return self._opus_audio + + def _map_opus_aid(self, main_aid, opus_aid): + """Map a main library AID to an Opus sub-library AID.""" + self._aid_to_opus_aid[main_aid] = opus_aid + return main_aid + + def _is_opus_aid(self, aid): + """Check if an AID is an Opus AID (has a mapped Opus sub-AID).""" + return aid in self._aid_to_opus_aid + + def _get_opus_aid(self, aid): + """Get the Opus sub-AID for a main AID.""" + return self._aid_to_opus_aid.get(aid) + + def Delay(self, ms): + _sdl_lib.SDL_Delay(ms) + + # ============================================================ + # Batch APIs (passthrough to audio_parser) + # ============================================================ + + def batch_get_metadata(self, file_paths, max_workers=None, show_progress=False): + # Expand paths + paths = file_paths if isinstance(file_paths, list) else [file_paths] + opus_files = [p for p in paths if _is_opus_file(p)] + non_opus_files = [p for p in paths if not _is_opus_file(p)] + + results = [] + # Opus files -> use opusplayer (ensure player created) + opus_player = self._get_opus_player() + if opus_files and OPUS_PLAYER_AVAILABLE and opus_player is not None: + results.extend(opus_player.batch_get_metadata(opus_files, max_workers, show_progress)) + # Non-Opus files -> use audio_parser + if non_opus_files: + if not AUDIO_PARSER_AVAILABLE: + raise RuntimeError("audio_parser not available") + results.extend(batch_get_metadata(non_opus_files, max_workers, show_progress)) + return results + + def batch_get_duration(self, file_paths, max_workers=None): + # Expand paths + paths = file_paths if isinstance(file_paths, list) else [file_paths] + opus_files = [p for p in paths if _is_opus_file(p)] + non_opus_files = [p for p in paths if not _is_opus_file(p)] + + result = {} + # Opus files -> use opusplayer (ensure player created) + opus_player = self._get_opus_player() + if opus_files and OPUS_PLAYER_AVAILABLE and opus_player is not None: + result.update(opus_player.batch_get_duration(opus_files, max_workers)) + # Non-Opus files -> use audio_parser + if non_opus_files: + if not AUDIO_PARSER_AVAILABLE: + raise RuntimeError("audio_parser not available") + result.update(batch_get_duration(non_opus_files, max_workers)) + return result + + def batch_get_metadata_by_type(self, file_paths, file_type, max_workers=None): + # If Opus files requested, use opusplayer (ensure player created) + opus_player = self._get_opus_player() + if OPUS_PLAYER_AVAILABLE and opus_player is not None and file_type.lower() in ('opus', '.opus'): + return opus_player.batch_get_metadata_by_type(file_paths, file_type, max_workers) + if not AUDIO_PARSER_AVAILABLE: + raise RuntimeError("audio_parser not available") + return batch_get_metadata_by_type(file_paths, file_type, max_workers) + + # Core playback functionality ====================================================== + def play_audio(self, aid: int) -> Tuple[int, str, str]: + """ + Play/resume audio with specified AID + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.play_audio(self._get_opus_aid(aid)) + + channel = self._find_channel_by_aid(aid) + if channel is None: + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") + + info = self._channel_info[channel] + if info['paused']: + if info['is_music']: + Mix_ResumeMusic() + else: + Mix_Resume(channel) + info['paused'] = False + info['start_time'] = time.time() - info['paused_position'] + + return (AP_DS_SUCCESS, "", "") + + # DAP recording functionality ====================================================== + + def play_from_file(self, file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]: + """ + Play audio directly from file, return AID. + + Note: .ap-ds-dap files are DAP export records (output format) and are + NOT supported as playback input. Passing one will return + AP_DS_ERR_AUDIO_LOAD_FAILED. + + Args: + file_path: Path to audio file + loops: Number of loops + start_pos: Starting position + + Returns: + int: Audio ID (AID) on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) 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") + if not isinstance(loops, int) or isinstance(loops, bool): + return (AP_DS_ERR_UNKNOWN, f"Invalid loops type: {type(loops).__name__}. Expected int.", "loops must be an integer (-1=infinite, 0=once, >0=count)") + 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") + + # Generate AID + self._aid_counter += 1 + aid = self._aid_counter + + # Record to DAP list without playing + self._add_to_dap_recordings(file_path) + self._aid_to_filepath[aid] = file_path + + # Opus playback: detect and delegate to OpusAudio sub-player + if _is_opus_file(file_path): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module") + opus_result = opus.play_from_file(file_path, loops, start_pos) + if isinstance(opus_result, int): + # Map main AID to Opus sub-AID + self._map_opus_aid(aid, opus_result) + return aid + else: + # Opus playback failed, return the Opus error + return opus_result + + # Normal audio file playback (MP3, OGG, FLAC, WAV) + # Music file handling + if self._is_music_file(file_path): + music = Mix_LoadMUS(file_path.encode()) + if not music: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music file: {file_path}", "Check file format and integrity") + + if Mix_PlayMusic(music, loops) != 0: + Mix_FreeMusic(music) + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play music: {file_path}", "Check audio device and file format") + + channel = -1 + self._music_cache[file_path] = music + + # Sound effect handling + else: + audio = Mix_LoadWAV(file_path.encode()) + if not audio: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load audio file: {file_path}", "Check file format and integrity") + + channel = Mix_PlayChannel(-1, audio, loops) + if channel == -1: + Mix_FreeChunk(audio) + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play audio: {file_path}", "No available audio channels") + + self._audio_cache[file_path] = audio + + # Record playback information + self._channel_info[channel] = { + 'aid': aid, + 'start_time': time.time() - start_pos, + 'paused': False, + 'file_path': file_path, + 'is_music': channel == -1, + 'loops': loops + } + + # Seek to specified position + if start_pos > 0: + self._seek_audio(channel, start_pos) + self._aid_to_filepath[aid] = file_path + + return aid + + def play_from_memory(self, file_path: str, loops: int = 0, start_pos: float = 0.0) -> Union[int, Tuple[int, str, str]]: + """ + Play audio from memory cache, return AID. + + Note: .ap-ds-dap files are DAP export records (output format) and are + NOT supported as playback input. + + Args: + file_path: Path to audio file + loops: Number of loops + start_pos: Starting position + + Returns: + int: Audio ID (AID) on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + """ + if not isinstance(file_path, (str, bytes, os.PathLike)): + return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Invalid file path type: {type(file_path).__name__}", "file_path must be a string or os.PathLike") + if not isinstance(loops, int) or isinstance(loops, bool): + return (AP_DS_ERR_UNKNOWN, f"Invalid loops type: {type(loops).__name__}. Expected int.", "loops must be an integer (-1=infinite, 0=once, >0=count)") + # Generate AID + self._aid_counter += 1 + aid = self._aid_counter + + + # Record to DAP list without playing + self._add_to_dap_recordings(file_path) + self._aid_to_filepath[aid] = file_path + + # Opus playback: detect and delegate + if _is_opus_file(file_path): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module") + opus_result = opus.play_from_file(file_path, loops, start_pos) + if isinstance(opus_result, int): + self._map_opus_aid(aid, opus_result) + return aid + else: + return opus_result + + # Normal audio file playback + if file_path in self._music_cache: + if Mix_PlayMusic(self._music_cache[file_path], loops) != 0: + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play music from cache: {file_path}", "Check audio device") + channel = -1 + elif file_path in self._audio_cache: + channel = Mix_PlayChannel(-1, self._audio_cache[file_path], loops) + if channel == -1: + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to play audio from cache: {file_path}", "No available audio channels") + else: + return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Audio not loaded in memory: {file_path}", "Call new_aid() first to load the file") + + # Record playback information + self._channel_info[channel] = { + 'aid': aid, + 'start_time': time.time() - start_pos, + 'paused': False, + 'file_path': file_path, + 'is_music': channel == -1, + 'loops': loops + } + + if start_pos > 0: + self._seek_audio(channel, start_pos) + self._aid_to_filepath[aid] = file_path + + return aid + + def new_aid(self, file_path: str) -> Union[int, Tuple[int, str, str]]: + """ + Generate AID for file. + + Args: + file_path: Path to audio file + + Returns: + int: Audio ID (AID) on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) 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") + 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") + + # Generate AID + self._aid_counter += 1 + aid = self._aid_counter + + + # Record to DAP list without loading + self._add_to_dap_recordings(file_path) + self._aid_to_filepath[aid] = file_path + + # Opus: register in opusplayer + if _is_opus_file(file_path): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Opus support not available for: {file_path}", "Install opusplayer module") + opus_aid = opus.new_aid(file_path) + if isinstance(opus_aid, int): + self._map_opus_aid(aid, opus_aid) + return aid + + # Normal audio file loading + if self._is_music_file(file_path): + if file_path not in self._music_cache: + music = Mix_LoadMUS(file_path.encode()) + if not music: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music file: {file_path}", "Check file format and integrity") + self._music_cache[file_path] = music + else: + if file_path not in self._audio_cache: + audio = Mix_LoadWAV(file_path.encode()) + if not audio: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load audio file: {file_path}", "Check file format and integrity") + self._audio_cache[file_path] = audio + + self._aid_to_filepath[aid] = file_path + + return aid + + # Music fade control functionality ====================================================== + def fadein_music(self, aid: int, loops: int = -1, ms: int = 0) -> Tuple[int, str, str]: + """ + Fade in music (basic fade in) + + Args: + aid: AID of the music file + loops: Number of loops, -1 for infinite, 0 for no loop, >0 for loop count + ms: Fade in time in milliseconds + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.fadein_music(self._get_opus_aid(aid), loops, ms) + + # Find AID corresponding music info + for channel, info in self._channel_info.items(): + if info['aid'] == aid and info['is_music']: + file_path = info['file_path'] + + # Record to DAP (important!) + self._add_to_dap_recordings(file_path) + + # Load music file + music = self._music_cache.get(file_path) + if not music: + music = Mix_LoadMUS(file_path.encode()) + if not music: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for fadein: {file_path}", "Check file format and integrity") + self._music_cache[file_path] = music + + # Stop current playback + Mix_HaltMusic() + + # Validate fade parameters + if not isinstance(ms, int) or not isinstance(loops, int): + return (AP_DS_ERR_UNKNOWN, "Invalid fade parameters: ms and loops must be integers", "Check fade parameters (ms: int milliseconds, loops: int)") + # Start fade in + result = Mix_FadeInMusic(music, loops, ms) + if result == 0: + # Update playback info + info['start_time'] = time.time() + info['paused'] = False + info['loops'] = loops + return (AP_DS_SUCCESS, "", "") + else: + error_msg = SDL_GetError().decode() if SDL_GetError() else 'Unknown error' + return (AP_DS_ERR_PLAYBACK_FAILED, f"Mix_FadeInMusic failed: {error_msg}", "Check SDL_mixer compatibility") + + return (AP_DS_ERR_INVALID_AID, f"AID {aid} not found or not a music file", "Verify the AID corresponds to a music file") + + def fadein_music_pos(self, aid: int, loops: int = -1, ms: int = 0, position: float = 0.0) -> Tuple[int, str, str]: + """ + Fade in music from specified position + + Args: + aid: AID of the music file + loops: Number of loops, -1 for infinite + ms: Fade in time in milliseconds + position: Starting position in seconds + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Check if function exists + if not hasattr(_mix_lib, 'Mix_FadeInMusicPos'): + return (AP_DS_ERR_FADE_NOT_SUPPORTED, "Mix_FadeInMusicPos not supported in this SDL_mixer version", "Update SDL_mixer or use fadein_music()") + + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.fadein_music(self._get_opus_aid(aid), loops, ms) + + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.fadein_music_pos(self._get_opus_aid(aid), loops, ms, position) + + # Find AID corresponding music info + for channel, info in self._channel_info.items(): + if info['aid'] == aid and info['is_music']: + file_path = info['file_path'] + + # Record to DAP (important!) + self._add_to_dap_recordings(file_path) + + # Load music file + music = self._music_cache.get(file_path) + if not music: + music = Mix_LoadMUS(file_path.encode()) + if not music: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for fadein: {file_path}", "Check file format and integrity") + self._music_cache[file_path] = music + + # Stop current playback + Mix_HaltMusic() + + # Validate fade parameters + if not isinstance(ms, int) or not isinstance(loops, int) or not isinstance(position, (int, float)): + return (AP_DS_ERR_UNKNOWN, "Invalid fade parameters: ms/loops must be int, position must be a number", "Check fade parameters") + # Start fade in from specified position + result = Mix_FadeInMusicPos(music, loops, ms, position) + if result == 0: + # Update playback info + info['start_time'] = time.time() - position + info['paused'] = False + info['loops'] = loops + return (AP_DS_SUCCESS, "", "") + else: + error_msg = SDL_GetError().decode() if SDL_GetError() else 'Unknown error' + return (AP_DS_ERR_PLAYBACK_FAILED, f"Mix_FadeInMusicPos failed: {error_msg}", "Check SDL_mixer compatibility") + + return (AP_DS_ERR_INVALID_AID, f"AID {aid} not found or not a music file", "Verify the AID corresponds to a music file") + + def fadeout_music(self, ms: int = 0) -> Tuple[int, str, str]: + """ + Fade out currently playing music + + Args: + ms: Fade out time in milliseconds + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player if Opus is playing + if self._opus_audio is not None and self._opus_audio.is_music_playing(): + return self._opus_audio.fadeout_music(ms) + + result = Mix_FadeOutMusic(ms) + if result == 1: + return (AP_DS_SUCCESS, "", "") + else: + return (AP_DS_ERR_PLAYBACK_FAILED, "Mix_FadeOutMusic failed or no music playing", "Ensure music is playing before calling fadeout") + + def is_music_playing(self) -> bool: + """ + Check if music is currently playing + + Returns: + bool: True if playing, False otherwise + """ + # Check Opus sub-player first + if self._opus_audio is not None and self._opus_audio.is_music_playing(): + return True + return Mix_PlayingMusic() == 1 + + def is_music_paused(self) -> bool: + """ + Check if music is paused + + Returns: + bool: True if paused, False otherwise + """ + # Check Opus sub-player first + if self._opus_audio is not None and self._opus_audio.is_music_paused(): + return True + return Mix_PausedMusic() == 1 + + def get_music_fading(self) -> int: + """ + Get fade state of music + + Returns: + int: State code + - 0 (MUS_NO_FADING): No fade + - 1 (MUS_FADING_IN): Fading in + - 2 (MUS_FADING_OUT): Fading out + """ + # Check Opus sub-player first + if self._opus_audio is not None and self._opus_audio.get_music_fading() != 0: + return self._opus_audio.get_music_fading() + return Mix_FadingMusic() + + # ============================================================ + # DAP Recording System (v3.1.0+ with O(1) deduplication + fallback) + # ============================================================ + + def _add_to_dap_recordings(self, file_path: str) -> None: + """ + Add audio file metadata to DAP (Dvs Audio Playlist) records. + + Primary: O(1) set-based deduplication. + Fallback: O(n) linear scan if set fails. + + Args: + file_path: Path to the audio file + """ + try: + # Get metadata using audio_parser + metadata = self.get_audio_metadata_by_path(file_path) + if not metadata or not isinstance(metadata, dict): + return + + # Create DAP record + record = { + 'path': file_path, + 'duration': metadata.get('duration', 0), + 'bitrate': metadata.get('bitrate', 0), + 'channels': metadata.get('channels', 2) + } + + # Primary: O(1) set-based deduplication + if file_path not in self._dap_records_set: + self._dap_records_set.add(file_path) + self._dap_recordings.append(record) + print(f"📝 Recorded DAP file: {file_path}") + return + + except Exception as e: + # Fallback: O(n) linear scan if set fails + print(f"⚠️ DAP set deduplication failed: {e}, falling back to O(n) scan") + + # ============================================================ + # Fallback: O(n) linear scan (redundant backup) + # ============================================================ + try: + metadata = self.get_audio_metadata_by_path(file_path) + if not metadata or not isinstance(metadata, dict): + return + + record = { + 'path': file_path, + 'duration': metadata.get('duration', 0), + 'bitrate': metadata.get('bitrate', 0), + 'channels': metadata.get('channels', 2) + } + + # O(n) linear deduplication + if not any(r.get('path') == file_path for r in self._dap_recordings): + self._dap_recordings.append(record) + print(f"📝 Recorded DAP file (fallback): {file_path}") + + except Exception as e: + print(f"⚠️ Failed to record DAP (fallback also failed): {e}") + + def save_dap_to_json(self, save_path: str) -> Tuple[int, str, str]: + """ + Save DAP recordings to JSON file. ONLY WHEN USER CALLS THIS FUNCTION. + + Args: + save_path: Path to save JSON file + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + try: + # Validate file extension + if not save_path.lower().endswith('.ap-ds-dap'): + return (AP_DS_ERR_DAP_INVALID_EXT, + f"Invalid file extension. Expected '.ap-ds-dap' but got '{os.path.splitext(save_path)[1]}'", + "Use .ap-ds-dap extension for DAP files") + + # Save to JSON with UTF-8 encoding + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(self._dap_recordings, f, ensure_ascii=False, indent=2) + + print(f"✅ Saved {len(self._dap_recordings)} DAP records to: {save_path}") + return (AP_DS_SUCCESS, "", "") + + except Exception as e: + return (AP_DS_ERR_DAP_SAVE_FAILED, f"Error saving DAP to JSON: {str(e)}", "Check write permissions and disk space") + + def get_dap_recordings(self) -> List[Dict]: + """ + Get current DAP recordings from memory. + + Returns: + List[Dict]: List of DAP records + """ + return self._dap_recordings.copy() + + # ============================================================ + # clear_dap_recordings() - Clear both list and set + # ============================================================ + + def clear_dap_recordings(self) -> None: + """Clear all DAP recordings from memory.""" + self._dap_recordings.clear() + self._dap_records_set.clear() + print("🗑️ Cleared all DAP recordings") + + def _is_music_file(self, file_path: str) -> bool: + """ + Check if file should be treated as music file. + + For WAV files: + - Duration < threshold (default 6s) → sound effect mode + - Duration >= threshold → music mode + + For other formats: + MP3, OGG, FLAC → music mode + Others (non-WAV) → sound effect mode + + Args: + file_path: Path to audio file + + Returns: + bool: True if music mode, False if sound effect mode + """ + ext = os.path.splitext(file_path)[1].lower() + + # Non-WAV formats + if ext in ['.mp3', '.ogg', '.flac']: + return True + + # WAV files - check duration using existing metadata API + if ext == '.wav': + try: + # Use existing metadata method to get duration + metadata = self.get_audio_metadata_by_path(file_path) + if metadata and 'duration' in metadata: + duration = metadata['duration'] + # Use global WAV_THRESHOLD + return duration >= WAV_THRESHOLD + else: + # If metadata not available, default to music mode for safety + print(f"Warning: Could not get duration for WAV file: {file_path}, defaulting to music mode") + return True + + except Exception as e: + print(f"Warning: Error getting WAV metadata for {file_path}: {e}") + # Default to music mode for safety if metadata fetch fails + return True + + # Other formats (AIF, AU, etc.) - sound effect mode + return False + + # Control functionality ====================================================== + + def pause_audio(self, aid: int) -> Tuple[int, str, str]: + """ + Pause audio with specified AID + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.pause_audio(self._get_opus_aid(aid)) + + channel = self._find_channel_by_aid(aid) + if channel is None: + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") + + info = self._channel_info[channel] + if not info['paused']: + if info['is_music']: + Mix_PauseMusic() + else: + Mix_Pause(channel) + info['paused'] = True + info['paused_position'] = time.time() - info['start_time'] + + return (AP_DS_SUCCESS, "", "") + + def stop_audio(self, aid: int) -> Union[float, Tuple[int, str, str]]: + """ + Stop playback and return played duration + + Returns: + float: Played duration in seconds on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + result = opus.stop_audio(self._get_opus_aid(aid)) + # Remove AID mapping after stop + self._aid_to_opus_aid.pop(aid, None) + return result + + channel = self._find_channel_by_aid(aid) + if channel is None: + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") + + info = self._channel_info[channel] + played_time = time.time() - info['start_time'] if not info['paused'] else info['paused_position'] + + if info['is_music']: + Mix_HaltMusic() + else: + Mix_HaltChannel(channel) + + del self._channel_info[channel] + return played_time + + def seek_audio(self, aid: int, position: float) -> Tuple[int, str, str]: + """ + Seek to specified position (seconds) + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.seek_audio(self._get_opus_aid(aid), position) + + channel = self._find_channel_by_aid(aid) + if channel is None: + 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)") + return self._seek_audio(channel, position) + + def _seek_audio(self, channel: int, position: float) -> Tuple[int, str, str]: + """Internal method: seek audio""" + info = self._channel_info.get(channel) + if not info: + return (AP_DS_ERR_INVALID_AID, f"Channel {channel} not found", "Internal error - channel not in tracking") + + # Music seeking + if info['is_music']: + Mix_HaltMusic() + # Reuse the cached music object to avoid re-decoding the whole + # file and leaking the previous Mix_Music instance on every seek. + music = self._music_cache.get(info['file_path']) + if not music: + music = Mix_LoadMUS(info['file_path'].encode()) + if not music: + return (AP_DS_ERR_AUDIO_LOAD_FAILED, f"Failed to load music for seeking: {info['file_path']}", "Check file format and integrity") + self._music_cache[info['file_path']] = music + + if Mix_PlayMusic(music, info['loops']) != 0: + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to reload music for seeking: {info['file_path']}", "Check file integrity") + + if hasattr(Mix_SetMusicPosition, '__call__'): + Mix_SetMusicPosition(position) + + self._channel_info[channel] = { + **info, + 'start_time': time.time() - position, + 'paused': False + } + return (AP_DS_SUCCESS, "", "") + # Sound effect seeking - not supported + else: + Mix_HaltChannel(channel) + audio = self._audio_cache.get(info['file_path']) + if audio is None: + return (AP_DS_ERR_AUDIO_NOT_LOADED, f"Audio not in cache: {info['file_path']}", "Call new_aid() to reload the file") + new_channel = Mix_PlayChannel(-1, audio, info['loops']) + if new_channel == -1: + return (AP_DS_ERR_PLAYBACK_FAILED, "Failed to replay audio after seek", "No available audio channels") + + self._channel_info[new_channel] = { + **info, + 'start_time': time.time() - position, + 'paused': False + } + if new_channel != channel: + del self._channel_info[channel] + return (AP_DS_SUCCESS, "", "") + + + def set_volume(self, aid: int, volume: int) -> Tuple[int, str, str]: + """ + Set audio volume + + Args: + aid: Audio ID + volume: Volume value (0-128) + + Returns: + Tuple[int, str, str]: (AP_DS_SUCCESS, "", "") on success, + (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.set_volume(self._get_opus_aid(aid), volume) + + channel = self._find_channel_by_aid(aid) + if channel is None: + 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") + + info = self._channel_info[channel] + + if info['is_music']: + result = Mix_VolumeMusic(volume) + if result != -1: + return (AP_DS_SUCCESS, "", "") + else: + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to set music volume", "Check audio device") + else: + result = Mix_Volume(channel, volume) + if result != -1: + return (AP_DS_SUCCESS, "", "") + else: + return (AP_DS_ERR_PLAYBACK_FAILED, f"Failed to set channel volume", "Check audio device") + + def get_volume(self, aid: int) -> Union[int, Tuple[int, str, str]]: + """ + Get current audio volume + + Args: + aid: Audio ID + + Returns: + int: Current volume value (0-128) on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + """ + # Opus playback: delegate to OpusAudio sub-player + if self._is_opus_aid(aid): + opus = self._get_opus_player() + if opus is None: + return (AP_DS_ERR_PLAYBACK_FAILED, "Opus player not available", "Install opusplayer module") + return opus.get_volume(self._get_opus_aid(aid)) + + channel = self._find_channel_by_aid(aid) + if channel is None: + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid and the audio is loaded") + + info = self._channel_info[channel] + if info['is_music']: + return Mix_VolumeMusic(-1) # -1 means get without setting + else: + return Mix_Volume(channel, -1) # -1 means get without setting + + + # Helper methods ====================================================== + + def _find_channel_by_aid(self, aid: int) -> Optional[int]: + """Find channel by AID""" + for channel, info in self._channel_info.items(): + if info['aid'] == aid: + return channel + return None + + + def _get_file_path_by_aid(self, aid: int) -> Union[str, Tuple[int, str, str]]: + """Get file path by AID""" + for info in self._channel_info.values(): + if info['aid'] == aid: + return info['file_path'] + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid") + + + def get_audio_duration(self, source: Union[str, int], is_file: bool = False) -> Union[int, Tuple[int, str, str]]: + """ + Get the duration of an audio file in seconds + + This method supports both file paths and AID (Audio ID) as input sources. + It automatically detects the file format and uses the appropriate parser + to calculate the duration accurately. + + Args: + source: Either a file path string or an integer AID + is_file: If True, treats source as file path; if False, as AID + + Returns: + int: Duration in seconds (rounded) on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + + Examples: + >>> # Get duration by file path + >>> duration = lib.get_audio_duration("audio/song.mp3", is_file=True) + >>> # Get duration by AID + >>> duration = lib.get_audio_duration(123, is_file=False) + """ + try: + # Case 1: Get duration by AID (Audio ID) + if not is_file and 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") + + file_path = self._aid_to_filepath[source] + return self._get_duration_by_filepath(file_path) + + # Case 2: Get duration by file path + file_path = str(source) + return self._get_duration_by_filepath(file_path) + + except Exception as e: + return (AP_DS_ERR_UNKNOWN, f"Error getting audio duration: {str(e)}", "Check file integrity and try again") + + def _get_duration_by_filepath(self, file_path: str) -> Union[int, Tuple[int, str, str]]: + try: + 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") + + # Opus duration: use opusplayer + if _is_opus_file(file_path): + opus = self._get_opus_player() + if opus is not None: + return opus.get_audio_duration(file_path, is_file=True) + + if AUDIO_PARSER_AVAILABLE: + try: + from .audio_parser import get_audio_duration + duration = get_audio_duration(file_path) + if duration > 0: + return duration + except Exception as e: + print(f"Audio parser error: {e}") + + # Fallback: estimate from file size + try: + return int(self.simple_mp3_duration_estimation(file_path)) + except: + return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Could not determine duration for {file_path}", "File may be corrupted or unsupported format") + + except Exception as e: + return (AP_DS_ERR_UNKNOWN, f"Error calculating audio duration: {str(e)}", "Check file integrity and try again") + + def simple_mp3_duration_estimation(self, filename: str) -> float: + """ + Estimate MP3 duration based on file size and common bitrates + + This provides a fallback when frame-by-frame parsing fails. + + Args: + filename: Path to the MP3 file + + Returns: + float: Estimated duration in seconds, 0 on error + """ + try: + file_size = os.path.getsize(filename) + + # Estimate audio data size (subtract possible ID3 tag) + audio_data_size = max(file_size - 2048, file_size * 0.98) + + # Estimate bitrate based on file size + if file_size < 2 * 1024 * 1024: # < 2MB + bitrate = 128 + elif file_size < 5 * 1024 * 1024: # < 5MB + bitrate = 192 + elif file_size < 10 * 1024 * 1024: # < 10MB + bitrate = 256 + else: + bitrate = 320 + + # Calculate duration: (file_size_bytes * 8) / (bitrate_bps) + duration = (audio_data_size * 8) / (bitrate * 1000) + return duration + + except Exception as e: + print(f"MP3 duration estimation error: {e}") + return 0 + + def get_audio_metadata_by_aid(self, aid: int) -> Union[Dict, Tuple[int, str, str]]: + """ + Obtain complete metadata of audio files based on AID + + Args: + aid: Audio ID + + Returns: + Dict: Dictionary containing complete audio metadata on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + Contains fields: path, format, duration, length, sample_rate, channels, bitrate + """ + try: + if aid not in self._aid_to_filepath: + return (AP_DS_ERR_INVALID_AID, f"Invalid AID: {aid}", "Check that the AID is valid") + + file_path = self._aid_to_filepath[aid] + + if AUDIO_PARSER_AVAILABLE: + from .audio_parser import get_audio_metadata + result = get_audio_metadata(file_path) + if result is None: + return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}", "File may be corrupted or unsupported") + return result + else: + return (AP_DS_ERR_METADATA_PARSE_FAILED, "audio_parser not available", "Install audio_parser module") + + except Exception as e: + return (AP_DS_ERR_UNKNOWN, f"Error getting audio metadata by AID: {str(e)}", "Check file integrity and try again") + + def get_audio_metadata_by_path(self, file_path: str) -> Union[Dict, Tuple[int, str, str]]: + """ + Directly obtain the complete metadata of an audio file based on its file path + + Args: + file_path: Audio file path + + Returns: + dict: Dictionary containing complete audio metadata on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + Fields included: path, format, duration, length, sample_rate, channels, bitrate + + """ + try: + 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") + + # Opus metadata: use opusplayer + if _is_opus_file(file_path): + opus = self._get_opus_player() + if opus is not None: + return opus.get_audio_metadata_by_path(file_path) + + if AUDIO_PARSER_AVAILABLE: + from .audio_parser import get_audio_metadata + result = get_audio_metadata(file_path) + if result is None: + return (AP_DS_ERR_METADATA_PARSE_FAILED, f"Failed to parse metadata for: {file_path}", "File may be corrupted or unsupported") + return result + else: + return (AP_DS_ERR_METADATA_PARSE_FAILED, "audio_parser not available", "Install audio_parser module") + + except Exception as e: + return (AP_DS_ERR_UNKNOWN, f"Error getting audio metadata by path: {str(e)}", "Check file integrity and try again") + + + def get_audio_metadata(self, source: Union[str, int], is_file: bool = False) -> Union[Dict, Tuple[int, str, str]]: + """ + Retrieve audio metadata based on AID (Audio Identifier) or audio file path. + + Args: + source: AID or audio file path. + is_file: If True, treats source as file path; if False, as AID + + Returns: + Dict: Dictionary containing complete audio metadata on success + Tuple[int, str, str]: (error_code, error_msg, suggestion) on failure + Contains fields: path, format, duration, length, sample_rate, channels, bitrate + """ + if is_file or isinstance(source, str): + return self.get_audio_metadata_by_path(str(source)) + elif isinstance(source, int): + return self.get_audio_metadata_by_aid(source) + else: + return (AP_DS_ERR_INVALID_SOURCE, f"Invalid source type: {type(source)}. Expected str or int.", "Use file path (str) or AID (int)") + + def _get_sample_rate(self, source: Union[str, int]) -> int: + """Get the actual sample rate from audio metadata. + + Args: + source: Audio source (file path string or AID integer) + + Returns: + int: Sample rate in Hz. Returns 44100 as fallback if metadata not available. + """ + try: + metadata = self.get_audio_metadata(source, is_file=isinstance(source, str)) + if isinstance(metadata, dict) and 'sample_rate' in metadata: + return metadata['sample_rate'] + except Exception: + pass + + return 44100 # Fallback default value + + def _get_channels(self, source: Union[str, int]) -> int: + """Get the actual channel count from audio metadata. + + Args: + source: Audio source (file path string or AID integer) + + Returns: + int: Number of audio channels. Returns 2 as fallback if metadata not available. + """ + try: + metadata = self.get_audio_metadata(source, is_file=isinstance(source, str)) + if isinstance(metadata, dict) and 'channels' in metadata: + return metadata['channels'] + except Exception: + pass + + return 2 # Fallback default value + + def _get_aid_for_audio(self, file_path: str) -> Union[int, Tuple[int, str, str]]: + """Find corresponding AID by file path""" + for channel, info in self._channel_info.items(): + if info.get('file_path') == file_path and not info.get('is_music', True): + return info.get('aid') + return (AP_DS_ERR_INVALID_AID, f"No AID found for file: {file_path}", "File may not be loaded or is a music file") + + def _get_aid_for_music(self, file_path: str) -> Union[int, Tuple[int, str, str]]: + """Find corresponding AID by music file path""" + for channel, info in self._channel_info.items(): + if info.get('file_path') == file_path and info.get('is_music', False): + return info.get('aid') + return (AP_DS_ERR_INVALID_AID, f"No AID found for music file: {file_path}", "File may not be loaded or is not a music file") + + def _get_playing_duration(self, aid: int) -> float: + """Get total duration of playing audio""" + file_path = self._get_file_path_by_aid(aid) + return self._get_file_duration(file_path) if file_path else 0.0 + + def _get_file_duration(self, file_path: str) -> float: + result = self._get_duration_by_filepath(file_path) + if isinstance(result, tuple): + return 0.0 + return float(result) + + # Resource management ====================================================== + + def clear_memory_cache(self) -> None: + """Clear memory cache""" + for audio in self._audio_cache.values(): + if audio: + Mix_FreeChunk(audio) + for music in self._music_cache.values(): + if music: + Mix_FreeMusic(music) + self._audio_cache.clear() + self._music_cache.clear() + + def cleanup_function(self): + """Clean up resources""" + self.clear_memory_cache() + Mix_CloseAudio() + SDL_Quit() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b244968 --- /dev/null +++ b/setup.py @@ -0,0 +1,70 @@ +from setuptools import setup, find_packages +import os + +# 读取README.md +def read_file(filename): + try: + with open(filename, 'r', encoding='utf-8') as f: + return f.read() + except: + return "Audio Player By DVS - Advanced audio processing and playback library" + +# 定义版本常量 +VERSION = "4.1.0rc1" + +# 自动生成或更新 _version.py +def write_version_file(): + version_file_path = os.path.join("ap_ds", "_version.py") + os.makedirs(os.path.dirname(version_file_path), exist_ok=True) + with open(version_file_path, "w", encoding="utf-8") as f: + f.write(f'__version__ = "{VERSION}"\n') + +# 调用函数生成版本文件 +write_version_file() + +# 获取README内容 +long_description = read_file("README.md") + +setup( + name="ap_ds", + version=VERSION, + description="Audio Player By DVS - Advanced audio processing and playback", + long_description=long_description, + long_description_content_type="text/markdown", + author="DVS", + author_email="me@dvsyun.top", + url="https://apds.top", + packages=find_packages(), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + # 自定义许可信息 + "License :: Other/Proprietary License", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", # 仅保留Linux,删掉Unix + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="audio music player playback sdl2 dvs", + python_requires=">=3.7", + install_requires=[], # 纯Python依赖 + include_package_data=True, + # 添加许可证信息 - 根据要求修改 + license="Custom Open Source License", + # 确保包含所有必要的文件 + package_data={ + '': ['*.md', '*.txt', '*.py'], # 包含所有markdown和文本文件 + }, + # 添加项目URLs(删除了GitHub链接) + project_urls={ + 'Documentation': 'https://apds.top', + 'License Info': 'https://apds.top', + }, +)