Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added — LLM-native Phase A
- **--plain 글로벌 출력 모드**: rich color/markup/i18n을 모두 끄고 영어 ASCII 출력. `AUDIOMAN_PLAIN=1` 환경변수로도 활성화. `--help`/`print_table`이 grep/awk 친화 텍스트로 fallback. LLM agent 후기 #1 직접 대응.
- **Finding 스키마** (`audioman.core.findings`): signal / spectral / plugin / container 4개 카테고리 통합 결함 표현. `code` (안정 enum), `severity` (info/warn/critical), `where` (file/sample/sec/freq), `measurement`, `hint`, `fix_hint` 필드. JSONSchema 발행 (`audioman://schema/finding.v1.json`).
- **`audioman observe`**: 새 1급 명령 — `signal+spectral` 카테고리 fault detector(clipping, DC offset, channel imbalance, leading/trailing/inner silence, mains hum, HF noise floor)를 통합된 `finding[]` 배열로 emit. `--category`, `--severity`, `--recursive` 지원. JSON envelope에 `duration_sec`, `total_samples`, `sample_rate`, `channels` 항상 채워짐 (후기 #3 대응).
- **`audioman changelog`**: CHANGELOG.md 파서. `--since X.Y.Z` 필터, `--json` envelope. 후기 #5 대응.
- **`audioman schemas list|show`**: 발행된 JSONSchema 노출. LLM agent가 `audioman --json` 출력의 모양을 호출 전에 알 수 있다.
- **analyze --json 메타 보강**: `$schema`, `audioman_version`, `duration_sec`, `total_samples`, `findings[]` 필드 추가. 기존 `duration`, `frames` 필드는 호환을 위해 유지.
- **새 detector 모듈** (`audioman.core.detectors`): `detect_clipping`, `detect_dc_offset`, `detect_channel_imbalance`, `silence_to_findings`, `spectrum_to_findings`. 기존 `core/analysis.py` 출력을 그대로 받아 Finding으로 어댑팅.

### Added — DAW 실시간 스트리밍 재현 / 플러그인 벤치마크·디버깅
- **`audioman stream`**: 실제 DAW(Ableton 등)의 고정 블록 콜백 처리를 재현해 플러그인 클릭/드롭아웃을 triage 하는 1급 명령. 4개 서브커맨드:
- `bench`: 블록 크기(64/128/256/512/1024)별 실시간 CPU 부하 측정 — 블록당 처리시간 vs 실시간 마감(deadline) 비율(RT factor p50/p99/max), xrun 수, 추정 동시 트랙 수.
- `triage`: 블록 스트리밍 출력에서 클릭/불연속을 검출해 `finding[]`로 emit. 블록 경계 정렬 여부로 "스트리밍 상태 단절(=DAW 클릭)" vs "소스 콘텐츠 클릭"을 구분. offline 렌더 대비 null test 포함.
- `compare`: 여러 블록 크기 출력을 서로/오프라인과 null test 비교 — block-size 의존 버그 탐지.
- `play`: sounddevice로 플러그인 통과 신호 실시간 재생 + PortAudio underflow(실 xrun) 카운트.
- **`audioman.core.streaming`**: 블록 단위 결정적 처리 엔진. `render_offline`(whole-buffer ground truth) / `render_streamed`(연속 블록, `reset_per_block`·`reset_first` 옵션). pedalboard 실측 확인: `reset=False` 연속 호출은 오프라인 렌더와 비트 단위 일치(-600dB), 매 블록 reset 시 경계 클릭(-7.5dB).
- **`audioman.core.discontinuity`**: 클릭 triage 디텍터. `detect_discontinuities`(MAD-robust sample-diff spike + 블록 경계 정렬 분류), `detect_nonfinite`(NaN/Inf), `null_test`(PDC 보상 후 offline 대비 차이). 기존 `Finding`/`Code.CLICK_DENSITY`/`SAMPLE_DROPOUT`/`NONFINITE_SAMPLES` 스키마 재사용.
- **`audioman.core.rt_bench`**: `BlockTiming`→`RTBenchReport` 집계. worst-case(p99/max) 기반 동시 트랙 추정, 워밍업 블록 제외.
- **`VST3PluginWrapper.process(reset=)`**: reset 인자 추가 — 블록 스트리밍에서 `reset=False`로 내부 상태(필터 히스토리/lookahead) 연속 유지. 기본값 True로 기존 오프라인 동작 하위호환.

### Removed
- **i18n 인프라 전면 삭제**: `src/audioman/i18n.py` 및 한국어 카탈로그 제거. 282개 `_("...")` 호출을 모두 평문 영어 문자열로 변환. `AUDIOMAN_LANG` 환경변수 미지원. CLI 출력 언어는 항상 영어로 통일 (`--plain` 플래그의 역할은 ANSI/Rich 끄기로 축소).

### Tests
- `tests/unit/test_plain_mode.py` (3), `test_findings.py` (16), `test_observe.py` (5), `test_changelog_cmd.py` (5) — 신규 29개 추가.
- `tests/unit/test_streaming.py` (14) — streaming null test, 블록 경계 클릭 검출, RT factor 단조성/블록 크기 의존성. pedalboard 빌트인만 사용(VST3 불필요).

## [0.2.0] - 2026-05-10

### Added
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ audioman process ./input_dir/ -p dereverb -o ./output_dir/ -r # recursive
| `doctor -p <plugin>` | Plugin analysis (freq response, THD, dynamics, waveshaper) |
| `vo {analyze,process}` | Voiceover workflow (VAD + RX denoise + utterance LUFS leveling) |
| `obs {probe,dry-run}` | OBS multitrack 영상 자동 진단 — track topology + voice/music classification + 처치 계획 (dry-run only) |
| `stream {bench,triage,compare,play}` | DAW 실시간 블록 처리 재현 — 플러그인 클릭/드롭아웃 triage + CPU 부하 벤치마크 |

## Plugin Click / Dropout Triage (DAW streaming)

실제 DAW(Ableton 등)에서 나는 클릭을 audioman이 재현하고 자동 진단한다. DAW는 오디오를 고정 블록(128/256/512 samples)으로 콜백 처리하며 블록 사이 플러그인 내부 상태를 연속 유지한다 — `stream`은 그 환경을 재현해 오프라인 바운스와 무엇이 다른지 노출한다.

```bash
# 블록 크기별 실시간 CPU 부하 (RT factor, xrun, 동시 트랙 추정)
audioman stream bench mix.wav -p reverb --blocks 64,128,256,512,1024

# 클릭/불연속 triage — 블록 경계 정렬 = 스트리밍 버그, 비정렬 = 소스 클릭
audioman stream triage mix.wav -p denoise --block-size 512 --json

# 잘못된 호스트 동작(매 블록 reset) 시뮬레이션 — 클릭 강제 유발
audioman stream triage mix.wav -p denoise --reset-per-block

# 블록 크기 의존 버그: 출력이 블록 크기마다 다른지 null test
audioman stream compare mix.wav -p delay --blocks 128,256,512

# 실제 오디오 디바이스로 재생 + PortAudio underflow(실 xrun) 카운트
audioman stream play mix.wav -p reverb --block-size 256

# VST3 없이 테스트: builtin: 접두사로 pedalboard 내장 이펙트 사용
audioman stream triage sine -p builtin:reverb --reset-per-block
```

## Batch Processing

Expand Down
2 changes: 1 addition & 1 deletion src/audioman/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Created: 2026-03-21
# Purpose: audioman - Cross-platform CLI wrapper for VST3/AU audio plugins

__version__ = "0.1.0"
__version__ = "0.3.0-dev"
64 changes: 46 additions & 18 deletions src/audioman/cli/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
from pathlib import Path

from audioman import __version__
from audioman.cli.output import print_error, print_json, print_table, print_success, output_console
from audioman.core.audio_file import read_audio, get_audio_stats
from audioman.core.analysis import (
Expand All @@ -14,28 +15,32 @@
spectrum_diagnostics,
)
from audioman.core.batch import collect_audio_files
from audioman.core.detectors import (
detect_signal_findings,
spectrum_to_findings,
silence_to_findings,
)
from audioman.core.waveform import render_waveform, render_envelope, render_spectral_envelope
from audioman.i18n import _


def add_parser(subparsers: argparse._SubParsersAction) -> None:
parser = subparsers.add_parser("analyze", help=_("Audio analysis (RMS, spectral entropy, silence detection, etc.)"))
parser.add_argument("input", help=_("Input audio file or directory"))
parser.add_argument("--frames", action="store_true", help=_("Per-frame detailed output"))
parser.add_argument("--frame-size", type=int, default=2048, help=_("Frame size (default: 2048)"))
parser.add_argument("--hop", type=int, default=512, help=_("Hop size (default: 512)"))
parser.add_argument("--silence-threshold", type=float, default=-40.0, help=_("Silence detection threshold dB (default: -40)"))
parser.add_argument("--waveform", "-w", action="store_true", help=_("Show ASCII waveform"))
parser.add_argument("--waveform-width", type=int, default=80, help=_("Waveform width (default: 80)"))
parser.add_argument("--waveform-height", type=int, default=16, help=_("Waveform height (default: 16)"))
parser.add_argument("--waveform-mode", choices=["rms", "peak"], default="peak", help=_("Waveform mode (default: peak)"))
parser.add_argument("--recursive", "-r", action="store_true", help=_("Include subdirectories (batch)"))
parser = subparsers.add_parser("analyze", help="Audio analysis (RMS, spectral entropy, silence detection, etc.)")
parser.add_argument("input", help="Input audio file or directory")
parser.add_argument("--frames", action="store_true", help="Per-frame detailed output")
parser.add_argument("--frame-size", type=int, default=2048, help="Frame size (default: 2048)")
parser.add_argument("--hop", type=int, default=512, help="Hop size (default: 512)")
parser.add_argument("--silence-threshold", type=float, default=-40.0, help="Silence detection threshold dB (default: -40)")
parser.add_argument("--waveform", "-w", action="store_true", help="Show ASCII waveform")
parser.add_argument("--waveform-width", type=int, default=80, help="Waveform width (default: 80)")
parser.add_argument("--waveform-height", type=int, default=16, help="Waveform height (default: 16)")
parser.add_argument("--waveform-mode", choices=["rms", "peak"], default="peak", help="Waveform mode (default: peak)")
parser.add_argument("--recursive", "-r", action="store_true", help="Include subdirectories (batch)")
parser.add_argument("--spectrum", action="store_true",
help=_("Add long-term FFT diagnostics (band energy, dominant frequencies, hum, hf slope)"))
help="Add long-term FFT diagnostics (band energy, dominant frequencies, hum, hf slope)")
parser.add_argument("--spectrum-fft", type=int, default=16384,
help=_("FFT size for spectrum diagnostics (default: 16384)"))
help="FFT size for spectrum diagnostics (default: 16384)")
parser.add_argument("--spectrum-min-rms", type=float, default=0.01,
help=_("Skip frames below this RMS when averaging spectrum (default: 0.01)"))
help="Skip frames below this RMS when averaging spectrum (default: 0.01)")
parser.set_defaults(func=run)


