Initial commit: ap_ds 音频播放库 (Audio Player By DVS AFS)

This commit is contained in:
dvs
2026-08-27 19:11:29 +08:00
commit bb521483b9
16 changed files with 11045 additions and 0 deletions
+21
View File
@@ -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
+159
View File
@@ -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.**
---
+830
View File
@@ -0,0 +1,830 @@
# 🎉 ap_ds AFS 0.0.1-AFS Pre-release — The Permanent Home of Opus Support
---
## 🔧 Version 0.0.1a3 — Bug Fix Announcement
Welcome to **0.0.1a3**! This release fixes several issues found in the previous version (0.0.1a2), focused mainly on the Opus playback engine, metadata parsing and packaging.
### ✅ What's Fixed in This Release
**Opus playback engine (`opusplayer.py`)**
- **Fade worker indentation** — `_fade_worker` was accidentally defined outside the `OpusAudio` class, causing `AttributeError` on `fadeout/fadein/fadein_pos`. It is now a proper class method.
- **Fade-in now stops and restarts** — `fadein_music` previously refused to run while playing ("Already playing"). It now stops any current playback first, starts a fresh AID, then fades in.
- **Seek now truly jumps** — `seek_audio` previously only moved the stream position without resetting the buffered audio, so jumps were not audible. It now restarts the playback thread from the new position.
- **Seek resumes after fade-out** — seeking now always restarts playback, even after a fade-out had stopped it.
- **Fade-out restores volume** — the volume was being reset to 0 after fade-out, making any later play/seek/fade-in silent. The normal volume is now restored after fade-out.
- **Boundary robustness** — `fadein_music(ms=None)`, `fadeout_music(None)` and `fadein_music_pos(position=None)` no longer crash with a TypeError; invalid values fall back safely to defaults or return an error tuple.
**Opus metadata parsing (`audio_parser.py`)**
- `open_audio(.opus)` no longer raises `ValueError`. A new `OPUSFile` parser delegates to `opusplayer`, so `get_audio_duration`, `get_audio_metadata` and `batch_get_metadata` all support Opus files.
- Directory scanning for `batch_get_metadata` now includes `.opus`.
**Audio library routing (`player.py`)**
- `get_audio_metadata_by_aid()` now routes Opus AIDs to the `OpusAudio` engine, returning consistent metadata.
**Packaging**
- Restored the main `ap_ds/__init__.py` (its exported functions had been accidentally replaced, which omitted all top-level APIs from the built package). All public APIs are exported again.
**Tests**
- CI/CD fade duration increased to 5s for a clearer audible effect.
- Added `test_opus_interactive.py` for manual Opus playback / seek / fade verification.
### 🙏 Keep Reporting Bugs
We truly value your feedback. If you encounter any issue while using the library, please don't hesitate to submit a bug report. Together we'll keep polishing the product:
📧 **Where to send (please CC both addresses to guarantee delivery):**
- **me@dvsyun.top** (primary)
- **dvs6666@163.com** (backup)
⏱️ **Our promise:** we will fix reported issues within **3 business days**.
To help us resolve issues faster, please include the following when submitting a bug report:
- Operating system and version
- Python version (`python --version`)
- The full error message
- Reproduction steps
- A sample audio file (if possible)
Every single piece of feedback drives us forward. 🎉
⚠️ **Important:** 0.0.1-AFS is a **pre-release test version** for feedback collection and bug reporting. The first stable release **1.0.0** is planned for **September 2026**.
This is the first release of the **AFS (All Format Support)** branch — a standalone fork of ap_ds ecosystem built specifically for **Opus** and all future new formats, with **permanent support commitment**.
If you encounter any issues, please contact us immediately:
📧 **Primary:** me@dvsyun.top
📧 **Backup:** dvs6666@163.com
⏱️ **We guarantee:** Fix within 3 business days
Your feedback is crucial! Together, let's polish Opus support to perfection.
> "We don't just support a new format — we open the door to higher quality, smaller size, and greater freedom." — DVS Development Team, August 2026
---
## 📦 Installation
### Install the Package
```bash
# AFS pre-release (includes Opus, for testing feedback)
pip install ap-ds-afs==0.0.1a3
# AFS stable release (coming September 2026)
pip install ap-ds-afs==1.0.0
```
### Import — Same as Always!
**No matter which package you installed** (`ap-ds` or `ap-ds-afs`), the import is **exactly the same**:
```python
from ap_ds import AudioLibrary
```
✅ Zero migration cost — just change the package name in `requirements.txt`
✅ No code changes needed — your existing code works as-is
### 🛡️ Foolproof Design — Conflict Detection
If a user accidentally installs **both** `ap-ds` and `ap-ds-afs` simultaneously, the library detects the conflict and **exits immediately** with a clear message:
```python
>>> 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 a **foolproof design** to prevent hard-to-debug import issues caused by conflicting packages.
---
## 🚀 Quick Start Guide
### Basic Audio Playback
```python
from ap_ds import AudioLibrary
# Initialize the library
lib = AudioLibrary()
# Play an 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.5s
lib.set_volume(aid, 80) # Set volume (0-128)
# Stop and get elapsed time
elapsed = lib.stop_audio(aid)
print(f"Played {elapsed:.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 Audio 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 (Parallel Processing)
```python
from ap_ds import batch_get_metadata
# Parse 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")
```
**⚠️ Windows Users:** You MUST protect your entry point with `if __name__ == "__main__"`:
```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()
```
### DAP Playlist System
```python
# Files are recorded automatically into DAP (DVS Audio Playlist)
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
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.
---
## 📢 A Letter to All Audio Developers
Friends, colleagues, music lovers, and everyone who has ever stayed up late wrestling with audio formats:
Today, we announce — **ap_ds AFS 0.0.1-AFS** is officially released! The **AFS (All Format Support)** branch makes its debut, providing **permanent support** for the Opus audio format for the first time!
But before excitement takes over, we must be honest with you:
**This is a pre-release 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
⚠️ Some edge-case bugs may still exist that we haven't found
⚠️ We need real users to test in diverse environments
So we're giving this version to **you** — our users — to help us test.
> **"AFS is the permanent home of Opus and all future new formats."**
This is part of ap_ds's dual-track strategy. We'll explain in detail in the following sections.
---
## 🚨 Important Notes on Version 0.0.1-AFS Positioning
### 1. This is a Pre-release
0.0.1-AFS is **not** an LTS or final stable version. It is a **pre-release** aimed at:
- Collecting real-world usage feedback
- Discovering edge-case bugs not covered by tests
- Verifying cross-platform compatibility
- Gathering data for the 1.0.0 stable release
### 2. AFS Branch — The Permanent Home of Opus
This is a critical statement:
**The AFS branch is the permanent home for Opus and all future new formats.**
Unlike the mainline (`ap-ds` 4.x), the AFS branch will **permanently retain** Opus support and continuously add new formats.
| Version | Opus Support | Description |
|---------|-------------|-------------|
| 0.0.1-AFS | ✅ Yes | First AFS pre-release |
| 1.0.0 (Sep 2026) | ✅ Yes | First AFS stable release |
| Future AFS | ✅ Yes | Permanent support |
| Mainline 4.2.0+ | ❌ No | Returns to lightweight positioning |
**Why?**
Because ap_ds mainline's core promise is **2.5MB lightweight**. Opus support (including DLLs) pushes the size to ~3.87MB. Mainline will remove Opus to return to 2.5MB. The AFS branch is specifically designed to carry Opus and future formats, at ~2.8-3.5MB.
So if you need Opus support:
- **Short-term testing:** Use 0.0.1-AFS pre-release
- **Long-term use:** Use AFS branch (`pip install ap-ds-afs`), with 1.0.0 stable coming in September
- Both packages use **identical imports** (`from ap_ds import AudioLibrary`) — zero migration cost
### 3. Bug Reporting & Fix Commitment
If you find any issues with 0.0.1-AFS:
📧 me@dvsyun.top
📧 dvs6666@163.com (CC both for delivery guarantee)
**We promise:**
- Fix within **3 business days**
- Patch releases will be published promptly
**When reporting, please provide:**
- OS and version
- Python version (`python --version`)
- Full error message (if any)
- Reproduction steps
- Audio file sample (if possible)
Every piece of feedback helps us build a better ap_ds. 🙏
---
## 🤔 Why Opus? — The Technical Imperative
### 1. Quality & Compression Ceiling
Opus is an open audio codec jointly developed by the Xiph.Org Foundation and IETF, combining SILK (speech) and CELT (general audio) algorithms with adaptive bitrate 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 matches or exceeds MP3 at 320kbps
- Ultra-low latency (as low as 5ms), ideal for real-time communication and gaming
### 2. Open Source & Freedom — A Philosophical Fit
Opus uses a BSD-like license with **no patent restrictions**, completely free. This aligns perfectly with ap_ds's commitment to openness, freedom, and zero burden.
### 3. Mature Ecosystem & Clear Demand
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** (broadcasting, podcasting)
- **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 0.0.1-AFS pre-release, we validate cross-platform Opus playback solutions and accumulate experience for the 1.0.0 stable release.
---
## 😅 Why Didn't We Support Opus Before? — The Upstream Dependency Story
This is a great question. Honestly, we've wanted to support Opus since ap_ds v1.0. But the reality is:
ap_ds has always relied on **SDL2 and SDL2_mixer** for audio playback.
SDL2 is an excellent cross-platform multimedia library that handles audio device abstraction, mixing, and buffering across Windows, macOS, and Linux. SDL2_mixer supports MP3, WAV, OGG, FLAC and other formats out of the box.
But the problem is — **SDL2_mixer's Opus support was never stable enough.**
| Platform | Issue |
|----------|-------|
| Windows | Official SDL2_mixer Windows binaries often lack Opus support or have incomplete compile flags, causing `Mix_LoadMUS` to return NULL for `.opus` files |
| macOS | SDL2_mixer Framework builds frequently have version mismatches and `libopusfile`/`libogg` dependency issues, causing runtime crashes |
| Linux | SDL2_mixer depends on system-installed `libopusfile-dev`, but package names and versions vary widely across distributions |
| API Inconsistency | Even when Opus loads, SDL2_mixer's metadata extraction (duration, bitrate, etc.) often returns 0 or errors |
We tried multiple approaches:
- Compiling our own Opus-enabled SDL2_mixer binaries → Bloated, high maintenance cost
- Forcing users to install `libopusfile-dev` on Linux → Poor UX, and Windows/macOS issues remained
- Waiting for upstream SDL2_mixer fixes across multiple versions → Issues persisted
**Conclusion:** SDL2_mixer's upstream support was insufficient to provide stable Opus playback.
Therefore, before AFS, we made a difficult decision — **temporarily not support Opus** to avoid giving users an unstable experience.
### How Does 0.0.1-AFS Solve This?
**Because SDL2 doesn't work, we decided to not use SDL2 at all!**
In the AFS branch, 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**, free from SDL2_mixer's limitations.
Meanwhile, Windows users don't need to hunt for DLLs — ap_ds automatically downloads the required four DLLs (`libopusfile-0.dll`, `libopus-0.dll`, `libogg-0.dll`, `libopusurl-0.dll`) on first run with **SHA256 hash verification** ensuring file integrity.
This is why AFS can support Opus, and why we couldn't before. It's not that we didn't want to — we had to find a reliable, stable, truly cross-platform solution. Now we have one.
---
## 🧬 Technical Deep Dive — What We Actually Did
### 1. New Modules & Architecture
To integrate Opus support without affecting mainline stability, we carefully designed the project structure:
| File | Responsibility | Description |
|------|---------------|-------------|
| `opusplayer.py` | Opus playback core | `OpusAudio` class — fully independent of SDL2, encapsulates all Opus playback, control, metadata and batch APIs |
| `_opusdll.py` | Opus library loader | Cross-platform loading (Windows auto-download, Linux/macOS system detection + install guide), unified libopusfile client |
| `player.py` (modified) | Opus routing integration | `AudioLibrary` auto-detects `.opus` files and forwards to `OpusAudio` sub-player, fully transparent playback |
| `__init__.py` (modified) | Export OpusAudio | Users can use `from ap_ds import OpusAudio` directly or seamlessly through `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 maps the main library AID to the sub-library AID via a dictionary. All subsequent controls (pause, resume, volume, seek) route correctly to the Opus engine, completely transparent to user code.
```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
```
**Fully transparent, zero learning curve.**
### 2. Cross-Platform Playback Backends — Three Platform Engines Rewritten for Opus
Opus playback cannot rely on SDL2, so we decided to completely bypass SDL2 and use native OS audio APIs directly with `libopusfile` decoding.
| Platform | Playback Solution | Tech Stack |
|----------|------------------|------------|
| **Windows** | winmm waveOut | libopusfile decode → waveOutWrite multi-buffer (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 → AudioQueue callback fill (consistent with official examples) |
**Platform Implementation Details:**
**Windows** (`_play_worker_windows`):
- Uses `waveOutOpen` to open default audio device
- Uses `CreateEventW` + `WaitForSingleObject` for buffer completion synchronization
- 4 buffers rotating to eliminate stuttering
- `WAVEHDR` structure with `c_void_p` for 64-bit compatibility
**Linux** (`_play_worker_linux`):
- Uses `snd_pcm_open` with "default" device
- Uses `snd_pcm_set_params` for PCM parameters (S16_LE, interleaved mode)
- Uses `snd_pcm_writei` for PCM data write
- On `-EPIPE` (buffer underrun), calls `snd_pcm_recover` for auto-recovery
**macOS** (`_play_worker_macos`):
- Uses `AudioQueueNewOutput` to create output queue
- Uses `AudioQueueAllocateBuffer` for buffer allocation
- Callback `HandleOutputBuffer` decodes Opus and fills `mAudioData`
- Uses `AudioQueueStart` to start, `AudioQueueStop` to stop
### 3. Opus-Specific Error Codes (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 DLL existence/locks |
| 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 valid Opus |
| 2004 | AP_DS_ERR_OPUS_HEADER_CORRUPT | OpusHead header corrupt | Invalid or corrupted header info |
| 2005 | AP_DS_ERR_OPUS_TAGS_PARSE_FAILED | Tag parse failed | Corrupted or invalid tag data |
| 2006 | AP_DS_ERR_OPUS_DECODE_FAILED | Opus decode failed | Corrupted audio data |
| 2007 | AP_DS_ERR_OPUS_SEEK_FAILED | Opus seek failed | Stream may not support seeking to this position |
| 2008 | AP_DS_ERR_OPUS_BITRATE_UNAVAILABLE | Bitrate unavailable | Cannot determine bitrate for this Opus stream |
| 2009 | AP_DS_ERR_OPUS_NOT_SEEKABLE | Stream not seekable | This Opus stream doesn't support seeking |
| 2010 | AP_DS_ERR_OPUS_CHANNEL_INVALID | Invalid channel count | Invalid channel count in Opus stream |
All Opus errors include:
- Machine-readable error code (for programmatic handling)
- Human-readable error message (for developer understanding)
- Actionable suggestion (for user problem resolution)
### 4. Auto DLL Download & Hash Verification (Windows)
Windows users don't need to manually find DLLs. When ap_ds first detects Opus support is needed, it:
1. Checks if the 4 required DLLs exist in the package directory
2. Downloads from `https://dvsyun.top/ap_ds/download/` if missing or hash verification fails
3. Performs SHA256 hash verification after download
4. Auto-retries if hash mismatch
| File | Size | SHA256 |
|------|------|--------|
| libopusfile-0.dll | 55,884 B | fc8ff75c5e0180e73b0528dc78c51ed0fb493741375cdc227f50c2a33cabf727 |
| libopus-0.dll | 500,112 B | 90aa25a0a6525d7da48a7ae8dd3306e45b0c28ce09a73d2a02b56cd95418d5be |
| libogg-0.dll | 40,580 B | 3038ce8d161324a6349bf7c83b78493857ff6a3501e3adb3d541c6a07bd94a57 |
| libopusurl-0.dll | 76,772 B | a6cde968a23f2d0067332a13718c52e265653a2c35d65862e8dff4cf2a0346d9 |
### 5. Cross-Platform Opus Library Loading
**Windows:**
- Check package directory for DLLs → Auto-download if missing → SHA256 verify → Load via `ctypes.CDLL`
**Linux:**
- User config check (`~/.config/ap_ds/opus_paths.conf`) → System library check (`ctypes.util.find_library("opusfile")`) → Auto-install (apt-get/dnf/pacman, interactive sudo) → Interactive setup
**macOS:**
- System library detection (find_library + common Homebrew/MacPorts paths) → MacPorts auto-install (`sudo port install opus opusfile libogg`) → Homebrew auto-install (`brew install opus opusfile libogg`) → Manual installation guide
### 6. OpusAudio Class — Complete API
The `OpusAudio` class provides an API nearly identical to `AudioLibrary`, but fully 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 during 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()` | Check if playing |
| `is_music_paused()` | Check if 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 duration |
| `cleanup_function()` | Release all resources |
### 7. Library Size Changes
Due to the addition of `opusplayer.py` (~45KB) and `_opusdll.py` (~25KB) plus Windows DLLs (~673KB total), this 0.0.1-AFS pre-release temporarily expands to ~3.87 MB.
| Version | Opus Support | Size | Description |
|---------|-------------|------|-------------|
| 4.0.x | ❌ | ~2.5MB | Stable, no Opus |
| **AFS 0.0.1-AFS** | ✅ | **~3.87MB** | **AFS pre-release with Opus** |
| 4.2.0+ (mainline) | ❌ | ~2.5MB | Mainline returns to lightweight |
| AFS 1.0.0+ | ✅ | ~2.8-3.5MB | AFS permanent home for Opus |
---
## 🌿 AFS Branch — ap_ds's "Dual-Track" Future
### Why Split?
With Opus added, the mainline package size grew from 2.5MB to 3.87MB. Every new format will increase size. If we stuff all formats into mainline, ap_ds will eventually become a bloated monster, betraying the original "lightweight" vision.
**So we decided: split the family.**
**AFS (All Format Support)** is a brand new independent branch that will carry all new format support, "beyond the 2.5MB limit."
| Aspect | Mainline (ap-ds) | AFS Branch (ap-ds-afs) |
|--------|-----------------|----------------------|
| PyPI package | `ap-ds` | `ap-ds-afs` |
| Import name | `ap_ds` | `ap_ds` (**identical!**) |
| Version | 4.x (continues) | 0.0.1-AFS → 1.0.0+ |
| Opus support | ❌ (except 4.1.0) | ✅ **Permanent** |
| Core formats | MP3/WAV/FLAC/OGG/AAC | Same + Opus + all future formats |
| Size | ~2.5MB | ~2.8-3.5MB |
| Update strategy | Security fixes only | Mainline sync + own new formats |
| Target users | Minimalist developers | Developers needing special formats |
**Key Design: Same Import Name**
```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 between the two packages seamlessly by just changing the package name in `requirements.txt` or `pip install`.
### Which One Should You Choose?
| Your Need | Recommendation |
|-----------|---------------|
| Only MP3/WAV/FLAC/OGG/AAC | Mainline (`ap-ds` 4.2.0+) — lightweight, stable |
| Need Opus, willing to test pre-release | AFS (`ap-ds-afs` 0.0.1-AFS) — early adopter, feedback |
| Need Opus, want long-term stability | AFS (`ap-ds-afs` 1.0.0, September 2026) — full-featured, LTS |
| Not sure about future format needs | Install mainline, switch to AFS later (same import!) |
---
## ⚠️ Windows Multiprocessing Warning
If you're using ap_ds AFS 0.0.1-AFS 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 re-imports your main module.
If you call `batch_get_metadata()` or any batch function that uses `ProcessPoolExecutor` directly at the top level of your script, child processes will execute these calls again when re-importing, causing infinite recursion and eventually a `BrokenProcessPool` error.
**Your program will crash. Directly.**
**Affected APIs:**
- `batch_get_metadata()`
- `batch_get_duration()`
- `batch_get_metadata_by_type()`
- Opus batch parsing is also affected
**How to fix?**
Simply wrap your batch parsing code inside `if __name__ == "__main__":`.
**❌ Wrong (Will crash 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()
```
**✅ Correct (with config function):**
```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.
---
## 📊 Version Comparison Overview
| Aspect | Mainline 4.0.x | AFS 0.0.1-AFS | Mainline 4.2.0+ (planned) | AFS 1.0.0 (planned) |
|--------|---------------|---------------|--------------------------|---------------------|
| Opus support | ❌ | ✅ | ❌ | ✅ |
| Playback formats | MP3/WAV/FLAC/OGG/AAC | +Opus | MP3/WAV/FLAC/OGG/AAC | +Opus + future formats |
| 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 DLLs | SDL2 | SDL2 + Opus DLLs |
| AFS branch | ❌ | ✅ (AFS itself) | ❌ | ✅ (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 | **Pre-release** | Planned | Planned (Sep 2026) |
### Version Relationship
| Version | Type | Support Period | Use Case |
|---------|------|---------------|----------|
| v3.0.0 LTS | LTS | Until Mar 2031 | Production |
| v4.0.x | LFV | ~6 months | Early adopters |
| **AFS 0.0.1-AFS** | **Pre-release** | **~1 month** | **Opus testing & feedback** |
| AFS 1.0.0+ (planned) | Stable | TBD | Opus permanent home (Sep 2026) |
| Mainline 4.2.0+ | LFV | ~6 months | Returns to lightweight |
### Upgrade Recommendations
| User Type | Recommendation |
|-----------|---------------|
| Production | Continue with v3.0.0 LTS, or wait for AFS 1.0.0 |
| Dev/Test | Try AFS 0.0.1-AFS with Opus, help test & feedback |
| Need Opus, willing to test | Use AFS 0.0.1-AFS, report bugs |
| Need Opus, want long-term stability | Wait for AFS 1.0.0 (September 2026) |
| No Opus needed, want lightweight | Wait for mainline 4.2.0+, or continue with 4.0.x |
| Affected by v3.1.x metadata bug | Must upgrade to v4.0.0+ |
---
## 🧪 CI/CD Test Results — Comprehensive Coverage, All Passed
### Opus Test Suite (OPUS_TEST.py)
AFS 0.0.1-AFS includes a complete Opus test suite covering 16 test categories:
| Category | Test Items | Description |
|----------|-----------|-------------|
| OP-1 | Module imports/constants/error codes | Verify all Opus modules, constants, error codes |
| OP-2 | DLL loading & auto-download | Verify `_opusdll.py` loading, DLL existence, hash verification |
| OP-3 | Metadata parsing | Verify Opus duration, sample rate, channels, bitrate, extended tags |
| OP-4 | Playback functions | 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 | Seek functionality | Verify `seek_audio` with 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 | Confirm 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` releases resources correctly |
| OP-14 | Boundary & error tests | Verify invalid parameter types, out-of-range values |
| OP-15 | DLL-specific tests | Verify DLL file sizes, hashes, load idempotency |
| OP-16 | Error code trigger specific tests | Verify each Opus error code trigger in real scenarios |
**Test Results:**
```
==================================================================
Opus Test Summary
==================================================================
Passed : 229
Failed : 0
Skipped: 0
==================================================================
```
**All 229 tests passed, 0 failed, 0 skipped.**
### Comprehensive CICD Test Suite (CI,CD_TEST.py)
```
==================================================================
CICD Test Summary
==================================================================
Passed : 650+
Failed : 0
Skipped: 0
==================================================================
```
### API Import Verification Test (IMPORT_TEST.py)
```
============================================================
📊 FINAL SUMMARY
============================================================
🎉 ALL APIs EXIST! Documentation is accurate.
============================================================
✅ Passed: 47
❌ Failed: 0
```
**All 47 API checks passed!**
---
## 📖 Technical Manual Update
`show_tech_manual()` has been updated for AFS 0.0.1-AFS with:
- **Section 2.1 OPUS SUPPORT** (New chapter) — Opus format introduction, playback backend architecture, auto DLL download, OpusAudio class API reference, AID 1:1 mapping mechanism
- **Section 10.4 Opus Error Codes** (New section) — Complete 10 Opus error codes list (2001-2010), meanings and suggested actions
- **Version History** — AFS 0.0.1-AFS entry, Opus support, cross-platform playback backend, AFS branch establishment
---
## 🌐 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), GitHub (compatibility mirror)
- ✉️ Feedback system: Users can submit feedback directly through the website
- 🔒 Full-site TLS encryption
---
## 📦 Repository Strategy
| Platform | Status | Purpose |
|----------|--------|---------|
| apds.top | ✅ Permanent home | Official source code, docs, downloads |
| GitCode | ✅ Primary mirror | Global code hosting |
| GitHub | ✅ Compatibility mirror | For GitHub developers (new!) |
| Gitee | ✅ China mirror | Fast access for Chinese users |
| GitLab (JiHu) | ❌ Deprecated | No longer maintained |
---
## ℹ️ Overview
**ap_ds AFS** is a lightweight (~3.87MB) Python audio library for playback and high-precision metadata parsing of MP3, FLAC, OGG, WAV, and **Opus** files. Zero external Python dependencies — only uses the Python standard library with non-blocking playback suitable for GUI applications.
**Core Features:**
- 🎵 **Native Opus support** — Dedicated playback pipeline independent of SDL2, cross-platform native audio output
- 📦 **Zero Python dependencies** — Standard library only
- 🎯 **High-precision metadata** — WAV/FLAC 100%, OGG 99.99%, MP3 >98%, Opus 100%
- ⚡ **Batch parsing** — Parallel processing of hundreds of files using `batch_get_metadata()`
- 🖥️ **Non-blocking playback** — Ideal for GUI applications
- 🌍 **Cross-platform** — Windows, macOS, Linux, embedded ARM64
- 📝 **DAP recording system** — Automatic playback history, metadata only
- 🧵 **Python 3.15t support** — No-GIL true parallelism, full multi-core performance
- 🏠 **AFS permanent support** — Permanent home for Opus and all future formats
---
## 📧 Contact & Support
📧 **License inquiries:** me@dvsyun.top or dvs6666@163.com — 7 business days response
🛠️ **Technical support:** apds.top · GitCode Issues · GitHub Issues · Gitee Issues · Email (completely free)
**Author:** DVS (DvsXT)
**Personal homepage & blog:** https://dvsx.top (under maintenance)
**Author profile:** https://dvsyun.top/me/dvs
**Email:** me@dvsyun.top · dvs6666@163.com
**ap_ds Official Portal:**
- 🎵 **Official website:** https://apds.top — Permanent official homepage, full documentation, releases, license center
- 📦 **PyPI:** https://pypi.org/project/ap_ds/
- 🌐 **Mirror docs:** https://www.dvsyun.top/ap_ds
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+173
View File
@@ -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}")
+893
View File
@@ -0,0 +1,893 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
============================================================================
AP_DS 0.0.1-AFS - 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('<h', 0) for _ in range(n)))
return path
_make_wav(WAV_FILE)
# Corrupted Opus file
BAD_OPUS = os.path.join(TMP_DIR, 'bad.opus')
with open(BAD_OPUS, 'wb') as f:
f.write(b'\x00\x01\x02\x03' * 100) # not a valid Opus stream
# ============================================================================
# Test framework
# ============================================================================
class OpusTest:
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(" Opus 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)
# ============================================================================
# [OP-1] Opus Module Imports / Constants / Error Codes
# ============================================================================
def test_opus_imports(t):
t.section("[OP-1] Opus Module Imports / Constants / Error Codes")
# Module imports
t.check("opusplayer importable", callable(opusplayer.OpusAudio))
t.check("_opusdll importable", opusdll is not None)
t.check("OpusAudio class exists", hasattr(opusplayer, 'OpusAudio'))
t.check("check_opus_dll callable", callable(opusplayer.check_opus_dll))
t.check("import_opus callable", callable(opusdll.import_opus))
t.check("download_opus_libraries callable", callable(opusdll.download_opus_libraries))
# Constants
t.check("MUS_NO_FADING=0", opusplayer.MUS_NO_FADING == 0)
t.check("MUS_FADING_IN=1", opusplayer.MUS_FADING_IN == 1)
t.check("MUS_FADING_OUT=2", opusplayer.MUS_FADING_OUT == 2)
t.check("WAVE_FORMAT_PCM=1", opusplayer.WAVE_FORMAT_PCM == 1)
t.check("CALLBACK_EVENT set", opusplayer.CALLBACK_EVENT == 0x00050000)
# Structures
t.check("OpusHead structure", hasattr(opusdll, 'OpusHead'))
t.check("OpusTags structure", hasattr(opusdll, 'OpusTags'))
t.check("WAVEFORMATEX structure", hasattr(opusdll, 'WAVEFORMATEX'))
t.check("WAVEHDR structure", hasattr(opusdll, 'WAVEHDR'))
# DLL file presence
pkg = os.path.dirname(os.path.abspath(opusdll.__file__))
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
t.check(f"{dll} exists", os.path.exists(os.path.join(pkg, dll)))
# DLL hash table
t.check("OPUS_DLL_HASHES defined", hasattr(opusdll, 'OPUS_DLL_HASHES'))
if hasattr(opusdll, 'OPUS_DLL_HASHES'):
for dll in ('libopusfile-0.dll', 'libopus-0.dll', 'libogg-0.dll', 'libopusurl-0.dll'):
t.check(f"hash for {dll}", dll in opusdll.OPUS_DLL_HASHES)
# ============================================================================
# [OP-2] _opusdll.py DLL Loading & Auto-Download
# ============================================================================
def test_opus_dll(t):
t.section("[OP-2] _opusdll.py DLL Loading & Auto-Download")
ok, msg = opusplayer.check_opus_dll()
t.check("check_opus_dll returns (bool,str)", isinstance(ok, bool) and isinstance(msg, str), f"got={ok},{msg}")
t.check("check_opus_dll ok", ok is True, f"msg={msg}")
r = opusdll.import_opus()
t.check("import_opus returns bool", isinstance(r, bool), f"got={r}")
t.check("import_opus ok", r is True)
t.check("opusfile handle loaded", opusdll.opusfile is not None)
t.check("OPUS_DLL_FILES has 4", len(opusdll.OPUS_DLL_FILES) == 4, f"got={len(opusdll.OPUS_DLL_FILES)}")
if hasattr(opusdll, 'OPUS_DLL_FILES'):
for dll in opusdll.OPUS_DLL_FILES:
t.check(f"DLL config {dll['filename']}", 'url' in dll and 'filename' in dll)
# ============================================================================
# [OP-3] Opus Metadata Parsing
# ============================================================================
def test_opus_metadata(t):
t.section("[OP-3] Opus Metadata Parsing (basic + extended)")
if not os.path.exists(OPUS_FILE):
t.skip("Opus metadata", "no Opus file available")
return
lib = opusplayer.OpusAudio()
meta = lib.get_audio_metadata_by_path(OPUS_FILE)
t.check("get_audio_metadata_by_path is dict", isinstance(meta, dict), f"got={type(meta).__name__}")
if isinstance(meta, dict):
t.check("format=opus", meta.get('format') == 'opus', f"got={meta.get('format')}")
t.check("duration>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())
+1
View File
@@ -0,0 +1 @@
# CI/CD.
+772
View File
@@ -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 0.0.1a3 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 0.0.1a3 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 0.0.1a3) │
└───────────────────────────────────────────────────────────────────────────────┘
AP_DS 0.0.1a3 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 0.0.1a3 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 0.0.1a3) │
└───────────────────────────────────────────────────────────────────────────────┘
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 0.0.1a3 (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 0.0.1a1
─────────────
• 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 0.0.1a3 - 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==0.0.1a3")
print(" pip install ap-ds==0.0.1a3 -i https://pypi.tuna.tsinghua.edu.cn/simple")
print(" pip install /path/to/ap-ds-0.0.1a3-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",
]
+946
View File
@@ -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()
+1053
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
__version__ = "0.0.1a3"
+693
View File
@@ -0,0 +1,693 @@
"""
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"<StreamInfo length={self.length:.6f}s "
f"rate={self.sample_rate}Hz "
f"channels={self.channels} "
f"bitrate={self.bitrate}bps>"
)
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", f.read(4))[0]
def read_u16_le(f):
"""Read a little-endian 16-bit unsigned integer from a file."""
return struct.unpack("<H", f.read(2))[0]
# ============================================================
# WAV Parser (100% accuracy)
# ============================================================
class WAVFile(FileType):
"""
WAV audio parser using RIFF chunk structure.
Extracts format information from the 'fmt ' chunk and data size from
the 'data' chunk. Computes duration from total frames and sample rate.
Accuracy: 100% (based on file structure, no heuristics).
"""
def _parse(self):
with open_file(self.filename) as f:
if f.read(4) != b"RIFF":
raise ValueError
f.read(4)
if f.read(4) != b"WAVE":
raise ValueError
sample_rate = channels = block_align = data_size = None
while True:
chunk = f.read(4)
if not chunk:
break
size = read_u32_le(f)
if chunk == b"fmt ":
fmt = f.read(size)
channels = struct.unpack("<H", fmt[2:4])[0]
sample_rate = struct.unpack("<I", fmt[4:8])[0]
block_align = struct.unpack("<H", fmt[12:14])[0]
elif chunk == b"data":
data_size = size
break
else:
f.seek(size, io.SEEK_CUR)
total_frames = data_size // block_align
length = total_frames / sample_rate
bitrate = sample_rate * block_align * 8 // channels
return StreamInfo(length, sample_rate, channels, bitrate)
# ============================================================
# FLAC Parser (100% accuracy)
# ============================================================
class FLACFile(FileType):
"""
FLAC audio parser reading the STREAMINFO metadata block.
The STREAMINFO block is mandatory in all FLAC files and contains
sample rate, channel count, and total samples. Accuracy: 100%.
"""
def _parse(self):
with open_file(self.filename) as f:
if f.read(4) != b"fLaC":
raise ValueError
while True:
header = f.read(4)
is_last = header[0] & 0x80
block_type = header[0] & 0x7F
size = 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("<Q", header[6:14])[0]
last_granule = max(last_granule, granule)
seg_count = header[26]
seg_sizes = f.read(seg_count)
f.seek(sum(seg_sizes), 1)
if sample_rate is None:
pos = f.tell()
f.seek(-sum(seg_sizes), 1)
packet = f.read(seg_sizes[0])
if packet.startswith(b"\x01vorbis"):
channels = packet[11]
sample_rate = struct.unpack("<I", packet[12:16])[0]
f.seek(pos, 0)
length = last_granule / sample_rate
bitrate = filesize * 8 / length
return StreamInfo(length, sample_rate, channels, bitrate)
# ============================================================
# OPUS Parser (delegates to opusplayer.py)
# ============================================================
class OPUSFile(FileType):
"""
OPUS audio parser that delegates to opusplayer.py (OpusAudio engine).
Opus files are handled by the dedicated Opus engine (libopusfile-based)
rather than a pure-Python parser. This class wraps the opusplayer
metadata results into the standard StreamInfo interface so that the
rest of the audio_parser API (open_audio / get_audio_metadata /
get_audio_duration / batch_*) works uniformly for Opus files.
Accuracy: 100%% (derived from libopusfile stream metadata).
"""
def _parse(self):
# Lazily import opusplayer to avoid forcing Opus DLL loading at
# module import time (keeps pure-Python formats dependency-free).
try:
from . import opusplayer
except ImportError:
try:
import opusplayer
except ImportError:
raise ValueError("opusplayer not available for Opus parsing")
meta = opusplayer._get_opus_metadata(self.filename)
if not isinstance(meta, dict):
raise ValueError("Invalid or unsupported Opus file")
length = float(meta.get("length", meta.get("duration", 0.0)) or 0.0)
sample_rate = int(meta.get("sample_rate", 48000) or 48000)
channels = int(meta.get("channels", 2) or 2)
bitrate = int(meta.get("bitrate", 0) or 0)
if length <= 0:
raise ValueError("Invalid Opus duration")
return StreamInfo(length, sample_rate, channels, bitrate)
# ============================================================
# Factory Function
# ============================================================
def open_audio(filename):
"""
Factory function that returns the appropriate parser instance.
Args:
filename: Path to the audio file
Returns:
FileType: Parser instance (WAVFile, FLACFile, MP3File, AACFile, OGGFile, or OPUSFile)
Raises:
ValueError: If the file format is unsupported
"""
ext = os.path.splitext(filename)[1].lower()
if ext == ".wav":
return WAVFile(filename)
if ext == ".flac":
return FLACFile(filename)
if ext == ".mp3":
return MP3File(filename)
if ext == ".aac":
return AACFile(filename)
if ext == ".ogg":
return OGGFile(filename)
if ext == ".opus":
# Opus is handled by opusplayer.py (dedicated Opus engine)
return OPUSFile(filename)
raise ValueError(f"Unsupported audio format: {ext}")
# ============================================================
# Single File Parsing
# ============================================================
def _parse_single_file(file_path: str) -> 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', '.opus'}
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
+1286
View File
File diff suppressed because it is too large Load Diff
+1364
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
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 AFS - Advanced audio processing and playback with Opus support"
# 定义版本常量
VERSION = "0.0.1a3"
# 自动生成或更新 _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-afs",
version=VERSION,
description="Audio Player By DVS AFS - Audio processing and playback with Opus native support",
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 :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: Other/Proprietary License",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"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 opus sdl2 dvs afs all-format-support",
python_requires=">=3.7",
install_requires=[],
include_package_data=True,
license="Custom Open Source License",
package_data={
'': ['*.md', '*.txt', '*.py'],
},
project_urls={
'Documentation': 'https://apds.top',
'License Info': 'https://apds.top',
'Source Code': 'https://gitcode.com/dvsxt/ap_ds',
},
)