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 +26 -0
- phonipy/__main__.py +3 -0
- phonipy/analysis.py +200 -0
- phonipy/app.py +92 -0
- phonipy/audio.py +106 -0
- phonipy/batch.py +158 -0
- phonipy/cli.py +78 -0
- phonipy/config.py +80 -0
- phonipy/metrics.py +56 -0
- phonipy/models.py +72 -0
- phonipy/plotting.py +50 -0
- phonipy-0.1.0.dist-info/METADATA +214 -0
- phonipy-0.1.0.dist-info/RECORD +16 -0
- phonipy-0.1.0.dist-info/WHEEL +4 -0
- phonipy-0.1.0.dist-info/entry_points.txt +2 -0
- phonipy-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|