phonipy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phonipy/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """PhoniPy: voice note stability and harmonic purity, offline and reproducible."""
2
+
3
+ from .analysis import analyze_file, analyze_samples, frequency_to_key, key_to_frequency
4
+ from .audio import AudioError
5
+ from .batch import BatchResult, analyze_directory, parser
6
+ from .config import AnalysisConfig
7
+ from .metrics import compute_metrics
8
+ from .models import AnalysisResult, Frame, Metrics, Segment
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = [
12
+ "AnalysisConfig",
13
+ "AnalysisResult",
14
+ "AudioError",
15
+ "BatchResult",
16
+ "Frame",
17
+ "Metrics",
18
+ "Segment",
19
+ "analyze_directory",
20
+ "analyze_file",
21
+ "analyze_samples",
22
+ "compute_metrics",
23
+ "frequency_to_key",
24
+ "key_to_frequency",
25
+ "parser",
26
+ ]
phonipy/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
phonipy/analysis.py ADDED
@@ -0,0 +1,200 @@
1
+ """Deterministic short-window harmonic detector and consecutive-note segmentation."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ from scipy.fft import irfft, next_fast_len, rfft, rfftfreq
7
+ from scipy.signal import find_peaks
8
+
9
+ from .audio import load_audio, prepare_audio
10
+ from .config import AnalysisConfig
11
+ from .metrics import compute_metrics
12
+ from .models import AnalysisResult, Frame, Segment
13
+
14
+
15
+ def frequency_to_key(frequency):
16
+ """Fractional MIDI note number, A4=69. Piano-key numbering equals MIDI minus 20."""
17
+ value = np.asarray(frequency, dtype=float)
18
+ if np.any(~np.isfinite(value)) or np.any(value <= 0):
19
+ raise ValueError("Frequency must be positive and finite")
20
+ answer = 69 + 12 * np.log2(value / 440)
21
+ return float(answer) if answer.ndim == 0 else answer
22
+
23
+
24
+ def key_to_frequency(key):
25
+ value = np.asarray(key, dtype=float)
26
+ if np.any(~np.isfinite(value)):
27
+ raise ValueError("Key must be finite")
28
+ with np.errstate(over="ignore", under="ignore"):
29
+ answer = 440 * 2 ** ((value - 69) / 12)
30
+ if np.any(~np.isfinite(answer)) or np.any(answer <= 0):
31
+ raise ValueError("Key is outside the representable frequency range")
32
+ return float(answer) if answer.ndim == 0 else answer
33
+
34
+
35
+ class _Detector:
36
+ def __init__(self, config):
37
+ self.config = config
38
+ self.size = round(config.window_ms * config.sample_rate / 1000)
39
+ self.window = np.hanning(self.size)
40
+ self.nfft = next_fast_len(4 * self.size)
41
+ self.frequencies = rfftfreq(self.nfft, 1 / config.sample_rate)
42
+ self.min_lag = max(1, math.ceil(config.sample_rate / config.fmax))
43
+ self.max_lag = min(self.size - 2, math.floor(config.sample_rate / config.fmin))
44
+ # Original window, not zero-padded FFT, sets physical spectral resolution.
45
+ self.bandwidth = max(config.harmonic_bandwidth_hz, 1.5 * config.sample_rate / self.size)
46
+
47
+ def detect(self, frame):
48
+ cfg = self.config
49
+ centered = frame - np.mean(frame)
50
+ rms = float(np.sqrt(np.mean(centered**2)))
51
+ rms_db = max(-300.0, 20 * math.log10(max(rms, 1e-15)))
52
+ if rms_db < cfg.min_rms_db:
53
+ return None, None, None, 0.0, 0.0, rms_db, False
54
+ # Normalized square difference/autocorrelation supplies periodicity candidates.
55
+ spectrum = rfft(centered, self.nfft)
56
+ acf = irfft(np.abs(spectrum) ** 2, self.nfft)[: self.size]
57
+ energy = np.concatenate(([0.0], np.cumsum(centered**2)))
58
+ lags = np.arange(self.size)
59
+ denominator = energy[self.size - lags] + energy[-1] - energy[lags]
60
+ nsdf = np.divide(2 * acf, denominator, out=np.zeros_like(acf), where=denominator > 1e-20)
61
+ peaks, _ = find_peaks(nsdf[self.min_lag - 1 : self.max_lag + 2])
62
+ peaks = peaks + self.min_lag - 1
63
+ peaks = peaks[(peaks >= self.min_lag) & (peaks <= self.max_lag)]
64
+ if not len(peaks):
65
+ return None, None, None, 0.0, 0.0, rms_db, False
66
+ best = float(np.max(nsdf[peaks]))
67
+ if best < cfg.min_periodicity:
68
+ return None, None, None, 0.0, max(0.0, best), rms_db, False
69
+ candidates = peaks[nsdf[peaks] >= max(cfg.min_periodicity, 0.9 * best)]
70
+ # Earliest strong periodicity peak avoids selecting integer period multiples.
71
+ lag = int(candidates[0])
72
+ a, b, c = nsdf[lag - 1 : lag + 2]
73
+ divisor = a - 2 * b + c
74
+ adjustment = 0.5 * (a - c) / divisor if abs(divisor) > 1e-15 else 0.0
75
+ frequency = float(cfg.sample_rate / (lag + np.clip(adjustment, -0.5, 0.5)))
76
+ power = np.abs(rfft(centered * self.window, self.nfft)) ** 2
77
+ power[1:-1] *= 2 # one-sided spectral power
78
+ total = float(np.sum(power))
79
+ if total <= 1e-20:
80
+ return None, None, None, 0.0, best, rms_db, False
81
+ mask = np.zeros(len(power), dtype=bool)
82
+ width = min(self.bandwidth, 0.35 * frequency)
83
+ for harmonic in range(1, cfg.harmonics + 1):
84
+ target = harmonic * frequency
85
+ if target >= cfg.sample_rate / 2:
86
+ break
87
+ left = np.searchsorted(self.frequencies, target - width)
88
+ right = np.searchsorted(self.frequencies, target + width, side="right")
89
+ mask[left:right] = True
90
+ ratio = float(np.clip(np.sum(power[mask]) / total, 0, 1))
91
+ band = power[(self.frequencies >= cfg.fmin) & (self.frequencies <= cfg.sample_rate / 2)]
92
+ flatness = float(np.exp(np.mean(np.log(band + 1e-30))) / (np.mean(band) + 1e-30))
93
+ fractional = frequency_to_key(frequency)
94
+ key = int(np.floor(fractional + 0.5))
95
+ accepted = (
96
+ cfg.fmin <= frequency <= cfg.fmax
97
+ and ratio >= cfg.min_energy_ratio
98
+ and flatness <= cfg.max_flatness
99
+ )
100
+ return frequency, key, fractional, ratio, min(1.0, best), rms_db, bool(accepted)
101
+
102
+
103
+ def segment_frames(frames, min_frames=8):
104
+ """Backdate accepted runs to their first frame; invalid frames always end a run."""
105
+ if isinstance(min_frames, bool) or not isinstance(min_frames, int) or min_frames < 1:
106
+ raise ValueError("min_frames must be a positive integer")
107
+ segments = []
108
+ run = []
109
+
110
+ def close():
111
+ if len(run) >= min_frames:
112
+ duration = run[-1].end - run[0].start
113
+ weights = np.array([f.end - f.start for f in run])
114
+ segments.append(
115
+ Segment(
116
+ run[0].key,
117
+ run[0].start,
118
+ run[-1].end,
119
+ duration,
120
+ float(np.average([f.energy_ratio for f in run], weights=weights)),
121
+ float(np.average([f.fractional_key for f in run], weights=weights)),
122
+ len(run),
123
+ )
124
+ )
125
+
126
+ for frame in frames:
127
+ if run and (
128
+ not frame.accepted
129
+ or frame.key != run[-1].key
130
+ or not math.isclose(frame.start, run[-1].end, abs_tol=1e-8)
131
+ ):
132
+ close()
133
+ run = []
134
+ if frame.accepted:
135
+ run.append(frame)
136
+ close()
137
+ return segments
138
+
139
+
140
+ def _analyze(prepared, config, source):
141
+ samples, duration, warnings, metadata = prepared
142
+ detector = _Detector(config)
143
+ hop = round(config.sample_rate * config.hop_ms / 1000)
144
+ frames = []
145
+ for start in range(0, len(samples), hop):
146
+ time = start / config.sample_rate
147
+ if time >= duration:
148
+ break
149
+ chunk = samples[start : start + detector.size]
150
+ if len(chunk) < detector.size:
151
+ chunk = np.pad(chunk, (0, detector.size - len(chunk)))
152
+ frequency, key, fractional, ratio, periodicity, rms_db, accepted = detector.detect(chunk)
153
+ frames.append(
154
+ Frame(
155
+ time,
156
+ min((start + hop) / config.sample_rate, duration),
157
+ frequency,
158
+ key,
159
+ fractional,
160
+ ratio,
161
+ periodicity,
162
+ rms_db,
163
+ accepted,
164
+ )
165
+ )
166
+ segments = segment_frames(frames, config.min_frames)
167
+ if not segments:
168
+ warnings.append("No stable detections; means and hoarseness are undefined (null)")
169
+ metadata.update(
170
+ {
171
+ "version": "0.1.0",
172
+ "detector": "nsdf-harmonic-v1",
173
+ "effective_hop_ms": 1000 * hop / config.sample_rate,
174
+ "effective_window_ms": 1000 * detector.size / config.sample_rate,
175
+ "nominal_minimum_run_ms": 1000 * hop * config.min_frames / config.sample_rate,
176
+ "key_numbering": "MIDI: A4=69; piano-key index = MIDI-20",
177
+ "frame_count": len(frames),
178
+ }
179
+ )
180
+ return AnalysisResult(
181
+ source,
182
+ compute_metrics(segments, duration, config.reference_key),
183
+ segments,
184
+ frames,
185
+ config.to_dict(),
186
+ metadata,
187
+ warnings,
188
+ )
189
+
190
+
191
+ def analyze_samples(samples, sample_rate, config=None, *, source="<array>"):
192
+ """Analyze mono/stereo PCM arrays. Integer PCM is scaled to floating PCM units."""
193
+ config = config or AnalysisConfig()
194
+ return _analyze(prepare_audio(samples, sample_rate, config), config, source)
195
+
196
+
197
+ def analyze_file(path, config=None):
198
+ """Decode a supported audio file and return all metrics, segments and frames."""
199
+ config = config or AnalysisConfig()
200
+ return _analyze(load_audio(path, config), config, str(path))
phonipy/app.py ADDED
@@ -0,0 +1,92 @@
1
+ """Run with: streamlit run <installed phonipy/app.py path>."""
2
+
3
+ import io
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ import matplotlib.pyplot as plt
8
+ import pandas as pd
9
+ import soundfile as sf
10
+ import streamlit as st
11
+
12
+ from phonipy import AnalysisConfig, analyze_samples
13
+
14
+
15
+ def main():
16
+ st.set_page_config(page_title="PhoniPy", page_icon="🎵", layout="wide")
17
+ st.title("PhoniPy")
18
+ st.caption("Voice stability and harmonic purity · research software")
19
+ with st.sidebar:
20
+ st.header("Analysis settings")
21
+ preset = st.selectbox("Voice range / task", ["general", "low", "high", "speech", "singing"])
22
+ reference = st.number_input("Reference note (MIDI; A4 = 69)", value=69.0)
23
+ minimum = st.number_input("Minimum consecutive frames", min_value=1, value=8)
24
+ periodicity = st.slider("Minimum periodicity", 0.0, 1.0, 0.65)
25
+ config = AnalysisConfig.preset(
26
+ preset,
27
+ reference_key=reference,
28
+ min_frames=int(minimum),
29
+ min_periodicity=periodicity,
30
+ max_duration=300.0,
31
+ )
32
+ st.download_button(
33
+ "Download settings",
34
+ json.dumps(config.to_dict(), indent=2),
35
+ "phonipy-config.json",
36
+ "application/json",
37
+ )
38
+ st.write("Upload solo voice recordings. Start with a sustained vowel or a single-note hold.")
39
+ st.info(
40
+ "These experimental measurements are not a diagnosis. Speech and intentional "
41
+ "note changes can naturally produce fragmented stable-note detections."
42
+ )
43
+ uploads = st.file_uploader(
44
+ "Audio files (up to 5 minutes each)",
45
+ type=["wav", "flac", "ogg", "aiff", "mp3"],
46
+ accept_multiple_files=True,
47
+ )
48
+ if not st.button("Analyze recordings", type="primary", disabled=not uploads):
49
+ return
50
+ rows, payloads = [], []
51
+ progress = st.progress(0.0)
52
+ for index, upload in enumerate(uploads):
53
+ try:
54
+ if upload.size > 100 * 1024 * 1024:
55
+ raise ValueError("File exceeds the 100 MB dashboard limit")
56
+ with sf.SoundFile(io.BytesIO(upload.getvalue())) as handle:
57
+ if handle.frames / handle.samplerate > config.max_duration:
58
+ raise ValueError("Recording exceeds the five-minute dashboard limit")
59
+ rate = handle.samplerate
60
+ samples = handle.read(always_2d=True)
61
+ result = analyze_samples(samples, rate, config, source=upload.name)
62
+ rows.append({"file": upload.name, **asdict(result.metrics)})
63
+ payloads.append(result.to_dict(include_frames=True))
64
+ with st.expander(upload.name, expanded=len(uploads) == 1):
65
+ st.audio(upload.getvalue())
66
+ for warning in result.warnings:
67
+ st.warning(warning)
68
+ figure = result.plot()
69
+ st.pyplot(figure)
70
+ plt.close(figure)
71
+ st.dataframe(pd.DataFrame([asdict(s) for s in result.segments]))
72
+ except (ValueError, OSError, RuntimeError) as exc:
73
+ st.error(f"{upload.name}: {exc}")
74
+ payloads.append({"source": upload.name, "error": str(exc)})
75
+ progress.progress((index + 1) / len(uploads))
76
+ if rows:
77
+ table = pd.DataFrame(rows)
78
+ st.subheader("Recording metrics")
79
+ st.dataframe(table, hide_index=True)
80
+ st.download_button(
81
+ "Download metrics CSV", table.to_csv(index=False), "phonipy-metrics.csv", "text/csv"
82
+ )
83
+ st.download_button(
84
+ "Download full results JSON",
85
+ json.dumps(payloads, allow_nan=False),
86
+ "phonipy-results.json",
87
+ "application/json",
88
+ )
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
phonipy/audio.py ADDED
@@ -0,0 +1,106 @@
1
+ """Audio decoding and explicit input quality handling."""
2
+
3
+ from math import gcd
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import soundfile as sf
8
+ from scipy.signal import resample_poly
9
+
10
+ from .config import AnalysisConfig
11
+
12
+
13
+ class AudioError(ValueError):
14
+ """A recording cannot be decoded or analyzed safely."""
15
+
16
+
17
+ def prepare_audio(samples, sample_rate, config: AnalysisConfig):
18
+ if (
19
+ isinstance(sample_rate, bool)
20
+ or not isinstance(sample_rate, (int, np.integer))
21
+ or not 1000 <= sample_rate <= 384000
22
+ ):
23
+ raise AudioError("sample_rate must be an integer between 1000 and 384000 Hz")
24
+ data = np.asarray(samples)
25
+ if data.ndim not in (1, 2) or data.size == 0 or (data.ndim == 2 and data.shape[1] == 0):
26
+ raise AudioError("Audio must be a nonempty samples or samples-by-channels array")
27
+ if data.dtype.kind not in "fiu":
28
+ raise AudioError("Audio samples must be real numeric values")
29
+ if len(data) / sample_rate > config.max_duration:
30
+ raise AudioError(f"Recording exceeds max_duration={config.max_duration} seconds")
31
+ if data.dtype.kind in "iu":
32
+ info = np.iinfo(data.dtype)
33
+ if data.dtype.kind == "u":
34
+ midpoint = (int(info.max) + 1) / 2
35
+ data = (data.astype(np.float64) - midpoint) / midpoint
36
+ else:
37
+ data = data.astype(np.float64) / max(abs(int(info.min)), int(info.max))
38
+ else:
39
+ data = data.astype(np.float64, copy=True)
40
+ warnings = []
41
+ if not np.isfinite(data).all():
42
+ if config.nonfinite == "error":
43
+ raise AudioError("Audio contains NaN or infinity; use nonfinite='zero' to repair")
44
+ data[~np.isfinite(data)] = 0
45
+ warnings.append("Nonfinite samples replaced with zero by explicit configuration")
46
+ if np.max(np.abs(data)) > 8:
47
+ raise AudioError("Floating audio must use normalized PCM units (approximately -1 to 1)")
48
+ if np.any(np.abs(data) >= 0.999):
49
+ warnings.append("Samples approach/exceed full scale; clipping may affect measurements")
50
+ channels = 1 if data.ndim == 1 else data.shape[1]
51
+ selected = None
52
+ if data.ndim == 2:
53
+ if config.channel == "mix":
54
+ data = data.mean(axis=1)
55
+ else:
56
+ selected = (
57
+ int(np.argmax(np.mean(data**2, axis=0)))
58
+ if config.channel == "loudest"
59
+ else int(config.channel == "right")
60
+ )
61
+ if selected >= channels:
62
+ raise AudioError("Requested right channel is absent")
63
+ data = data[:, selected]
64
+ if channels > 1:
65
+ warnings.append(f"Multichannel input: {config.channel} channel policy applied")
66
+ elif config.channel == "right":
67
+ raise AudioError("Requested right channel is absent")
68
+ duration = len(data) / sample_rate
69
+ if abs(np.mean(data)) > 0.01:
70
+ warnings.append("DC offset detected; each analysis frame is mean-centered")
71
+ if sample_rate / 2 <= config.fmax:
72
+ raise AudioError("Input Nyquist frequency must exceed configured fmax")
73
+ if sample_rate != config.sample_rate:
74
+ divisor = gcd(int(sample_rate), config.sample_rate)
75
+ data = resample_poly(data, config.sample_rate // divisor, int(sample_rate) // divisor)
76
+ if len(data) < round(config.window_ms * config.sample_rate / 1000):
77
+ warnings.append("Input is shorter than one analysis window; zero-padding applied")
78
+ return (
79
+ data,
80
+ duration,
81
+ warnings,
82
+ {
83
+ "original_sample_rate": int(sample_rate),
84
+ "analysis_sample_rate": config.sample_rate,
85
+ "channels": channels,
86
+ "selected_channel": selected,
87
+ "sample_count": len(samples),
88
+ },
89
+ )
90
+
91
+
92
+ def load_audio(path, config):
93
+ path = Path(path)
94
+ if not path.is_file():
95
+ raise AudioError(f"Audio file does not exist: {path}")
96
+ try:
97
+ with sf.SoundFile(path) as handle:
98
+ if handle.frames / handle.samplerate > config.max_duration:
99
+ raise AudioError(f"Recording exceeds max_duration={config.max_duration} seconds")
100
+ samples = handle.read(dtype="float64", always_2d=True)
101
+ rate = handle.samplerate
102
+ except AudioError:
103
+ raise
104
+ except (sf.LibsndfileError, OSError, ValueError) as exc:
105
+ raise AudioError(f"Cannot decode {path.name}: {exc}") from exc
106
+ return prepare_audio(samples, rate, config)
phonipy/batch.py ADDED
@@ -0,0 +1,158 @@
1
+ """Batch processing in the spirit of midipy, with per-file error isolation."""
2
+
3
+ import csv
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from dataclasses import asdict, dataclass, fields
8
+ from pathlib import Path
9
+
10
+ from .analysis import analyze_file
11
+ from .config import AnalysisConfig
12
+ from .models import AnalysisResult, Metrics
13
+
14
+ EXTENSIONS = {".wav", ".flac", ".ogg", ".aiff", ".aif", ".mp3", ".au", ".snd"}
15
+
16
+
17
+ @dataclass
18
+ class BatchResult:
19
+ results: list[AnalysisResult]
20
+ errors: list[dict]
21
+ config: dict
22
+
23
+ @property
24
+ def rows(self):
25
+ return [
26
+ {
27
+ "source": r.source,
28
+ "status": "ok",
29
+ **asdict(r.metrics),
30
+ "warnings": " | ".join(r.warnings),
31
+ }
32
+ for r in self.results
33
+ ]
34
+
35
+ def save(self, path):
36
+ """Write an atomic summary; CSV/XLSX also receive a structured error sidecar."""
37
+ path = Path(path)
38
+ suffix = path.suffix.lower()
39
+ if suffix not in (".json", ".csv", ".xlsx"):
40
+ raise ValueError("Output extension must be .json, .csv or .xlsx")
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, suffix=suffix)
43
+ os.close(descriptor)
44
+ temporary = Path(temporary)
45
+ try:
46
+ if suffix == ".json":
47
+ temporary.write_text(
48
+ json.dumps(
49
+ {
50
+ "config": self.config,
51
+ "results": [r.to_dict() for r in self.results],
52
+ "errors": self.errors,
53
+ },
54
+ indent=2,
55
+ allow_nan=False,
56
+ )
57
+ + "\n",
58
+ encoding="utf-8",
59
+ )
60
+ else:
61
+ columns = [
62
+ "source",
63
+ "status",
64
+ *[f.name for f in fields(Metrics)],
65
+ "warnings",
66
+ "error",
67
+ ]
68
+ rows = self.rows + [
69
+ {"source": e["source"], "status": "error", "error": e["error"]}
70
+ for e in self.errors
71
+ ]
72
+ if suffix == ".csv":
73
+ with temporary.open("w", newline="", encoding="utf-8") as handle:
74
+ writer = csv.DictWriter(handle, fieldnames=columns)
75
+ writer.writeheader()
76
+ writer.writerows(rows)
77
+ else:
78
+ try:
79
+ from openpyxl import Workbook
80
+ except ImportError as exc:
81
+ raise ImportError("Install phonipy[excel] for XLSX output") from exc
82
+ book = Workbook()
83
+ sheet = book.active
84
+ sheet.title = "Metrics"
85
+ sheet.append(columns)
86
+ for row in rows:
87
+ # Force strings to text to avoid interpreting source filenames as formulas.
88
+ sheet.append([row.get(c) for c in columns])
89
+ for cell in sheet[sheet.max_row]:
90
+ if isinstance(cell.value, str):
91
+ cell.data_type = "s"
92
+ sheet.freeze_panes = "A2"
93
+ book.save(temporary)
94
+ sidecar = path.with_suffix(path.suffix + ".metadata.json")
95
+ sidecar.write_text(
96
+ json.dumps(
97
+ {
98
+ "config": self.config,
99
+ "errors": self.errors,
100
+ "recordings": [
101
+ {"source": r.source, "metadata": r.metadata, "warnings": r.warnings}
102
+ for r in self.results
103
+ ],
104
+ },
105
+ indent=2,
106
+ allow_nan=False,
107
+ )
108
+ + "\n",
109
+ encoding="utf-8",
110
+ )
111
+ os.replace(temporary, path)
112
+ finally:
113
+ temporary.unlink(missing_ok=True)
114
+ return path
115
+
116
+
117
+ def analyze_directory(source, config=None, *, recursive=False, strict=False, progress=None):
118
+ config = config or AnalysisConfig()
119
+ directory = Path(source)
120
+ if not directory.is_dir():
121
+ raise ValueError(f"Input directory does not exist: {directory}")
122
+ paths = sorted(
123
+ p
124
+ for p in (directory.rglob("*") if recursive else directory.iterdir())
125
+ if p.is_file() and p.suffix.lower() in EXTENSIONS
126
+ )
127
+ if not paths:
128
+ raise ValueError(f"No supported audio files found in {directory}")
129
+ results, errors = [], []
130
+ for index, path in enumerate(paths):
131
+ try:
132
+ results.append(analyze_file(path, config))
133
+ except (ValueError, OSError, RuntimeError) as exc:
134
+ if strict:
135
+ raise
136
+ errors.append({"source": str(path), "type": type(exc).__name__, "error": str(exc)})
137
+ if progress:
138
+ progress(index + 1, len(paths), str(path))
139
+ return BatchResult(results, errors, config.to_dict())
140
+
141
+
142
+ def parser(source, metrics=None, output_format="csv", save_path="Output", **kwargs):
143
+ """midipy-style convenience API; returns a DataFrame (requires pandas)."""
144
+ import pandas as pd
145
+
146
+ result = analyze_directory(source, **kwargs)
147
+ table = pd.DataFrame(result.rows)
148
+ if metrics is not None and metrics != ["all"]:
149
+ unknown = set(metrics) - set(table.columns)
150
+ if unknown:
151
+ raise ValueError(f"Unknown metrics: {sorted(unknown)}")
152
+ table = table.loc[:, metrics]
153
+ if output_format not in ("csv", "json", "xlsx", "excel"):
154
+ raise ValueError("output_format must be csv, json, xlsx or excel")
155
+ extension = "xlsx" if output_format == "excel" else output_format
156
+ result.save(f"{save_path}.{extension}")
157
+ table.attrs["errors"] = result.errors
158
+ return table
phonipy/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """Command-line batch/single-file entry point. No network or telemetry."""
2
+
3
+ import argparse
4
+ import csv
5
+ import sys
6
+ from dataclasses import fields, replace
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .analysis import analyze_file
11
+ from .batch import BatchResult, analyze_directory
12
+ from .config import AnalysisConfig
13
+
14
+
15
+ def main(argv=None):
16
+ parser = argparse.ArgumentParser(description="PhoniPy voice stability analysis")
17
+ parser.add_argument("input", type=Path, help="Audio file or directory")
18
+ parser.add_argument("-o", "--output", type=Path, default=Path("phonipy-results.json"))
19
+ parser.add_argument("--config", type=Path, help="JSON configuration file")
20
+ parser.add_argument("--preset", choices=["general", "low", "high", "speech", "singing"])
21
+ parser.add_argument("--reference-key", type=float, help="Fractional MIDI note; A4=69")
22
+ parser.add_argument("--recursive", action="store_true")
23
+ parser.add_argument("--strict", action="store_true", help="Stop on the first failed file")
24
+ parser.add_argument("--details", type=Path, help="Directory for frame/segment JSON and CSV")
25
+ parser.add_argument("--plot", action="store_true", help="Save composite PNGs in --details")
26
+ parser.add_argument("--version", action="version", version=__version__)
27
+ args = parser.parse_args(argv)
28
+ if args.config and args.preset:
29
+ parser.error("Choose --config or --preset, not both")
30
+ if args.plot and not args.details:
31
+ parser.error("--plot requires --details DIR")
32
+ try:
33
+ config = (
34
+ AnalysisConfig.from_json(args.config)
35
+ if args.config
36
+ else AnalysisConfig.preset(args.preset or "general")
37
+ )
38
+ if args.reference_key is not None:
39
+ config = replace(config, reference_key=args.reference_key)
40
+ if args.input.is_dir():
41
+ batch = analyze_directory(
42
+ args.input, config, recursive=args.recursive, strict=args.strict
43
+ )
44
+ else:
45
+ batch = BatchResult([analyze_file(args.input, config)], [], config.to_dict())
46
+ batch.save(args.output)
47
+ if args.details:
48
+ args.details.mkdir(parents=True, exist_ok=True)
49
+ for index, result in enumerate(batch.results):
50
+ # Numeric prefix prevents collisions between equal filenames in subfolders.
51
+ name = f"{index:05d}-{Path(result.source).stem}"
52
+ result.to_json(args.details / f"{name}.json", include_frames=True)
53
+ for label in ("frames", "segments"):
54
+ records = getattr(result, label)
55
+ if records:
56
+ from dataclasses import asdict
57
+
58
+ with (args.details / f"{name}-{label}.csv").open(
59
+ "w", newline="", encoding="utf-8"
60
+ ) as handle:
61
+ writer = csv.DictWriter(
62
+ handle, fieldnames=[f.name for f in fields(records[0])]
63
+ )
64
+ writer.writeheader()
65
+ writer.writerows(asdict(record) for record in records)
66
+ if args.plot:
67
+ import matplotlib.pyplot as plt
68
+
69
+ figure = result.plot(args.details / f"{name}.png")
70
+ plt.close(figure)
71
+ print(
72
+ f"Analyzed {len(batch.results)} file(s); {len(batch.errors)} error(s). "
73
+ f"Saved {args.output}"
74
+ )
75
+ return 1 if batch.errors else 0
76
+ except (ValueError, OSError, RuntimeError, ImportError, TypeError) as exc:
77
+ print(f"phonipy: {exc}", file=sys.stderr)
78
+ return 2
phonipy/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Explicit, serializable detector settings. Note indices use MIDI numbering (A4=69)."""
2
+
3
+ import json
4
+ import math
5
+ from dataclasses import asdict, dataclass, replace
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AnalysisConfig:
11
+ sample_rate: int = 16000
12
+ hop_ms: float = 17.25
13
+ window_ms: float = 69.0
14
+ min_frames: int = 8
15
+ fmin: float = 65.0
16
+ fmax: float = 1100.0
17
+ reference_key: float = 69.0
18
+ harmonics: int = 12
19
+ min_rms_db: float = -60.0
20
+ min_periodicity: float = 0.65
21
+ min_energy_ratio: float = 0.15
22
+ max_flatness: float = 0.6
23
+ harmonic_bandwidth_hz: float = 22.0
24
+ channel: str = "loudest"
25
+ nonfinite: str = "error"
26
+ max_duration: float = 3600.0
27
+
28
+ def __post_init__(self):
29
+ for name in ("sample_rate", "min_frames", "harmonics"):
30
+ value = getattr(self, name)
31
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
32
+ raise ValueError(f"{name} must be a positive integer")
33
+ for name, value in asdict(self).items():
34
+ if isinstance(value, (float, int)) and not math.isfinite(value):
35
+ raise ValueError(f"{name} must be finite")
36
+ if not 4000 <= self.sample_rate <= 192000:
37
+ raise ValueError("sample_rate must be between 4000 and 192000 Hz")
38
+ if not 0 < self.hop_ms <= self.window_ms <= 500:
39
+ raise ValueError("Require 0 < hop_ms <= window_ms <= 500")
40
+ if round(self.hop_ms * self.sample_rate / 1000) < 1:
41
+ raise ValueError("hop_ms is shorter than one sample")
42
+ if not 20 <= self.fmin < self.fmax < self.sample_rate / 2:
43
+ raise ValueError("Require 20 <= fmin < fmax < Nyquist frequency")
44
+ if self.window_ms / 1000 * self.fmin < 2:
45
+ raise ValueError("window_ms must contain at least two cycles at fmin")
46
+ for name in ("min_periodicity", "min_energy_ratio", "max_flatness"):
47
+ if not 0 <= getattr(self, name) <= 1:
48
+ raise ValueError(f"{name} must be between 0 and 1")
49
+ if not -160 <= self.min_rms_db <= 0:
50
+ raise ValueError("min_rms_db must be between -160 and 0 dBFS")
51
+ if self.harmonic_bandwidth_hz <= 0 or self.max_duration <= 0:
52
+ raise ValueError("Bandwidth and max_duration must be positive")
53
+ if self.harmonics > 128:
54
+ raise ValueError("harmonics must not exceed 128")
55
+ if self.channel not in ("loudest", "mix", "left", "right"):
56
+ raise ValueError("channel must be loudest, mix, left or right")
57
+ if self.nonfinite not in ("error", "zero"):
58
+ raise ValueError("nonfinite must be error or zero")
59
+
60
+ def to_dict(self):
61
+ return asdict(self)
62
+
63
+ @classmethod
64
+ def from_json(cls, path):
65
+ """Reject unknown fields and invalid values instead of guessing."""
66
+ return cls(**json.loads(Path(path).read_text(encoding="utf-8")))
67
+
68
+ @classmethod
69
+ def preset(cls, name="general", **overrides):
70
+ """Task/range presets; ranges describe pitch, not a person's sex."""
71
+ presets = {
72
+ "general": {},
73
+ "low": {"fmin": 50.0, "fmax": 500.0, "window_ms": 86.25},
74
+ "high": {"fmin": 130.0, "fmax": 1400.0},
75
+ "speech": {"fmin": 65.0, "fmax": 600.0},
76
+ "singing": {"fmin": 50.0, "fmax": 1600.0, "window_ms": 86.25},
77
+ }
78
+ if name not in presets:
79
+ raise ValueError(f"Unknown preset {name!r}; choose {', '.join(presets)}")
80
+ return replace(cls(**presets[name]), **overrides)
phonipy/metrics.py ADDED
@@ -0,0 +1,56 @@
1
+ """The manuscript's ten global parameters, with undefined means represented by None."""
2
+
3
+ import math
4
+
5
+ from .models import Metrics, Segment
6
+
7
+
8
+ def compute_metrics(segments: list[Segment], raw_duration: float, reference_key=69.0):
9
+ if not math.isfinite(raw_duration) or raw_duration < 0:
10
+ raise ValueError("raw_duration must be finite and nonnegative")
11
+ if not math.isfinite(reference_key):
12
+ raise ValueError("reference_key must be finite")
13
+ previous_end = 0.0
14
+ for segment in segments:
15
+ values = (
16
+ segment.start,
17
+ segment.end,
18
+ segment.duration,
19
+ segment.mean_energy,
20
+ segment.mean_fractional_key,
21
+ )
22
+ if not all(math.isfinite(v) for v in values):
23
+ raise ValueError("Segment values must be finite")
24
+ if (
25
+ segment.start < previous_end - 1e-9
26
+ or segment.end > raw_duration + 1e-9
27
+ or segment.start < 0
28
+ or segment.duration <= 0
29
+ or not math.isclose(
30
+ segment.duration, segment.end - segment.start, rel_tol=1e-8, abs_tol=1e-9
31
+ )
32
+ or not 0 <= segment.mean_energy <= 1
33
+ ):
34
+ raise ValueError("Segments must be ordered, disjoint and inside the recording")
35
+ previous_end = segment.end
36
+ count = len(segments)
37
+ if count == 0:
38
+ return Metrics(raw_duration, 0.0, 0.0, None, None, None, None, 0.0, 0.0, 0)
39
+ valid = math.fsum(s.duration for s in segments)
40
+ active = segments[-1].end - segments[0].start
41
+ energy = math.fsum(s.duration * s.mean_energy for s in segments) / valid
42
+ average = valid / count
43
+ # Unweighted mean across detections, preserving fractional estimated pitch.
44
+ offset = math.fsum(s.mean_fractional_key - reference_key for s in segments) / count
45
+ return Metrics(
46
+ raw_duration,
47
+ active,
48
+ valid,
49
+ offset,
50
+ energy,
51
+ (1 - energy) * 100,
52
+ average,
53
+ average / valid,
54
+ min(1.0, valid / active),
55
+ count,
56
+ )
phonipy/models.py ADDED
@@ -0,0 +1,72 @@
1
+ """Plain data structures with JSON-safe, explicit missing measurements."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Frame:
10
+ start: float
11
+ end: float
12
+ frequency: float | None
13
+ key: int | None
14
+ fractional_key: float | None
15
+ energy_ratio: float
16
+ periodicity: float
17
+ rms_db: float
18
+ accepted: bool
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Segment:
23
+ key: int
24
+ start: float
25
+ end: float
26
+ duration: float
27
+ mean_energy: float
28
+ mean_fractional_key: float
29
+ frame_count: int
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Metrics:
34
+ raw_duration: float
35
+ active_time: float
36
+ valid_time: float
37
+ average_offset: float | None
38
+ average_energy: float | None
39
+ hoarseness: float | None
40
+ average_duration: float | None
41
+ continuity: float
42
+ sparsity: float
43
+ breakouts: int
44
+
45
+
46
+ @dataclass
47
+ class AnalysisResult:
48
+ source: str
49
+ metrics: Metrics
50
+ segments: list[Segment]
51
+ frames: list[Frame]
52
+ config: dict
53
+ metadata: dict
54
+ warnings: list[str] = field(default_factory=list)
55
+
56
+ def to_dict(self, include_frames=False):
57
+ result = asdict(self)
58
+ if not include_frames:
59
+ result.pop("frames")
60
+ return result
61
+
62
+ def to_json(self, path=None, include_frames=False):
63
+ value = json.dumps(self.to_dict(include_frames), indent=2, allow_nan=False)
64
+ if path is not None:
65
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
66
+ Path(path).write_text(value + "\n", encoding="utf-8")
67
+ return value
68
+
69
+ def plot(self, path=None):
70
+ from .plotting import composite_plot
71
+
72
+ return composite_plot(self, path)
phonipy/plotting.py ADDED
@@ -0,0 +1,50 @@
1
+ """The manuscript's composite harmonic-purity / stable-note plot."""
2
+
3
+
4
+ def composite_plot(result, path=None):
5
+ try:
6
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
7
+ from matplotlib.figure import Figure
8
+ except ImportError as exc:
9
+ raise ImportError("Install phonipy[plot] to generate composite plots") from exc
10
+ figure = Figure(figsize=(12, 4), layout="constrained")
11
+ FigureCanvasAgg(figure)
12
+ axis = figure.subplots()
13
+ axis.plot(
14
+ [f.start for f in result.frames],
15
+ [f.energy_ratio for f in result.frames],
16
+ color="0.45",
17
+ linewidth=0.8,
18
+ label="Harmonic energy ratio",
19
+ )
20
+ for segment in result.segments:
21
+ axis.axvspan(segment.start, segment.end, alpha=0.1, color="blue")
22
+ axis.scatter([segment.start], [segment.mean_energy], color="blue", s=20)
23
+ axis.annotate(
24
+ str(segment.key),
25
+ (segment.start, segment.mean_energy),
26
+ xytext=(2, 8),
27
+ textcoords="offset points",
28
+ fontsize=8,
29
+ color="blue",
30
+ )
31
+ axis.scatter([segment.end], [segment.mean_energy], color="red", s=20)
32
+ axis.annotate(
33
+ f"{segment.frame_count} frames",
34
+ (segment.end, segment.mean_energy),
35
+ xytext=(2, -14),
36
+ textcoords="offset points",
37
+ fontsize=7,
38
+ color="red",
39
+ )
40
+ axis.set(
41
+ xlabel="Time (seconds)",
42
+ ylabel="Harmonic-series / total energy",
43
+ ylim=(0, 1.1),
44
+ xlim=(0, max(result.metrics.raw_duration, 0.01)),
45
+ title="PhoniPy — stable notes (MIDI numbering) and harmonic purity",
46
+ )
47
+ axis.legend(loc="lower right")
48
+ if path:
49
+ figure.savefig(path, dpi=160)
50
+ return figure
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.5
2
+ Name: phonipy
3
+ Version: 0.1.0
4
+ Summary: Reproducible musical-note stability and harmonic-purity analysis of voice recordings
5
+ Project-URL: Homepage, https://github.com/udanish50/PhoniPy
6
+ Project-URL: Issues, https://github.com/udanish50/PhoniPy/issues
7
+ Author: Muhammad Umair Danish
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: audio,music-therapy,phonation,pitch,voice
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: scipy>=1.10
18
+ Requires-Dist: soundfile>=0.12
19
+ Provides-Extra: app
20
+ Requires-Dist: matplotlib>=3.7; extra == 'app'
21
+ Requires-Dist: pandas>=2; extra == 'app'
22
+ Requires-Dist: streamlit>=1.40; extra == 'app'
23
+ Provides-Extra: benchmark
24
+ Requires-Dist: certifi>=2026.10.19; extra == 'benchmark'
25
+ Requires-Dist: wfdb>=4.1; extra == 'benchmark'
26
+ Provides-Extra: excel
27
+ Requires-Dist: openpyxl>=3.1; extra == 'excel'
28
+ Provides-Extra: plot
29
+ Requires-Dist: matplotlib>=3.7; extra == 'plot'
30
+ Provides-Extra: test
31
+ Requires-Dist: build>=1.2; extra == 'test'
32
+ Requires-Dist: matplotlib>=3.7; extra == 'test'
33
+ Requires-Dist: openpyxl>=3.1; extra == 'test'
34
+ Requires-Dist: pytest-cov>=5; extra == 'test'
35
+ Requires-Dist: pytest>=8; extra == 'test'
36
+ Requires-Dist: ruff>=0.9; extra == 'test'
37
+ Requires-Dist: twine>=6; extra == 'test'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # PhoniPy
41
+
42
+ Reproducible musical-note stability and harmonic-purity analysis for solo voice recordings.
43
+ PhoniPy reads audio, detects stable note intervals, computes all ten parameters from
44
+ *Musical-Key Stability Analysis of Continuous Voice*, and exports recording summaries,
45
+ frame data, segment data, and composite plots.
46
+
47
+ **Research software, version 0.1.0.** The detector is an explicitly documented implementation
48
+ of the manuscript's framework, not a verified reproduction of its unavailable prototype.
49
+ The manuscript's clinical claims and thresholds are not validated by this package.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install phonipy
55
+ # Optional exports, plots, dashboard:
56
+ pip install 'phonipy[excel,plot,app]'
57
+ ```
58
+
59
+ Python 3.10 or newer. Core dependencies: NumPy, SciPy, and SoundFile/libsndfile.
60
+ WAV, FLAC, OGG, AIFF and other libsndfile-supported formats are accepted. MP3 support
61
+ depends on the installed libsndfile build; unsupported codecs produce a per-file error.
62
+ For compressed video/audio containers such as M4A, convert to PCM WAV first.
63
+
64
+ ## Python API
65
+
66
+ ```python
67
+ from phonipy import AnalysisConfig, analyze_file, analyze_directory
68
+
69
+ config = AnalysisConfig.preset("speech", reference_key=57) # A3, MIDI numbering
70
+ result = analyze_file("voice.wav", config)
71
+ print(result.metrics)
72
+ result.to_json("voice-results.json", include_frames=True)
73
+ result.plot("voice-composite.png") # requires phonipy[plot]
74
+
75
+ batch = analyze_directory("recordings", config, recursive=True)
76
+ batch.save("results.csv") # also JSON or XLSX; failures are retained
77
+ print(batch.errors)
78
+ ```
79
+
80
+ The package also accepts NumPy arrays with `analyze_samples(samples, sample_rate, config)`.
81
+ Arrays use samples-by-channels order; integer PCM is scaled automatically. Floating PCM
82
+ should be approximately within -1 to 1. No automatic gain normalization is performed.
83
+
84
+ ### Familiar midipy-style batch API
85
+
86
+ ```python
87
+ from phonipy import parser
88
+
89
+ table = parser(
90
+ "recordings",
91
+ metrics=["source", "valid_time", "average_energy", "breakouts"],
92
+ output_format="csv",
93
+ save_path="VoiceOutput",
94
+ )
95
+ ```
96
+
97
+ `parser` requires pandas (included with `phonipy[app]`). `metrics` selects returned DataFrame
98
+ columns; the saved analysis summary always retains all ten metrics and failures for auditing.
99
+ Prefer the core batch API when pandas is not needed. No outputs are created by analysis
100
+ functions until an explicit save/export call.
101
+
102
+ ## Command line
103
+
104
+ ```bash
105
+ phonipy voice.wav -o result.json --reference-key 57 --details details --plot
106
+ phonipy recordings -o results.csv --recursive --preset speech
107
+ python -m phonipy recordings -o results.xlsx --config examples/config.json
108
+ ```
109
+
110
+ Exit codes: **0** complete success, **1** batch completed with failed files, **2** invalid
111
+ input/settings/output or a single-file failure. Batch processing continues after corrupt
112
+ files by default; `--strict` stops on the first failure. JSON includes settings, warnings,
113
+ and failures; CSV/XLSX include failure rows plus a `.metadata.json` sidecar. `--details`
114
+ adds full JSON and frame/segment CSVs. Duplicate filenames receive distinct numeric prefixes.
115
+
116
+ ## Local dashboard
117
+
118
+ ```bash
119
+ pip install 'phonipy[app]'
120
+ streamlit run "$(python -c 'import phonipy.app; print(phonipy.app.__file__)')"
121
+ ```
122
+
123
+ Upload multiple files, adjust settings, view composite plots, and download CSV/JSON.
124
+ Analysis runs locally without uploads to an analysis service or telemetry from PhoniPy.
125
+ The dashboard limits each recording to five minutes and 100 MB.
126
+
127
+ ## The ten metrics
128
+
129
+ Let `d_i = end_i - start_i` for each valid stable-note segment; `N` is the segment count.
130
+
131
+ | Metric | Definition | Unit |
132
+ | --- | --- | --- |
133
+ | `raw_duration` | Original sample count / original sample rate | s |
134
+ | `active_time` | Last valid offset − first valid onset | s |
135
+ | `valid_time` | Sum of valid segment durations | s |
136
+ | `average_offset` | Unweighted segment mean of fractional pitch − reference note | semitones |
137
+ | `average_energy` | Duration-weighted harmonic-energy ratio across valid intervals | 0–1 |
138
+ | `hoarseness` | `(1 - average_energy) * 100` | % |
139
+ | `average_duration` | `valid_time / N` | s |
140
+ | `continuity` | `average_duration / valid_time` | dimensionless |
141
+ | `sparsity` | `valid_time / active_time` | 0–1 |
142
+ | `breakouts` | `N`, including the initial acquisition | count |
143
+
144
+ No valid detections: time totals, breakouts, continuity and sparsity are zero; undefined
145
+ means and hoarseness are `null` in JSON and blank in CSV/XLSX. **Continuity equals `1/N`**
146
+ whenever N > 0; it is retained exactly as specified, not presented as an independent metric.
147
+ The manuscript uses two inconsistent descriptions of breakouts; this package follows
148
+ Appendix A's segment count. Signed pitch offsets can cancel. `hoarseness` is the manuscript's
149
+ name for an acoustic residual-energy transform, not a clinical hoarseness rating.
150
+
151
+ ## Detector and reproducibility
152
+
153
+ - Default analysis rate: 16 kHz; hop: 17.25 ms (276 samples); Hann window: 69 ms.
154
+ - Normalized autocorrelation supplies candidate periods; the earliest sufficiently strong
155
+ local peak is refined with parabolic interpolation. This makes the fundamental candidate
156
+ stage explicit where the manuscript only says "strongest harmonic series."
157
+ - Harmonic-series energy is measured from a one-sided Hann-windowed power spectrum in
158
+ nonoverlapping frequency bands around up to 12 harmonics; total energy includes all bins.
159
+ - RMS, periodicity, spectral flatness, pitch range, and harmonic ratio gate detections.
160
+ - The identical rounded MIDI note must persist for at least eight frames. Accepted runs are
161
+ backdated to their first frame, not the eighth. Invalid/unvoiced frames end a run.
162
+ - Each frame represents a hop interval. Durations use disjoint intervals and the last interval
163
+ is clipped to the original duration. Forward-looking windows are zero-padded at the end.
164
+ Overlapping spectral windows do not create overlapping measured durations.
165
+ - Fractional pitch is retained separately for pitch offset. A4=440 Hz is MIDI note 69;
166
+ traditional 88-key piano index = MIDI number minus 20. Differences are identical.
167
+ - All effective settings, sample rates, channel selection, and warnings are exported.
168
+
169
+ See [methodology](docs/methodology.md) for equations and limitations.
170
+
171
+ ## Input handling and limits
172
+
173
+ Stereo/multichannel recordings select the loudest channel by default to avoid phase cancellation.
174
+ Choose `channel="mix"`, `"left"`, or `"right"` explicitly if desired. Resampling is polyphase.
175
+ NaN/infinite samples raise an error unless `nonfinite="zero"` explicitly enables replacement.
176
+ Empty/corrupt inputs, absent channels, invalid ranges, unavailable codecs, and excessive
177
+ durations produce actionable errors. Clipping/DC offset and short input produce warnings.
178
+ The default one-hour duration cap is configurable. Audio and frame results are held in memory;
179
+ split very large recordings into tasks. Output failures are surfaced rather than silently ignored.
180
+
181
+ Use quiet, solo voice recordings with consistent microphones and levels. Stable vowels and
182
+ single-note holds are the clearest tasks. Speech has natural unvoiced intervals and singing
183
+ has intentional pitch changes; their scores should not be directly compared with vowel scores.
184
+ Noise, reverberation, vibrato, note-boundary quantization, and octave errors can affect results.
185
+ No automatic recovery can reconstruct information absent from a recording.
186
+
187
+ ## Tests and benchmarks
188
+
189
+ ```bash
190
+ pip install -e '.[test,app,benchmark]'
191
+ pytest --cov=phonipy --cov-report=term-missing
192
+ ruff check .
193
+ python benchmarks/download.py --data-dir benchmarks/data
194
+ python benchmarks/run.py --data-dir benchmarks/data --output benchmarks/results
195
+ python -m build
196
+ python -m twine check dist/*
197
+ ```
198
+
199
+ The benchmark scripts analyze synthetic signals with known pitch/timing, all 3,000 recordings
200
+ in a pinned Free Spoken Digit Dataset snapshot, and all 208 VOICED sustained-vowel records.
201
+ They record source hashes, settings, dependency versions, wall time, failures, and per-file metrics.
202
+ Real recordings have no ground-truth values for these novel metrics: those runs demonstrate
203
+ processing coverage, not clinical accuracy or disease classification.
204
+ See [benchmark protocol](benchmarks/README.md) and [results](docs/benchmark-results.md).
205
+
206
+ ## Attribution and license
207
+
208
+ Implementation: Muhammad Umair Danish. Scientific framework: the user-supplied manuscript
209
+ *Musical-Key Stability Analysis of Continuous Voice: A Methodology for Quantifying Phonatory
210
+ Continuity and Purity in Neurological Disorders with Applications in Music Therapy*, by
211
+ Demian Kogutek and Danish Umair Muhammad. The manuscript itself is not redistributed.
212
+
213
+ Code: MIT. Benchmark recordings retain their upstream licenses and are not bundled in
214
+ the wheel/source distribution. See the benchmark protocol for dataset citations.
@@ -0,0 +1,16 @@
1
+ phonipy/__init__.py,sha256=rZfaoE9Ad95XYuDeUEFzc7HEDfzjNuylC3TSWlVx4DY,706
2
+ phonipy/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ phonipy/analysis.py,sha256=SSFmq2-a7A8Sx9qgDEMQs2Lp8tni1_9fHOkNmdS87XA,8370
4
+ phonipy/app.py,sha256=BOjLQHgkY7ukbT7DSwOyplm-TrLC2AoFBsLfBJZbOCY,3720
5
+ phonipy/audio.py,sha256=8TDY2MSCK27B3QW13SogPVp5YtielhwJ5_LmnoLhIcM,4424
6
+ phonipy/batch.py,sha256=IYnnujMc2Ja8KVA0L_ON2RSy5cNJmJdhmKx22LIg6Ho,6060
7
+ phonipy/cli.py,sha256=ks8IoL7KRSYuygslStFqdGSsF8uNCiS-CubAzFc3eco,3687
8
+ phonipy/config.py,sha256=VAlqP3XRp1Qn7Ex8rl_W75wkjl8kAz546TIEsGBcefY,3539
9
+ phonipy/metrics.py,sha256=NOnm5m6rITWnqFHbjr95iUFZ6NGmUMTGu-wcYqmaPwg,2049
10
+ phonipy/models.py,sha256=1DFB_wJyOn3Fb-obAc-ANIm1a-TGHs04ucRv_EhiBCk,1698
11
+ phonipy/plotting.py,sha256=w40l5mB3LOr7NuaU9b3LXYmYqtTJBHgxGwmMf0T2bZ4,1772
12
+ phonipy-0.1.0.dist-info/METADATA,sha256=CeFjFepKh4DLVsFQJSGU_zhq_FCU7ugojYQL8M-kh50,10284
13
+ phonipy-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ phonipy-0.1.0.dist-info/entry_points.txt,sha256=-Zo5xNYPnOAfnL6wXdAtAWXCHI681WkBu3Czch5ZQWk,45
15
+ phonipy-0.1.0.dist-info/licenses/LICENSE,sha256=JLY-8B0S8K71MNOujR_tKcqLA6Qmp3v4ATNE_a5q_RM,1078
16
+ phonipy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ phonipy = phonipy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Umair Danish
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.