chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# WaveTone Harness SOP
## Target Software
WaveTone 2.61 is a Windows freeware application for transcription support. It
opens audio files, analyzes spectrograms and fundamental frequency, detects keys
and chords, edits notes, and exports MIDI, text, and processed WAVE output from
GUI menu actions.
The local application layout inspected for this harness:
- `wavetone.exe`: main GUI application.
- `data/asdecoder.exe` plus BASS DLLs: bundled decoder support files.
- `wthelp/*.html`: Shift-JIS help files documenting audio analysis and export.
- No documented console help, batch mode, scripting API, or headless export
command was found.
## Backend Strategy
This harness does not reimplement WaveTone analysis. The real backend remains
`wavetone.exe`, and `cli-anything-wavetone wavetone launch` starts that binary
with an optional audio file argument.
Because WaveTone 2.61 exposes analysis settings, MIDI/text export, and WAVE
export through modal GUI flows, this harness provides an agent-facing project
manifest rather than pretending to generate native WFD data. The JSON manifest
tracks the source audio, intended analysis settings, labels, tempo metadata,
and any WFD path saved later from WaveTone itself.
## Command Groups
- `project`: create and edit the JSON manifest.
- `audio`: inspect source audio metadata before opening it in WaveTone.
- `wavetone`: check and launch the real WaveTone executable.
- `session`: keep lightweight event logs for agent workflows.
- `defaults`: show the default analysis settings used for new manifests.
## Required Dependency
WaveTone is a hard runtime dependency. Set one of:
- `WAVETONE_EXE=C:\path\to\wavetone.exe`
- `WAVETONE_HOME=C:\path\to\wavetone2.6.1`
The backend finder also checks common portable extraction locations such as
`%USERPROFILE%\Desktop\wavetone2.6.1\wavetone.exe`.
## Known Limits
- WFD is not generated by this harness because the format is not documented.
- Real analysis and export still occur in the WaveTone GUI.
- Headless export evidence is therefore limited to launch smoke coverage and
audio/project file verification until a stable automation or scripting
interface is discovered.
## Validation Notes
The tests include unit coverage for manifests and audio probing, CLI subprocess
coverage, and a real-backend smoke test that launches `wavetone.exe` with a WAV
file and terminates it after a short wait.
@@ -0,0 +1,60 @@
# cli-anything-wavetone
Agent-native CLI harness for WaveTone 2.61 on Windows.
WaveTone is a transcription support tool for audio files. It analyzes
spectrograms and fundamental frequency, supports key and chord detection, note
editing, and GUI-driven MIDI/text/WAVE export. This harness creates a structured
project manifest, probes audio files, records labels and tempo metadata, and
launches the real WaveTone executable.
## Install
```bash
cd wavetone/agent-harness
pip install -e .
```
Set the backend path if WaveTone is not on the default portable path:
```powershell
$env:WAVETONE_HOME = "C:\Users\you\Desktop\wavetone2.6.1"
# or
$env:WAVETONE_EXE = "C:\Users\you\Desktop\wavetone2.6.1\wavetone.exe"
```
## Usage
```bash
cli-anything-wavetone --json wavetone doctor
cli-anything-wavetone --json project new song.wav -o song.wt.json
cli-anything-wavetone --project song.wt.json --json audio probe
cli-anything-wavetone --project song.wt.json --json project set-tempo --bpm 128
cli-anything-wavetone --project song.wt.json --json project add-label verse --time 32.5
cli-anything-wavetone --project song.wt.json --json wavetone launch
```
Running `cli-anything-wavetone` with no subcommand opens the REPL.
## Backend Truthfulness
This harness calls the actual `wavetone.exe`. It does not reimplement
WaveTone's analysis, transcription, or export engine. WaveTone 2.61 does not
document a headless scripting interface, so analysis and export remain GUI menu
flows. The JSON project is an agent-facing control and planning layer around the
real application.
## Tests
```bash
cd wavetone/agent-harness
python -m pytest cli_anything/wavetone/tests/ -v
```
The default test run skips real WaveTone backend tests so CI and contributors
without WaveTone can still run the CLI-only suite. To opt into the real Windows
smoke, set `CLI_ANYTHING_WAVETONE_REAL_BACKEND=1` plus `WAVETONE_EXE` or
`WAVETONE_HOME` pointing at a WaveTone 2.61 extraction with `wavetone.exe` and
the bundled `data` directory. E2E subprocess tests run the in-tree
`python -m cli_anything.wavetone.wavetone_cli` module by default and do not
silently prefer an installed `cli-anything-wavetone` from `PATH`.
@@ -0,0 +1,3 @@
"""WaveTone CLI-Anything harness."""
__version__ = "1.0.0"
@@ -0,0 +1,7 @@
"""Run cli-anything-wavetone as a module."""
from .wavetone_cli import main
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Core modules for cli-anything-wavetone."""
@@ -0,0 +1,112 @@
"""Audio probing utilities for WaveTone projects."""
from __future__ import annotations
import json
import shutil
import subprocess
import wave
from pathlib import Path
from typing import Any
from .project import normalize_audio_path
def _safe_float(value: Any) -> float | None:
if value in (None, "", "N/A"):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _safe_int(value: Any) -> int | None:
if value in (None, "", "N/A"):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _probe_wav_stdlib(path: Path) -> dict[str, Any]:
with wave.open(str(path), "rb") as handle:
frames = handle.getnframes()
sample_rate = handle.getframerate()
channels = handle.getnchannels()
sample_width = handle.getsampwidth()
duration = frames / sample_rate if sample_rate else 0.0
return {
"path": str(path),
"format": "wav",
"codec": "pcm",
"duration_seconds": round(duration, 6),
"sample_rate": sample_rate,
"channels": channels,
"sample_width_bytes": sample_width,
"size_bytes": path.stat().st_size,
"probe_method": "python-wave",
}
def _probe_ffprobe(path: Path) -> dict[str, Any]:
ffprobe = shutil.which("ffprobe")
if not ffprobe:
raise RuntimeError("ffprobe not found")
result = subprocess.run(
[
ffprobe,
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,sample_rate,channels:format=duration,format_name,bit_rate,size",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
)
data = json.loads(result.stdout)
audio_stream = next(
(stream for stream in data.get("streams", []) if stream.get("codec_type") == "audio"),
{},
)
fmt = data.get("format", {})
duration = _safe_float(fmt.get("duration"))
return {
"path": str(path),
"format": fmt.get("format_name"),
"codec": audio_stream.get("codec_name"),
"duration_seconds": round(duration, 6) if duration is not None else None,
"sample_rate": _safe_int(audio_stream.get("sample_rate")),
"channels": _safe_int(audio_stream.get("channels")),
"bit_rate": _safe_int(fmt.get("bit_rate")),
"size_bytes": _safe_int(fmt.get("size")) or path.stat().st_size,
"probe_method": "ffprobe",
}
def probe_audio(audio_path: str | Path) -> dict[str, Any]:
path = normalize_audio_path(audio_path)
if path.suffix.lower() in {".wav", ".wave"}:
try:
return _probe_wav_stdlib(path)
except (wave.Error, EOFError):
pass
try:
return _probe_ffprobe(path)
except (RuntimeError, subprocess.CalledProcessError, json.JSONDecodeError):
return {
"path": str(path),
"format": path.suffix.lower().lstrip(".") or None,
"codec": None,
"duration_seconds": None,
"sample_rate": None,
"channels": None,
"size_bytes": path.stat().st_size,
"probe_method": "stat",
"warning": "Install ffprobe for detailed metadata on this format.",
}
@@ -0,0 +1,187 @@
"""Project manifest helpers for the WaveTone harness."""
from __future__ import annotations
import json
import math
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "wavetone-project/v1"
SOFTWARE_VERSION = "2.61"
SUPPORTED_AUDIO_EXTENSIONS = {
".wav",
".wave",
".aif",
".aiff",
".mp3",
".wma",
".aac",
".ogg",
".oga",
".flac",
".wv",
".ape",
".alac",
".tta",
}
DEFAULT_ANALYSIS_SETTINGS: dict[str, Any] = {
"blocks_per_second": 12,
"blocks_per_semitone": 5,
"note_range": "C1-B7",
"reference_frequency_hz": 440.0,
"analyze_fundamental_frequency": True,
"channel": "Stereo",
"skip_analysis_dialog": False,
}
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def normalize_audio_path(audio_path: str | Path) -> Path:
path = Path(audio_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Audio file not found: {path}")
if not path.is_file():
raise ValueError(f"Audio path is not a file: {path}")
if path.suffix.lower() not in SUPPORTED_AUDIO_EXTENSIONS:
supported = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS))
raise ValueError(f"Unsupported audio extension {path.suffix!r}. Supported: {supported}")
return path
def _finite_float(value: float, field_name: str) -> float:
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{field_name} must be finite")
return number
def create_project(
audio_path: str | Path,
name: str | None = None,
analysis_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
audio = normalize_audio_path(audio_path)
created_at = now_iso()
settings = deepcopy(DEFAULT_ANALYSIS_SETTINGS)
if analysis_settings:
settings.update(analysis_settings)
return {
"schema_version": SCHEMA_VERSION,
"software": {"name": "WaveTone", "version": SOFTWARE_VERSION, "backend": "wavetone.exe"},
"project": {"name": name or audio.stem, "created_at": created_at, "modified_at": created_at},
"audio": {
"path": str(audio),
"filename": audio.name,
"extension": audio.suffix.lower(),
"size_bytes": audio.stat().st_size,
},
"analysis": settings,
"tempo": {"bpm": None, "first_bar_time_seconds": 0.0, "meter": "4/4"},
"labels": [],
"notes": [],
"wfd_path": None,
"limitations": [
"WaveTone 2.61 exposes analysis, MIDI/text export, and WAVE export through GUI menus.",
"This manifest is an agent-facing plan; WFD analysis data must be saved by WaveTone itself.",
],
}
def touch(project: dict[str, Any]) -> dict[str, Any]:
project.setdefault("project", {})["modified_at"] = now_iso()
return project
def save_project(project: dict[str, Any], output_path: str | Path) -> Path:
path = Path(output_path).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
touch(project)
path.write_text(json.dumps(project, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
return path
def load_project(project_path: str | Path) -> dict[str, Any]:
path = Path(project_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Project file not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Project file must be a JSON object")
if data.get("schema_version") != SCHEMA_VERSION:
raise ValueError(f"Unsupported WaveTone project schema: {data.get('schema_version')!r}")
return data
def add_label(
project: dict[str, Any],
name: str,
time_seconds: float,
note: str | None = None,
) -> dict[str, Any]:
time_value = _finite_float(time_seconds, "Label time")
if time_value < 0:
raise ValueError("Label time must be >= 0")
label: dict[str, Any] = {"name": name, "time_seconds": round(time_value, 6)}
if note:
label["note"] = note
project.setdefault("labels", []).append(label)
project["labels"].sort(key=lambda item: item["time_seconds"])
return touch(project)
def set_tempo(
project: dict[str, Any],
bpm: float,
first_bar_time_seconds: float = 0.0,
meter: str = "4/4",
) -> dict[str, Any]:
bpm_value = _finite_float(bpm, "BPM")
first_bar_value = _finite_float(first_bar_time_seconds, "First bar time")
if bpm_value <= 0:
raise ValueError("BPM must be > 0")
if first_bar_value < 0:
raise ValueError("First bar time must be >= 0")
project["tempo"] = {
"bpm": round(bpm_value, 6),
"first_bar_time_seconds": round(first_bar_value, 6),
"meter": meter,
}
return touch(project)
def update_analysis(project: dict[str, Any], **settings: Any) -> dict[str, Any]:
analysis = project.setdefault("analysis", deepcopy(DEFAULT_ANALYSIS_SETTINGS))
for key, value in settings.items():
if value is not None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"{key} must be finite")
analysis[key] = value
return touch(project)
def set_wfd_path(project: dict[str, Any], wfd_path: str | Path | None) -> dict[str, Any]:
project["wfd_path"] = None if wfd_path is None else str(Path(wfd_path).expanduser().resolve())
return touch(project)
def project_summary(project: dict[str, Any]) -> dict[str, Any]:
return {
"schema_version": project.get("schema_version"),
"name": project.get("project", {}).get("name"),
"audio": project.get("audio"),
"tempo": project.get("tempo"),
"label_count": len(project.get("labels", [])),
"labels": project.get("labels", []),
"analysis": project.get("analysis"),
"wfd_path": project.get("wfd_path"),
"limitations": project.get("limitations", []),
}
@@ -0,0 +1,61 @@
"""Small session event log for WaveTone CLI runs."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any
from .project import now_iso
def _new_session() -> dict[str, Any]:
return {"schema_version": "wavetone-session/v1", "events": []}
def _assert_json_safe(value: Any, path: str = "payload") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"{path} must contain only finite numbers")
if isinstance(value, dict):
for key, item in value.items():
_assert_json_safe(item, f"{path}.{key}")
elif isinstance(value, list):
for idx, item in enumerate(value):
_assert_json_safe(item, f"{path}[{idx}]")
def _load_session_data(path: Path) -> dict[str, Any]:
if not path.exists():
return _new_session()
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Session file must be a JSON object")
events = data.get("events")
if events is None:
data["events"] = []
elif not isinstance(events, list):
raise ValueError("Session file field 'events' must be a list")
return data
def append_event(session_path: str | Path, event: str, payload: dict[str, Any]) -> dict[str, Any]:
path = Path(session_path).expanduser().resolve()
data = _load_session_data(path)
_assert_json_safe(payload)
record = {"time": now_iso(), "event": event, "payload": payload}
data["events"].append(record)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
return record
def load_events(session_path: str | Path) -> list[dict[str, Any]]:
path = Path(session_path).expanduser().resolve()
if not path.exists():
return []
data = _load_session_data(path)
return list(data["events"])
@@ -0,0 +1,53 @@
---
name: "cli-anything-wavetone"
description: "Control WaveTone 2.61 workflows through a JSON manifest and launch the real Windows WaveTone executable."
---
# cli-anything-wavetone
Use this skill when an agent needs to prepare, inspect, and launch WaveTone 2.61
audio transcription workflows.
## Requirements
- Windows host.
- WaveTone 2.61 extracted locally.
- Set `WAVETONE_EXE` to `wavetone.exe` or `WAVETONE_HOME` to the extracted
WaveTone directory when the default portable path is not used.
## Core Commands
All commands support the top-level `--json` flag.
```bash
cli-anything-wavetone --json wavetone doctor
cli-anything-wavetone --json project new input.wav -o project.wt.json
cli-anything-wavetone --project project.wt.json --json audio probe
cli-anything-wavetone --project project.wt.json --json project set-tempo --bpm 120 --meter 4/4
cli-anything-wavetone --project project.wt.json --json project add-label chorus --time 64.0
cli-anything-wavetone --project project.wt.json --json wavetone launch
```
## Command Groups
- `project`: create manifests, set tempo, add labels, update intended analysis
settings, and attach a WFD saved from WaveTone.
- `audio`: probe audio metadata before opening it in WaveTone.
- `wavetone`: run `doctor`, list supported formats, or launch the real GUI.
- `session`: record lightweight event logs for multi-step agent workflows.
- `defaults`: show default analysis settings.
## Agent Guidance
The JSON project manifest is not a WaveTone WFD file. It is an agent-facing plan
for the source audio, intended analysis settings, labels, and tempo metadata.
Use WaveTone itself to perform analysis and save WFD/MIDI/text/WAVE outputs.
When running in automated checks, use:
```bash
cli-anything-wavetone --json wavetone launch input.wav --wait 1 --terminate
```
This confirms that the real backend can be started without leaving a persistent
GUI process behind.
@@ -0,0 +1,177 @@
# WaveTone Harness Test Plan and Results
## Test Inventory Plan
- `test_core.py`: 24 unit tests for project manifests, audio probing, session logs,
and backend discovery.
- `test_full_e2e.py`: 8 tests covering default real-backend opt-in gating,
source-module subprocess resolution, CLI workflows, and real WaveTone launch
smoke coverage.
## Unit Test Plan
- Create a manifest from a supported WAV file.
- Reject missing files and unsupported extensions.
- Save and load schema-compatible JSON.
- Reject non-object project JSON.
- Add labels in sorted time order.
- Set tempo and analysis options.
- Preserve omitted CLI boolean analysis flags as tri-state values.
- Require attached WFD analysis files to exist and use the `.wfd` suffix.
- Reject non-finite numeric project values before JSON serialization.
- Probe a generated WAV file with the Python stdlib.
- Fall back to stat metadata for malformed WAV files.
- Probe non-WAV audio with a single stable `ffprobe -show_entries` argument.
- Safely parse non-numeric ffprobe metadata.
- Append and reload session events.
- Reject invalid session JSON schemas with clear `ValueError`s.
- Reject non-finite session payload values before writing JSON.
- Resolve `WAVETONE_EXE` from the environment.
- Reject WaveTone backend data artifacts that are directories instead of files.
- Preserve inherited project and JSON context for REPL-style nested CLI
invocations.
- Return a failing exit status for launch smoke checks when the backend exits
early with a nonzero code.
- Convert launch runtime errors to clean CLI errors.
- Reject GUI launch attempts on non-Windows hosts with a clear error.
- Strip Windows REPL path quotes while preserving backslashes.
- Preserve WaveTone branding and expose the local skill path in the REPL banner.
- Report Click REPL exit control flow without falling through to the unexpected
exception handler.
## E2E Test Plan
### CLI Project Workflow
Simulates an agent preparing an audio file before opening it in WaveTone.
Operations:
1. Generate a real WAV fixture.
2. Resolve the in-tree `python -m cli_anything.wavetone.wavetone_cli` module by
default, even if an installed `cli-anything-wavetone` exists in `PATH` or
`CLI_ANYTHING_FORCE_INSTALLED` is set externally.
3. Run `cli-anything-wavetone --json project new`.
4. Run `project set-tempo`.
5. Run `project add-label`.
6. Run `audio probe`.
Verified:
- CLI JSON is parseable.
- Project file exists.
- Labels and tempo persist.
- Audio metadata is correct.
### CLI Backend Workflow
Simulates an agent validating the installed WaveTone backend. These tests are
skipped by default and require `CLI_ANYTHING_WAVETONE_REAL_BACKEND=1` plus
`WAVETONE_EXE` or `WAVETONE_HOME`.
Operations:
1. Run `wavetone doctor`.
2. Run `wavetone formats`.
3. Launch the real `wavetone.exe` with a generated WAV and terminate it after a
short wait.
Verified:
- Doctor reports all bundled files.
- Formats include documented WaveTone audio extensions.
- Real WaveTone process starts and is terminated by the smoke test.
## Test Results
Default command:
```bash
$env:PATH = "$env:APPDATA\Python\Python313\Scripts;$env:PATH"
$env:CLI_ANYTHING_FORCE_INSTALLED = "1"
Remove-Item Env:CLI_ANYTHING_WAVETONE_REAL_BACKEND -ErrorAction SilentlyContinue
Remove-Item Env:WAVETONE_EXE -ErrorAction SilentlyContinue
Remove-Item Env:WAVETONE_HOME -ErrorAction SilentlyContinue
python -m pytest cli_anything\wavetone\tests\ -v -s
```
Default result:
```text
collected 32 items
cli_anything/wavetone/tests/test_core.py::test_create_project_manifest PASSED
cli_anything/wavetone/tests/test_core.py::test_rejects_unsupported_audio PASSED
cli_anything/wavetone/tests/test_core.py::test_save_load_project_roundtrip PASSED
cli_anything/wavetone/tests/test_core.py::test_labels_are_sorted PASSED
cli_anything/wavetone/tests/test_core.py::test_update_analysis_settings PASSED
cli_anything/wavetone/tests/test_core.py::test_cli_analysis_preserves_omitted_boolean_flags PASSED
cli_anything/wavetone/tests/test_core.py::test_cli_attach_wfd_requires_existing_wfd_file PASSED
cli_anything/wavetone/tests/test_core.py::test_rejects_non_finite_project_numbers PASSED
cli_anything/wavetone/tests/test_core.py::test_load_project_rejects_non_object_json PASSED
cli_anything/wavetone/tests/test_core.py::test_probe_wav_metadata PASSED
cli_anything/wavetone/tests/test_core.py::test_probe_malformed_wav_falls_back_to_stat PASSED
cli_anything/wavetone/tests/test_core.py::test_ffprobe_uses_single_show_entries_argument PASSED
cli_anything/wavetone/tests/test_core.py::test_ffprobe_handles_non_numeric_metadata PASSED
cli_anything/wavetone/tests/test_core.py::test_session_event_log PASSED
cli_anything/wavetone/tests/test_core.py::test_session_rejects_invalid_schema PASSED
cli_anything/wavetone/tests/test_core.py::test_find_wavetone_from_env PASSED
cli_anything/wavetone/tests/test_core.py::test_doctor_rejects_required_data_directories PASSED
cli_anything/wavetone/tests/test_core.py::test_cli_preserves_inherited_project_and_json_context PASSED
cli_anything/wavetone/tests/test_core.py::test_wavetone_launch_fails_on_early_nonzero_exit PASSED
cli_anything/wavetone/tests/test_core.py::test_wavetone_launch_reports_runtime_errors PASSED
cli_anything/wavetone/tests/test_core.py::test_launch_requires_windows PASSED
cli_anything/wavetone/tests/test_core.py::test_repl_split_strips_windows_quotes PASSED
cli_anything/wavetone/tests/test_core.py::test_repl_skin_uses_wavetone_branding_and_local_skill_path PASSED
cli_anything/wavetone/tests/test_core.py::test_repl_reports_click_exit_without_unexpected_error PASSED
cli_anything/wavetone/tests/test_full_e2e.py::test_real_backend_requires_explicit_opt_in PASSED
cli_anything/wavetone/tests/test_full_e2e.py::test_resolve_cli_defaults_to_source_module PASSED
cli_anything/wavetone/tests/test_full_e2e.py::test_resolve_cli_uses_installed_only_when_requested PASSED
cli_anything/wavetone/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
cli_anything/wavetone/tests/test_full_e2e.py::TestCLISubprocess::test_project_audio_workflow_json PASSED
cli_anything/wavetone/tests/test_full_e2e.py::TestCLISubprocess::test_formats_json PASSED
cli_anything/wavetone/tests/test_full_e2e.py::TestRealWaveToneBackend::test_doctor_real_backend SKIPPED
cli_anything/wavetone/tests/test_full_e2e.py::TestRealWaveToneBackend::test_launch_real_backend_with_wav SKIPPED
30 passed, 2 skipped in 1.44s
```
Real backend opt-in command:
```bash
$env:PATH = "$env:APPDATA\Python\Python313\Scripts;$env:PATH"
$env:CLI_ANYTHING_FORCE_INSTALLED = "1"
$env:CLI_ANYTHING_WAVETONE_REAL_BACKEND = "1"
$env:WAVETONE_HOME = "C:\Users\Hp\Desktop\wavetone2.6.1"
Remove-Item Env:WAVETONE_EXE -ErrorAction SilentlyContinue
python -m pytest cli_anything\wavetone\tests\ -v -s
```
Real backend result:
```text
32 passed in 3.03s
```
## Coverage Notes
- Unit tests cover manifest creation, validation, persistence, labels, tempo,
analysis settings, CLI analysis flag tri-state behavior, finite numeric
validation, WFD attachment validation, project JSON schema validation, audio
probing, malformed WAV fallback, session logs, session schema and payload
validation, backend discovery, backend data artifact validation, ffprobe
argument construction, ffprobe metadata parsing, inherited CLI project and
JSON context, failed launch smoke reporting, launch runtime error reporting,
Windows launch gating, REPL Windows path splitting, REPL branding and local
skill path display, and REPL Click exit handling.
- CLI subprocess tests resolve and use the in-tree
`python -m cli_anything.wavetone.wavetone_cli` module by default, regardless
of ambient `CLI_ANYTHING_FORCE_INSTALLED`.
- Real backend coverage launches `C:\Users\Hp\Desktop\wavetone2.6.1\wavetone.exe`
with a generated WAV and terminates it after a short wait.
- Real backend tests are skipped by default unless
`CLI_ANYTHING_WAVETONE_REAL_BACKEND=1` and `WAVETONE_EXE` or `WAVETONE_HOME`
are set.
- WaveTone 2.61 has no documented headless analysis/export API. Export
verification remains a known gap until a stable non-GUI automation surface is
discovered.
@@ -0,0 +1 @@
"""Tests for cli-anything-wavetone."""
@@ -0,0 +1,18 @@
from __future__ import annotations
import math
import struct
import wave
from pathlib import Path
def make_wav(path: Path, freq: float = 440.0, duration: float = 0.5, sample_rate: int = 8000) -> Path:
frames = int(duration * sample_rate)
with wave.open(str(path), "wb") as handle:
handle.setnchannels(1)
handle.setsampwidth(2)
handle.setframerate(sample_rate)
for idx in range(frames):
sample = int(16000 * math.sin(2 * math.pi * freq * idx / sample_rate))
handle.writeframes(struct.pack("<h", sample))
return path
@@ -0,0 +1,422 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
from cli_anything.wavetone import wavetone_cli as cli_module
from cli_anything.wavetone.core import audio as audio_core
from cli_anything.wavetone.core.audio import probe_audio
from cli_anything.wavetone.core.project import (
DEFAULT_ANALYSIS_SETTINGS,
add_label,
create_project,
load_project,
save_project,
set_tempo,
update_analysis,
)
from cli_anything.wavetone.core.session import append_event, load_events
from cli_anything.wavetone.tests.helpers import make_wav
from cli_anything.wavetone.utils import wavetone_backend
from cli_anything.wavetone.utils.repl_skin import ReplSkin
from cli_anything.wavetone.wavetone_cli import _split_repl_args, cli
def test_create_project_manifest(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav, name="Tone Test")
assert project["schema_version"] == "wavetone-project/v1"
assert project["project"]["name"] == "Tone Test"
assert project["audio"]["path"] == str(wav.resolve())
assert project["analysis"] == DEFAULT_ANALYSIS_SETTINGS
def test_rejects_unsupported_audio(tmp_path: Path) -> None:
txt = tmp_path / "not-audio.txt"
txt.write_text("x", encoding="utf-8")
with pytest.raises(ValueError):
create_project(txt)
def test_save_load_project_roundtrip(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav)
add_label(project, "chorus", 12.5)
set_tempo(project, 128, first_bar_time_seconds=0.2)
output = save_project(project, tmp_path / "project.json")
loaded = load_project(output)
assert loaded["labels"][0]["name"] == "chorus"
assert loaded["tempo"]["bpm"] == 128
assert loaded["tempo"]["first_bar_time_seconds"] == 0.2
def test_labels_are_sorted(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav)
add_label(project, "late", 4.0)
add_label(project, "early", 1.0)
add_label(project, "middle", "2.5")
assert [label["name"] for label in project["labels"]] == ["early", "middle", "late"]
def test_update_analysis_settings(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav)
update_analysis(project, channel="L+R", blocks_per_second=24, analyze_fundamental_frequency=False)
assert project["analysis"]["channel"] == "L+R"
assert project["analysis"]["blocks_per_second"] == 24
assert project["analysis"]["analyze_fundamental_frequency"] is False
def test_cli_analysis_preserves_omitted_boolean_flags(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav)
project["analysis"]["analyze_fundamental_frequency"] = True
project["analysis"]["skip_analysis_dialog"] = True
project_path = save_project(project, tmp_path / "tone.wt.json")
result = CliRunner().invoke(cli, ["--json", "project", "analysis", "--channel", "R", str(project_path)])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["analysis"]["channel"] == "R"
assert data["analysis"]["analyze_fundamental_frequency"] is True
assert data["analysis"]["skip_analysis_dialog"] is True
loaded = load_project(project_path)
assert loaded["analysis"]["channel"] == "R"
assert loaded["analysis"]["analyze_fundamental_frequency"] is True
assert loaded["analysis"]["skip_analysis_dialog"] is True
def test_cli_attach_wfd_requires_existing_wfd_file(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project_path = save_project(create_project(wav), tmp_path / "tone.wt.json")
missing = tmp_path / "missing.wfd"
result = CliRunner().invoke(cli, ["--project", str(project_path), "project", "attach-wfd", str(missing)])
assert result.exit_code != 0
assert "does not exist" in result.output
wrong_suffix = tmp_path / "analysis.txt"
wrong_suffix.write_text("wfd", encoding="utf-8")
result = CliRunner().invoke(cli, ["--project", str(project_path), "project", "attach-wfd", str(wrong_suffix)])
assert result.exit_code == 1
assert ".wfd" in result.output
wfd_path = tmp_path / "analysis.wfd"
wfd_path.write_text("wfd", encoding="utf-8")
result = CliRunner().invoke(cli, ["--project", str(project_path), "--json", "project", "attach-wfd", str(wfd_path)])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["wfd_path"] == str(wfd_path.resolve())
def test_rejects_non_finite_project_numbers(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = create_project(wav)
with pytest.raises(ValueError, match="BPM.*finite"):
set_tempo(project, float("nan"))
with pytest.raises(ValueError, match="reference_frequency_hz.*finite"):
update_analysis(project, reference_frequency_hz=float("inf"))
def test_load_project_rejects_non_object_json(tmp_path: Path) -> None:
project_path = tmp_path / "broken.wt.json"
project_path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError, match="JSON object"):
load_project(project_path)
def test_probe_wav_metadata(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav", duration=0.5, sample_rate=16000)
info = probe_audio(wav)
assert info["probe_method"] == "python-wave"
assert info["sample_rate"] == 16000
assert info["channels"] == 1
assert info["duration_seconds"] == 0.5
assert info["size_bytes"] > 0
def test_probe_malformed_wav_falls_back_to_stat(tmp_path: Path) -> None:
wav = tmp_path / "broken.wav"
wav.write_bytes(b"")
info = probe_audio(wav)
assert info["probe_method"] == "stat"
assert info["format"] == "wav"
assert info["duration_seconds"] is None
assert info["size_bytes"] == 0
def test_ffprobe_uses_single_show_entries_argument(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
audio = tmp_path / "tone.mp3"
audio.write_bytes(b"mp3")
captured: dict[str, list[str]] = {}
monkeypatch.setattr(audio_core.shutil, "which", lambda name: "ffprobe")
def fake_run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
captured["args"] = args
stdout = json.dumps(
{
"streams": [
{
"codec_type": "audio",
"codec_name": "mp3",
"sample_rate": "44100",
"channels": "2",
}
],
"format": {
"duration": "1.25",
"format_name": "mp3",
"bit_rate": "128000",
"size": "3",
},
}
)
return subprocess.CompletedProcess(args, 0, stdout=stdout)
monkeypatch.setattr(audio_core.subprocess, "run", fake_run)
info = audio_core._probe_ffprobe(audio)
entries = captured["args"][captured["args"].index("-show_entries") + 1]
assert captured["args"].count("-show_entries") == 1
assert "stream=codec_type,codec_name,sample_rate,channels" in entries
assert ":format=duration,format_name,bit_rate,size" in entries
assert info["probe_method"] == "ffprobe"
assert info["sample_rate"] == 44100
assert info["channels"] == 2
def test_ffprobe_handles_non_numeric_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
audio = tmp_path / "tone.mp3"
audio.write_bytes(b"mp3")
monkeypatch.setattr(audio_core.shutil, "which", lambda name: "ffprobe")
def fake_run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
stdout = json.dumps(
{
"streams": [{"codec_type": "audio", "sample_rate": "N/A"}],
"format": {
"duration": "N/A",
"format_name": "mp3",
"bit_rate": "N/A",
"size": "N/A",
},
}
)
return subprocess.CompletedProcess(args, 0, stdout=stdout)
monkeypatch.setattr(audio_core.subprocess, "run", fake_run)
info = audio_core._probe_ffprobe(audio)
assert info["duration_seconds"] is None
assert info["sample_rate"] is None
assert info["bit_rate"] is None
assert info["size_bytes"] == audio.stat().st_size
def test_session_event_log(tmp_path: Path) -> None:
session_path = tmp_path / "session.json"
append_event(session_path, "created", {"project": "demo"})
append_event(session_path, "launched", {"pid": 123})
events = load_events(session_path)
assert [event["event"] for event in events] == ["created", "launched"]
def test_session_rejects_invalid_schema(tmp_path: Path) -> None:
session_path = tmp_path / "session.json"
session_path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError, match="JSON object"):
append_event(session_path, "created", {})
session_path.write_text(json.dumps({"events": {}}), encoding="utf-8")
with pytest.raises(ValueError, match="events.*list"):
load_events(session_path)
session_path.unlink()
with pytest.raises(ValueError, match="finite"):
append_event(session_path, "bad", {"value": float("nan")})
assert not session_path.exists()
def test_find_wavetone_from_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
fake = tmp_path / "wavetone.exe"
fake.write_bytes(b"MZ")
monkeypatch.setenv("WAVETONE_EXE", str(fake))
assert wavetone_backend.find_wavetone() == fake.resolve()
def test_doctor_rejects_required_data_directories(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path / "wavetone"
data_dir = root / "data"
help_dir = root / "wthelp"
data_dir.mkdir(parents=True)
help_dir.mkdir()
exe = root / "wavetone.exe"
exe.write_bytes(b"MZ")
directory_artifact = wavetone_backend.REQUIRED_DATA_FILES[0]
for filename in wavetone_backend.REQUIRED_DATA_FILES:
path = data_dir / filename
if filename == directory_artifact:
path.mkdir()
else:
path.write_bytes(b"x")
monkeypatch.setenv("WAVETONE_EXE", str(exe))
monkeypatch.setattr(wavetone_backend.platform, "system", lambda: "Windows")
monkeypatch.setattr(wavetone_backend.platform, "platform", lambda: "Windows-test")
status = wavetone_backend.doctor()
artifact = next(check for check in status["checks"] if check["name"] == f"data/{directory_artifact}")
assert status["ready"] is False
assert artifact["ok"] is False
def test_cli_preserves_inherited_project_and_json_context(tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project_path = save_project(create_project(wav), tmp_path / "tone.wt.json")
result = CliRunner().invoke(
cli,
["audio", "probe"],
obj={"project": str(project_path), "json": True},
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["audio"]["path"] == str(wav.resolve())
def test_wavetone_launch_fails_on_early_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_launch_wavetone(**kwargs: object) -> dict[str, object]:
return {
"backend": "wavetone.exe",
"executable": "C:/fake/wavetone.exe",
"running_after_wait": False,
"terminated": False,
"exit_code": 42,
}
monkeypatch.setattr(wavetone_backend, "launch_wavetone", fake_launch_wavetone)
result = CliRunner().invoke(cli, ["--json", "wavetone", "launch", "--wait", "1"])
assert result.exit_code == 42
data = json.loads(result.output)
assert data["ok"] is False
assert data["launch"]["exit_code"] == 42
def test_wavetone_launch_reports_runtime_errors(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_launch_wavetone(**kwargs: object) -> dict[str, object]:
raise RuntimeError("WaveTone launch requires Windows")
monkeypatch.setattr(wavetone_backend, "launch_wavetone", fake_launch_wavetone)
result = CliRunner().invoke(cli, ["--json", "wavetone", "launch"])
assert result.exit_code == 1
assert "WaveTone launch requires Windows" in result.output
assert "Traceback" not in result.output
def test_launch_requires_windows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(wavetone_backend.platform, "system", lambda: "Linux")
with pytest.raises(RuntimeError, match="requires Windows"):
wavetone_backend.launch_wavetone()
def test_repl_split_strips_windows_quotes() -> None:
line = 'project new "C:\\Users\\me\\My Music\\song.wav" -o "C:\\Users\\me\\song.wt.json"'
assert _split_repl_args(line) == [
"project",
"new",
"C:\\Users\\me\\My Music\\song.wav",
"-o",
"C:\\Users\\me\\song.wt.json",
]
def test_repl_skin_uses_wavetone_branding_and_local_skill_path(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
skill_path = tmp_path / "SKILL.md"
skill_path.write_text("# WaveTone", encoding="utf-8")
skin = ReplSkin("WaveTone", version="test", history_file=str(tmp_path / "history"), skill_path=str(skill_path))
skin.print_banner()
output = capsys.readouterr().out
assert skin.display_name == "WaveTone"
assert "WaveTone" in output
assert "Local skill:" in output
assert "SKILL.md" in output
def test_repl_reports_click_exit_without_unexpected_error(monkeypatch: pytest.MonkeyPatch) -> None:
errors: list[str] = []
inputs = iter(["wavetone launch", "exit"])
class FakeSkin:
def __init__(self, name: str, version: str) -> None:
self.name = name
self.version = version
def print_banner(self) -> None:
return None
def info(self, message: str) -> None:
return None
def create_prompt_session(self) -> object:
return object()
def get_input(self, prompt_session: object, project_name: str, modified: bool) -> str:
return next(inputs)
def error(self, message: str) -> None:
errors.append(message)
def print_goodbye(self) -> None:
return None
def fake_main(**kwargs: object) -> None:
raise click.exceptions.Exit(1)
monkeypatch.setattr(cli_module, "ReplSkin", FakeSkin)
monkeypatch.setattr(cli_module.cli, "main", fake_main)
result = CliRunner().invoke(cli_module.repl, [], obj={"json": False})
assert result.exit_code == 0, result.output
assert errors == ["Command exited with code 1"]
@@ -0,0 +1,153 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from cli_anything.wavetone.tests.helpers import make_wav
from cli_anything.wavetone.utils import wavetone_backend
REAL_BACKEND_ENV = "CLI_ANYTHING_WAVETONE_REAL_BACKEND"
def _resolve_cli(name: str, force_installed: bool = False) -> list[str]:
"""Run the in-tree module by default; installed entry points are opt-in."""
module = "cli_anything.wavetone.wavetone_cli"
if not force_installed:
print(f"[_resolve_cli] Using source module: {sys.executable} -m {module}")
return [sys.executable, "-m", module]
path = shutil.which(name)
if not path:
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
print(f"[_resolve_cli] Using installed command: {path}")
return [path]
def _env_flag_enabled(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _real_backend_ready() -> tuple[bool, str]:
if not _env_flag_enabled(REAL_BACKEND_ENV):
return False, f"set {REAL_BACKEND_ENV}=1 with WAVETONE_EXE or WAVETONE_HOME to run real backend tests"
if sys.platform != "win32":
return False, "WaveTone real-backend tests require Windows"
if not (os.environ.get("WAVETONE_EXE") or os.environ.get("WAVETONE_HOME")):
return False, "set WAVETONE_EXE or WAVETONE_HOME to run real backend tests"
status = wavetone_backend.doctor()
if status.get("ready"):
return True, ""
failed_checks = [check["name"] for check in status.get("checks", []) if not check.get("ok")]
reason = ", ".join(failed_checks) if failed_checks else "WaveTone backend is not ready"
return False, f"WaveTone real backend unavailable: {reason}"
_REAL_BACKEND_READY, _REAL_BACKEND_SKIP_REASON = _real_backend_ready()
def test_real_backend_requires_explicit_opt_in(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(REAL_BACKEND_ENV, raising=False)
monkeypatch.delenv("WAVETONE_EXE", raising=False)
monkeypatch.delenv("WAVETONE_HOME", raising=False)
ready, reason = _real_backend_ready()
assert ready is False
assert REAL_BACKEND_ENV in reason
def test_resolve_cli_defaults_to_source_module(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CLI_ANYTHING_FORCE_INSTALLED", "1")
monkeypatch.setattr(shutil, "which", lambda name: pytest.fail("default resolver should not inspect PATH"))
assert _resolve_cli("cli-anything-wavetone") == [sys.executable, "-m", "cli_anything.wavetone.wavetone_cli"]
def test_resolve_cli_uses_installed_only_when_requested(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(shutil, "which", lambda name: "C:/installed/cli-anything-wavetone.exe")
assert _resolve_cli("cli-anything-wavetone", force_installed=True) == ["C:/installed/cli-anything-wavetone.exe"]
class TestCLISubprocess:
def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(_resolve_cli("cli-anything-wavetone") + args, capture_output=True, text=True, check=check)
def test_help(self) -> None:
result = self._run(["--help"])
assert "Agent-native CLI harness for WaveTone" in result.stdout
def test_project_audio_workflow_json(self, tmp_path: Path) -> None:
wav = make_wav(tmp_path / "tone.wav")
project = tmp_path / "tone.wt.json"
result = self._run(["--json", "project", "new", str(wav), "-o", str(project)])
data = json.loads(result.stdout)
assert data["ok"] is True
assert project.exists()
result = self._run(
["--project", str(project), "--json", "project", "set-tempo", "--bpm", "132.5", "--first-bar", "0.1"]
)
assert json.loads(result.stdout)["tempo"]["bpm"] == 132.5
result = self._run(["--project", str(project), "--json", "project", "add-label", "intro", "--time", "0.25"])
assert json.loads(result.stdout)["labels"][0]["name"] == "intro"
result = self._run(["--project", str(project), "--json", "audio", "probe"])
audio = json.loads(result.stdout)["audio"]
assert audio["duration_seconds"] == 0.5
assert audio["channels"] == 1
print(f"\n Project: {project}")
print(f" Audio: {wav} ({wav.stat().st_size:,} bytes)")
def test_formats_json(self) -> None:
result = self._run(["--json", "wavetone", "formats"])
data = json.loads(result.stdout)
assert ".wav" in data["formats"]["extensions"]
assert "MP3" in data["formats"]["from_readme"]
@pytest.mark.skipif(not _REAL_BACKEND_READY, reason=_REAL_BACKEND_SKIP_REASON)
class TestRealWaveToneBackend:
def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(_resolve_cli("cli-anything-wavetone") + args, capture_output=True, text=True, check=check)
def test_doctor_real_backend(self) -> None:
result = self._run(["--json", "wavetone", "doctor"])
data = json.loads(result.stdout)
assert data["ready"] is True
assert any(check["name"] == "data/asdecoder.exe" and check["ok"] for check in data["checks"])
print(f"\n WaveTone root: {data['root']}")
def test_launch_real_backend_with_wav(self, tmp_path: Path) -> None:
exe = wavetone_backend.find_wavetone()
wav = make_wav(tmp_path / "launch.wav")
result = self._run(
[
"--json",
"wavetone",
"launch",
str(wav),
"--exe",
str(exe),
"--wait",
"1",
"--terminate",
]
)
data = json.loads(result.stdout)["launch"]
assert data["backend"] == "wavetone.exe"
assert data["audio_path"] == str(wav.resolve())
assert data["running_after_wait"] is True
assert data["terminated"] is True
print(f"\n Launched WaveTone PID: {data['pid']}")
print(f" WAV: {wav} ({wav.stat().st_size:,} bytes)")
@@ -0,0 +1 @@
"""Utility modules for cli-anything-wavetone."""
@@ -0,0 +1,568 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
display_overrides = {"wavetone": "WaveTone"}
self.display_name = display_overrides.get(self.software, software.replace("_", " ").title())
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
if self.skill_path:
meta_lines.extend(_meta_lines("Local skill:", _display_home_path(self.skill_path)))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = f" {sep.join(sep_parts)}"
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,163 @@
"""Backend wrapper for the real WaveTone 2.61 executable."""
from __future__ import annotations
import os
import platform
import subprocess
import time
from pathlib import Path
from typing import Any
from cli_anything.wavetone.core.project import SUPPORTED_AUDIO_EXTENSIONS, normalize_audio_path
REQUIRED_DATA_FILES = [
"awlib.dll",
"bass.dll",
"bassflac.dll",
"basswma.dll",
"basswv.dll",
"bass_aac.dll",
"bass_alac.dll",
"bass_ape.dll",
"bass_tta.dll",
"asdecoder.exe",
]
def default_candidates() -> list[Path]:
candidates: list[Path] = []
env_exe = os.environ.get("WAVETONE_EXE")
if env_exe:
candidates.append(Path(env_exe))
env_home = os.environ.get("WAVETONE_HOME")
if env_home:
candidates.append(Path(env_home) / "wavetone.exe")
candidates.append(Path(env_home) / "WaveTone.exe")
home = Path.home()
candidates.extend(
[
home / "Desktop" / "wavetone2.6.1" / "wavetone.exe",
home / "Downloads" / "wavetone2.6.1" / "wavetone.exe",
Path("C:/Program Files/WaveTone/wavetone.exe"),
Path("C:/Program Files (x86)/WaveTone/wavetone.exe"),
]
)
return candidates
def find_wavetone(executable: str | Path | None = None) -> Path:
candidates = [Path(executable)] if executable else default_candidates()
checked: list[str] = []
for candidate in candidates:
path = candidate.expanduser()
checked.append(str(path))
if path.exists() and path.is_file():
return path.resolve()
raise FileNotFoundError(
"WaveTone executable not found. Set WAVETONE_EXE to wavetone.exe or "
"WAVETONE_HOME to the extracted WaveTone directory. Checked: "
+ "; ".join(checked)
)
def doctor(executable: str | Path | None = None) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
try:
exe = find_wavetone(executable)
root = exe.parent
checks.append({"name": "wavetone executable", "ok": True, "path": str(exe)})
except FileNotFoundError as exc:
return {
"ready": False,
"platform": platform.system(),
"checks": [{"name": "wavetone executable", "ok": False, "error": str(exc)}],
}
data_dir = root / "data"
checks.append({"name": "data directory", "ok": data_dir.is_dir(), "path": str(data_dir)})
for filename in REQUIRED_DATA_FILES:
path = data_dir / filename
checks.append({"name": f"data/{filename}", "ok": path.is_file(), "path": str(path)})
help_dir = root / "wthelp"
checks.append({"name": "help directory", "ok": help_dir.is_dir(), "path": str(help_dir)})
checks.append(
{
"name": "windows host",
"ok": platform.system().lower() == "windows",
"platform": platform.platform(),
}
)
return {
"ready": all(check["ok"] for check in checks),
"platform": platform.system(),
"root": str(root),
"checks": checks,
}
def launch_wavetone(
audio_path: str | Path | None = None,
executable: str | Path | None = None,
wait_seconds: float = 0.0,
terminate: bool = False,
) -> dict[str, Any]:
if platform.system().lower() != "windows":
raise RuntimeError("WaveTone launch requires Windows")
exe = find_wavetone(executable)
args = [str(exe)]
audio: Path | None = None
if audio_path:
audio = normalize_audio_path(audio_path)
args.append(str(audio))
process = subprocess.Popen(args, cwd=str(exe.parent))
if wait_seconds > 0:
time.sleep(wait_seconds)
polled = process.poll()
running_after_wait = polled is None
exit_code = polled
terminated = False
if terminate and running_after_wait:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
terminated = True
exit_code = process.returncode
return {
"backend": "wavetone.exe",
"executable": str(exe),
"cwd": str(exe.parent),
"pid": process.pid,
"args": args,
"audio_path": str(audio) if audio else None,
"running_after_wait": running_after_wait,
"terminated": terminated,
"exit_code": exit_code,
"note": "WaveTone analysis and export dialogs are GUI driven in version 2.61.",
}
def supported_formats() -> dict[str, Any]:
return {
"extensions": sorted(SUPPORTED_AUDIO_EXTENSIONS),
"from_readme": [
"WAVE",
"AIFF",
"MP3",
"WMA",
"AAC",
"Vorbis",
"FLAC",
"WavPack",
"Monkey's Audio",
"ALAC",
"TTA",
],
}
@@ -0,0 +1,371 @@
"""Click CLI for WaveTone 2.61."""
from __future__ import annotations
import json
import shlex
from pathlib import Path
from typing import Any
import click
from cli_anything.wavetone import __version__
from cli_anything.wavetone.core.audio import probe_audio
from cli_anything.wavetone.core.project import (
DEFAULT_ANALYSIS_SETTINGS,
add_label,
create_project,
load_project,
project_summary,
save_project,
set_tempo,
set_wfd_path,
update_analysis,
)
from cli_anything.wavetone.core.session import append_event, load_events
from cli_anything.wavetone.utils import wavetone_backend
from cli_anything.wavetone.utils.repl_skin import ReplSkin
def emit(data: dict[str, Any], json_mode: bool) -> None:
if json_mode:
click.echo(json.dumps(data, indent=2, sort_keys=True))
return
for key, value in data.items():
if isinstance(value, (dict, list)):
click.echo(f"{key}: {json.dumps(value, sort_keys=True)}")
else:
click.echo(f"{key}: {value}")
def ctx_json(ctx: click.Context) -> bool:
return bool(ctx.obj and ctx.obj.get("json"))
def ctx_project(ctx: click.Context) -> Path | None:
value = ctx.obj.get("project") if ctx.obj else None
return Path(value).expanduser().resolve() if value else None
def load_ctx_project(ctx: click.Context, explicit_path: str | Path | None = None) -> tuple[dict[str, Any], Path]:
path = Path(explicit_path).expanduser().resolve() if explicit_path else ctx_project(ctx)
if not path:
raise click.ClickException("Project path required. Use --project PATH or pass a project argument.")
return load_project(path), path
def _strip_matching_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value
def _split_repl_args(line: str) -> list[str]:
return [_strip_matching_quotes(arg) for arg in shlex.split(line, posix=False)]
@click.group(invoke_without_command=True)
@click.option("--project", "project_path", type=click.Path(dir_okay=False), help="WaveTone project JSON path.")
@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON.")
@click.version_option(version=__version__, prog_name="cli-anything-wavetone")
@click.pass_context
def cli(ctx: click.Context, project_path: str | None, json_mode: bool) -> None:
"""Agent-native CLI harness for WaveTone 2.61."""
ctx.ensure_object(dict)
if project_path is not None:
ctx.obj["project"] = project_path
else:
ctx.obj.setdefault("project", None)
if json_mode:
ctx.obj["json"] = True
else:
ctx.obj.setdefault("json", False)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
@cli.command()
@click.pass_context
def repl(ctx: click.Context) -> None:
"""Start an interactive command loop."""
skin = ReplSkin("WaveTone", version=__version__)
skin.print_banner()
skin.info("Type 'help' for commands, 'exit' to quit.")
prompt_session = skin.create_prompt_session()
while True:
try:
line = skin.get_input(prompt_session, project_name="wavetone", modified=False)
except (EOFError, KeyboardInterrupt):
break
line = line.strip()
if not line:
continue
if line in {"exit", "quit"}:
break
if line == "help":
click.echo(cli.get_help(ctx))
continue
try:
args = _split_repl_args(line)
cli.main(args=args, prog_name="cli-anything-wavetone", standalone_mode=False, obj=ctx.obj)
except click.ClickException as exc: # pragma: no cover
skin.error(exc.format_message())
except click.exceptions.Exit as exc: # pragma: no cover
if exc.exit_code not in (0, None):
skin.error(f"Command exited with code {exc.exit_code}")
except Exception as exc: # pragma: no cover
skin.error(f"Unexpected error: {exc}")
skin.print_goodbye()
@cli.group(name="project")
def project_group() -> None:
"""Create and edit WaveTone project manifests."""
@project_group.command("new")
@click.argument("audio_path", type=click.Path(exists=True, dir_okay=False))
@click.option("-o", "--output", "output_path", type=click.Path(dir_okay=False), required=True)
@click.option("--name", help="Project name. Defaults to the audio file stem.")
@click.pass_context
def project_new(ctx: click.Context, audio_path: str, output_path: str, name: str | None) -> None:
"""Create a WaveTone project manifest for an audio file."""
try:
project = create_project(audio_path, name=name)
path = save_project(project, output_path)
except (OSError, RuntimeError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "project": project_summary(project)}, ctx_json(ctx))
@project_group.command("info")
@click.argument("project_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def project_info(ctx: click.Context, project_path: str | None) -> None:
"""Inspect a WaveTone project manifest."""
try:
project, path = load_ctx_project(ctx, project_path)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "project": project_summary(project)}, ctx_json(ctx))
@project_group.command("add-label")
@click.argument("name")
@click.option("--time", "time_seconds", type=float, required=True, help="Label time in seconds.")
@click.option("--note", help="Optional label note.")
@click.argument("project_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def project_add_label(ctx: click.Context, name: str, time_seconds: float, note: str | None, project_path: str | None) -> None:
"""Add a navigation label to the manifest."""
try:
project, path = load_ctx_project(ctx, project_path)
add_label(project, name=name, time_seconds=time_seconds, note=note)
save_project(project, path)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "labels": project.get("labels", [])}, ctx_json(ctx))
@project_group.command("set-tempo")
@click.option("--bpm", type=float, required=True)
@click.option("--first-bar", "first_bar_time_seconds", type=float, default=0.0, show_default=True)
@click.option("--meter", default="4/4", show_default=True)
@click.argument("project_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def project_set_tempo(ctx: click.Context, bpm: float, first_bar_time_seconds: float, meter: str, project_path: str | None) -> None:
"""Set the tempo plan WaveTone should use before chord or note work."""
try:
project, path = load_ctx_project(ctx, project_path)
set_tempo(project, bpm=bpm, first_bar_time_seconds=first_bar_time_seconds, meter=meter)
save_project(project, path)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "tempo": project.get("tempo")}, ctx_json(ctx))
@project_group.command("analysis")
@click.option("--blocks-per-second", type=int)
@click.option("--blocks-per-semitone", type=int)
@click.option("--note-range")
@click.option("--reference-frequency-hz", type=float)
@click.option("--channel", type=click.Choice(["Stereo", "L-R", "L+R", "L", "R"]))
@click.option("--fundamental", "fundamental", flag_value=True, default=None)
@click.option("--no-fundamental", "fundamental", flag_value=False, default=None)
@click.option("--skip-dialog", "skip_dialog", flag_value=True, default=None)
@click.option("--show-dialog", "skip_dialog", flag_value=False, default=None)
@click.argument("project_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def project_analysis(
ctx: click.Context,
blocks_per_second: int | None,
blocks_per_semitone: int | None,
note_range: str | None,
reference_frequency_hz: float | None,
channel: str | None,
fundamental: bool | None,
skip_dialog: bool | None,
project_path: str | None,
) -> None:
"""Update intended WaveTone analysis settings."""
try:
project, path = load_ctx_project(ctx, project_path)
update_analysis(
project,
blocks_per_second=blocks_per_second,
blocks_per_semitone=blocks_per_semitone,
note_range=note_range,
reference_frequency_hz=reference_frequency_hz,
channel=channel,
analyze_fundamental_frequency=fundamental,
skip_analysis_dialog=skip_dialog,
)
save_project(project, path)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "analysis": project.get("analysis")}, ctx_json(ctx))
@project_group.command("attach-wfd")
@click.argument("wfd_path", type=click.Path(exists=True, dir_okay=False))
@click.argument("project_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def project_attach_wfd(ctx: click.Context, wfd_path: str, project_path: str | None) -> None:
"""Attach a WFD analysis file saved by WaveTone."""
try:
if Path(wfd_path).suffix.lower() != ".wfd":
raise ValueError("WFD path must use the .wfd extension")
project, path = load_ctx_project(ctx, project_path)
set_wfd_path(project, wfd_path)
save_project(project, path)
except (OSError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "project_path": str(path), "wfd_path": project.get("wfd_path")}, ctx_json(ctx))
@cli.group(name="audio")
def audio_group() -> None:
"""Probe audio files before opening them in WaveTone."""
@audio_group.command("probe")
@click.argument("audio_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def audio_probe(ctx: click.Context, audio_path: str | None) -> None:
"""Return duration, sample rate, channel, codec, and size metadata."""
try:
if audio_path:
target = audio_path
else:
project, _ = load_ctx_project(ctx)
target = project["audio"]["path"]
data = probe_audio(target)
except (OSError, ValueError, RuntimeError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "audio": data}, ctx_json(ctx))
@cli.group(name="wavetone")
def wavetone_group() -> None:
"""Inspect and launch the real WaveTone executable."""
@wavetone_group.command("doctor")
@click.option("--exe", "executable", type=click.Path(dir_okay=False), help="Path to wavetone.exe.")
@click.pass_context
def wavetone_doctor(ctx: click.Context, executable: str | None) -> None:
"""Check whether WaveTone and its bundled decoder files are available."""
data = wavetone_backend.doctor(executable)
emit(data, ctx_json(ctx))
if not data.get("ready"):
raise click.exceptions.Exit(1)
@wavetone_group.command("formats")
@click.pass_context
def wavetone_formats(ctx: click.Context) -> None:
"""List audio formats documented by WaveTone 2.61."""
emit({"ok": True, "formats": wavetone_backend.supported_formats()}, ctx_json(ctx))
@wavetone_group.command("launch")
@click.argument("audio_path", required=False, type=click.Path(exists=True, dir_okay=False))
@click.option("--exe", "executable", type=click.Path(dir_okay=False), help="Path to wavetone.exe.")
@click.option("--wait", "wait_seconds", type=float, default=0.0, show_default=True)
@click.option("--terminate", is_flag=True, help="Terminate after the wait period, useful for smoke tests.")
@click.pass_context
def wavetone_launch(ctx: click.Context, audio_path: str | None, executable: str | None, wait_seconds: float, terminate: bool) -> None:
"""Launch the real WaveTone GUI, optionally with an audio file."""
try:
target = audio_path
if not target:
project_path = ctx_project(ctx)
if project_path:
project = load_project(project_path)
target = project["audio"]["path"]
data = wavetone_backend.launch_wavetone(
audio_path=target,
executable=executable,
wait_seconds=wait_seconds,
terminate=terminate,
)
except (OSError, RuntimeError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
failed_launch = (
wait_seconds > 0
and not data.get("running_after_wait")
and not data.get("terminated")
and data.get("exit_code") not in (None, 0)
)
emit({"ok": not failed_launch, "launch": data}, ctx_json(ctx))
if failed_launch:
raise click.exceptions.Exit(int(data.get("exit_code") or 1))
@cli.group(name="session")
def session_group() -> None:
"""Record lightweight CLI session events."""
@session_group.command("record")
@click.argument("session_path", type=click.Path(dir_okay=False))
@click.argument("event")
@click.option("--payload", default="{}", help="JSON object payload.")
@click.pass_context
def session_record(ctx: click.Context, session_path: str, event: str, payload: str) -> None:
"""Append an event to a session log."""
try:
payload_data = json.loads(payload)
if not isinstance(payload_data, dict):
raise ValueError("Payload must be a JSON object")
record = append_event(session_path, event, payload_data)
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "event": record}, ctx_json(ctx))
@session_group.command("events")
@click.argument("session_path", type=click.Path(dir_okay=False))
@click.pass_context
def session_events(ctx: click.Context, session_path: str) -> None:
"""List session events."""
try:
events = load_events(session_path)
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise click.ClickException(str(exc)) from exc
emit({"ok": True, "events": events}, ctx_json(ctx))
@cli.command("defaults")
@click.pass_context
def defaults(ctx: click.Context) -> None:
"""Show default WaveTone analysis settings used in new manifests."""
emit({"ok": True, "analysis_defaults": DEFAULT_ANALYSIS_SETTINGS}, ctx_json(ctx))
def main() -> None:
cli(obj={})
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Setup for cli-anything-wavetone."""
from pathlib import Path
from setuptools import find_namespace_packages, setup
readme = Path(__file__).parent / "cli_anything" / "wavetone" / "README.md"
setup(
name="cli-anything-wavetone",
version="1.0.0",
author="cli-anything contributors",
description=(
"Agent-native CLI harness for WaveTone 2.61. Creates structured audio "
"analysis manifests and launches the real WaveTone Windows executable."
),
long_description=readme.read_text(encoding="utf-8") if readme.exists() else "",
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(
include=["cli_anything.*"],
exclude=["cli_anything.*.tests", "cli_anything.*.tests.*"],
),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio :: Analysis",
"Topic :: Multimedia :: Sound/Audio :: Editors",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Operating System :: Microsoft :: Windows",
],
python_requires=">=3.10",
install_requires=["click>=8.0.0", "prompt-toolkit>=3.0.0"],
extras_require={"dev": ["pytest>=7.0.0"]},
entry_points={
"console_scripts": [
"cli-anything-wavetone=cli_anything.wavetone.wavetone_cli:main",
],
},
package_data={"cli_anything.wavetone": ["README.md", "skills/SKILL.md", "skills/*.md"]},
include_package_data=True,
zip_safe=False,
)