Expand All @@ -45,17 +50,26 @@ def _analyze_file(
) -> dict:
audio, sr = read_audio(path)
stats = get_audio_stats(audio, sr)
audio_length = audio.shape[-1] if audio.ndim == 2 else audio.shape[0]

metrics = compute_frame_metrics(audio, sr, frame_size=frame_size, hop_size=hop)
summary = compute_summary(metrics)
silence = detect_silence(audio, sr, threshold_db=silence_threshold)

# Findings: signal + (optional) spectral. LLM agent 후기 대응.
findings = detect_signal_findings(audio, sr, file=str(path))
findings.extend(silence_to_findings(silence, audio_length, sr, file=str(path)))

# LLM agent 후기 #3 대응: duration/total_samples를 명시적으로 보장.
# `frames`는 채널당 샘플 수, `total_samples`는 그 별칭(명시적 이름).
result = {
"file": str(path),
"sample_rate": sr,
"channels": stats.channels,
"duration": round(stats.duration, 4),
"duration_sec": round(stats.duration, 6),
"frames": stats.frames,
"total_samples": int(stats.frames),
"rms": round(stats.rms, 6),
"peak": round(stats.peak, 6),
"summary": summary,
Expand All @@ -64,9 +78,13 @@ def _analyze_file(
}

if spectrum:
result["spectrum"] = spectrum_diagnostics(
spec = spectrum_diagnostics(
audio, sr, fft_size=spectrum_fft, min_rms=spectrum_min_rms
)
result["spectrum"] = spec
findings.extend(spectrum_to_findings(spec, file=str(path)))

result["findings"] = [f.to_dict() for f in findings]

if frames_mode:
result["frame_metrics"] = {
Expand Down Expand Up @@ -124,7 +142,12 @@ def _run_single(args: argparse.Namespace, path: Path) -> None:
)

if args.json:
out = {"command": "analyze", **result}
out = {
"$schema": "audioman://schema/analyze.v1.json",
"audioman_version": __version__,
"command": "analyze",
**result,
}
if waveform_text:
out["ascii_waveform"] = waveform_text
out["ascii_envelope"] = envelope_text
Expand Down Expand Up @@ -206,7 +229,12 @@ def _run_batch(args: argparse.Namespace, input_dir: Path) -> None:
spectrum_min_rms=args.spectrum_min_rms,
)
if args.json:
print(json.dumps({"command": "analyze", **result}, ensure_ascii=False, default=str))
print(json.dumps({
"$schema": "audioman://schema/analyze.v1.json",
"audioman_version": __version__,
"command": "analyze",
**result,
}, ensure_ascii=False, default=str))
else:
output_console.print(
f" [{i+1}/{len(files)}] {fpath.name}: "
Expand Down
49 changes: 42 additions & 7 deletions src/audioman/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,46 @@

import argparse
import logging
import os
import sys

from audioman import __version__
from audioman.cli import scan, list_cmd, info, process, chain, preset, dump, analyze, fx, visualize, doctor, eq_profile, bounce, commit_cmd, mixdown, edl as edl_cli, master as master_cli, fader_test as fader_test_cli, fader_compare as fader_compare_cli, voiceover as voiceover_cli, screen as screen_cli, obs as obs_cli
from audioman.i18n import _

def _early_plain_detect(argv: list[str] | None) -> bool:
"""parse_args 전에 --plain / AUDIOMAN_PLAIN을 감지.

i18n._detect_lang()이 import 시점에 호출될 수 있으므로
env를 먼저 세팅해야 한국어 카탈로그가 활성화되지 않는다.
"""
args = list(argv) if argv is not None else sys.argv[1:]
if "--plain" in args:
os.environ["AUDIOMAN_PLAIN"] = "1"
return True
val = os.environ.get("AUDIOMAN_PLAIN", "").strip().lower()
return val in ("1", "true", "yes", "on")


_PLAIN_EARLY = _early_plain_detect(None)

from audioman import __version__ # noqa: E402
from audioman.cli import scan, list_cmd, info, process, chain, preset, dump, analyze, fx, visualize, doctor, eq_profile, bounce, commit_cmd, mixdown, edl as edl_cli, master as master_cli, fader_test as fader_test_cli, fader_compare as fader_compare_cli, voiceover as voiceover_cli, screen as screen_cli, obs as obs_cli, observe as observe_cli, changelog_cmd, schemas_cmd, stream as stream_cli # noqa: E402
from audioman.cli.output import set_plain # noqa: E402


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="audioman",
description=_("Cross-platform CLI wrapper for VST3/AU audio plugins"),
description="Cross-platform CLI wrapper for VST3/AU audio plugins",
)
parser.add_argument("--version", action="version", version=f"audioman {__version__}")
parser.add_argument("--json", action="store_true", help=_("JSON output mode"))
parser.add_argument("--verbose", "-v", action="store_true", help=_("Verbose logging"))
parser.add_argument("--json", action="store_true", help="JSON output mode")
parser.add_argument(
"--plain",
action="store_true",
help="LLM-friendly output: no color, no rich tables, English help (also via AUDIOMAN_PLAIN=1)",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")

subparsers = parser.add_subparsers(dest="command", help=_("Available commands"))
subparsers = parser.add_subparsers(dest="command", help="Available commands")

scan.add_parser(subparsers)
list_cmd.add_parser(subparsers)
Expand All @@ -43,14 +66,26 @@ def build_parser() -> argparse.ArgumentParser:
voiceover_cli.add_parser(subparsers)
screen_cli.add_parser(subparsers)
obs_cli.add_parser(subparsers)
observe_cli.add_parser(subparsers)
changelog_cmd.add_parser(subparsers)
schemas_cmd.add_parser(subparsers)
stream_cli.add_parser(subparsers)

return parser


def main(argv: list[str] | None = None) -> None:
# parse 전에 --plain 재감지 (argv가 명시적으로 전달된 경우)
plain = _early_plain_detect(argv) or _PLAIN_EARLY
if plain:
set_plain(True)

parser = build_parser()
args = parser.parse_args(argv)

if getattr(args, "plain", False):
set_plain(True)

if args.verbose:
logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s")

Expand Down
17 changes: 8 additions & 9 deletions src/audioman/cli/bounce.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,26 @@
import argparse

from audioman.cli.output import print_error, print_json, print_success, print_warning, output_console
from audioman.i18n import _


def add_parser(subparsers: argparse._SubParsersAction) -> None:
parser = subparsers.add_parser("bounce", help=_("Bounce multiple tracks into a single stereo file"))
parser.add_argument("inputs", nargs="*", help=_("Input audio files"))
parser.add_argument("--output", "-o", required=True, help=_("Output file path"))
parser = subparsers.add_parser("bounce", help="Bounce multiple tracks into a single stereo file")
parser.add_argument("inputs", nargs="*", help="Input audio files")
parser.add_argument("--output", "-o", required=True, help="Output file path")
parser.add_argument(
"--gain", default="",
help=_("Comma-separated gain values in dB per track (e.g. '0,-3,-6')"),
help="Comma-separated gain values in dB per track (e.g. '0,-3,-6')",
)
parser.add_argument(
"--pan", default="",
help=_("Comma-separated pan values per track (-1.0 L ~ 0.0 C ~ 1.0 R, e.g. '0,-0.5,0.5')"),
help="Comma-separated pan values per track (-1.0 L ~ 0.0 C ~ 1.0 R, e.g. '0,-0.5,0.5')",
)
parser.add_argument(
"--chain", default="",
help=_("Per-track plugin chains separated by '|' (e.g. 'denoise|limiter:threshold=-1|')"),
help="Per-track plugin chains separated by '|' (e.g. 'denoise|limiter:threshold=-1|')",
)
parser.add_argument("--session", help=_("Session file (YAML/JSON) — overrides other track options"))
parser.add_argument("--dry-run", action="store_true", help=_("Show plan without executing"))
parser.add_argument("--session", help="Session file (YAML/JSON) — overrides other track options")
parser.add_argument("--dry-run", action="store_true", help="Show plan without executing")
parser.set_defaults(func=run)


Expand Down
Loading