window.WhistleCoach = window.WhistleCoach || {}; window.WhistleCoach.AudioFeatures = (() => { const clamp = (value, minimum = 0, maximum = 1) => Math.min(maximum, Math.max(minimum, value)); function toFeaturePayload(audio, stableDurationMs = 0) { return { rms_volume: Number(audio?.rms || 0), airflow_score: Number(audio?.airflow || 0), peak_frequency_hz: Number(audio?.peakFrequency || 0), pitch_stability_score: Number(audio?.tone || 0), stable_duration_ms: Math.round(stableDurationMs || 0), }; } function classifyAudio(features) { if (features.airflow_score < 0.32) return "no_airflow"; if (features.pitch_stability_score < 0.58) return "airflow_no_tone"; if (features.stable_duration_ms >= 2000) return "stable_whistle"; return "tone_starting"; } function pitchToMidi(frequencyHz) { if (!frequencyHz || frequencyHz <= 0) return null; return Math.round(69 + 12 * Math.log2(frequencyHz / 440)); } function midiToName(midi) { if (midi === null) return ""; const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`; } return { toFeaturePayload, classifyAudio, pitchToMidi, midiToName, }; })();