chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user