| 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, | |
| }; | |
| })(); | |