BeatDetector
A beat detector implementation.
| Type | Name | Interface Description |
|---|---|---|
| Functions | constructor() | |
| Functions | getResult(): number | • Function: Gets the current beat detection result. All rhythm patterns are quantified to 3/4 or 4/4 time: - 4/4 time: cycles through 1 → 2 → 3 → 4 → 1 → ... - 3/4 time: cycles through 1 → 2 → 3 → 1 → ... Value 1 represents the onset beat (first beat of each measure). There is typically a ~2 second accuracy delay before the detector stabilizes. Returns The current beat position in the measure (1-based), or -1 when no result is available. |
Examples
constructor()
let obj = new APJS.BeatDetector();
Use Case
Live audio-detection rhythm game scoring loop — uses APJS.AudioDetectionModule.getOrCreateAudioDetectionBuilder + AudioSourceType.ExternalFile + a Touch handler…
// CORRECTNESS NOTE — APJS.d.ts JSDoc examples for the Detector classes use the WRONG name
// 'getAudioDetectionBuilder'. The actual exported namespace function is
// 'getOrCreateAudioDetectionBuilder' (declared in AudioComponent.d.ts:85). Using the
// JSDoc'd name fails TS compilation. This example is BeatDetector-specific. Other AudioDetectionType values require
// detector-specific result handling and remain unverified until focused live tests cover them.
// Numeric detectors need thresholds, Spectrum/SoundEvent return arrays, and Keyword is event-driven.
@component()
export class BeatTapScoring extends APJS.BasicScriptComponent {
@serializeProperty bgmPlayer!: APJS.SceneObject;
@serializeProperty scoreText!: APJS.SceneObject;
@serializeProperty comboText!: APJS.SceneObject;
@serializeProperty judgeText!: APJS.SceneObject;
// Tunables — adjust per game feel.
private static readonly PERFECT_WINDOW_S = 0.15; // ±150 ms
private static readonly GOOD_WINDOW_S = 0.30; // ±300 ms
private static readonly PERFECT_POINTS = 100;
private static readonly GOOD_POINTS = 50;
private audio: APJS.AudioComponent | null = null;
private detector: APJS.BeatDetector | null = null;
private scoreComp: APJS.Text | null = null;
private comboComp: APJS.Text | null = null;
private judgeComp: APJS.Text | null = null;
private touchCallback!: (event: APJS.IEvent) => void;
private elapsed = 0;
private lastResult = -999;
private pendingBeats: Array<{time: number; beat: number; judged: boolean}> = [];
private score = 0;
private combo = 0;
private maxCombo = 0;
private perfects = 0;
private goods = 0;
private misses = 0;
private autoMisses = 0;
private taps = 0;
private lastJudgmentLabel = "";
// RecordStart: full reset of accumulated state + restart BGM. RecordEnd: stop BGM
// so playMode='Infinity' loop stops between takes (E2 audio loop, see GameState SKILL).
private onRecordStart = (_event: APJS.IEvent) => {
this.elapsed = 0;
this.lastResult = -999;
this.pendingBeats = [];
this.score = 0;
this.combo = 0;
this.maxCombo = 0;
this.perfects = 0;
this.goods = 0;
this.misses = 0;
this.autoMisses = 0;
this.taps = 0;
this.lastJudgmentLabel = "";
if (this.audio) {
this.audio.stop();
this.audio.play();
}
this.refreshHUD();
};
private onRecordEnd = (_event: APJS.IEvent) => {
if (this.audio) this.audio.stop();
};
// Build the detector in onInit (NOT onStart) per the engine contract — builder.build()
// returns null if called outside the onInit lifecycle slot.
onInit(): void {
if (this.bgmPlayer) {
this.audio = this.bgmPlayer.getComponent("AudioComponent") as APJS.AudioComponent;
}
if (!this.audio) return; // pre-condition: AudioComponent ref must be bound before preview starts
const builder = APJS.AudioDetectionModule
.getOrCreateAudioDetectionBuilder(APJS.AudioDetectionType.Beat) as APJS.BeatDetectorBuilder | null;
if (!builder) return;
builder.setDetectorSource(APJS.AudioSourceType.ExternalFile, this.audio);
this.detector = builder.build();
}
onStart(): void {
if (this.scoreText) this.scoreComp = this.scoreText.getComponent("Text") as APJS.Text;
if (this.comboText) this.comboComp = this.comboText.getComponent("Text") as APJS.Text;
if (this.judgeText) this.judgeComp = this.judgeText.getComponent("Text") as APJS.Text;
if (this.audio) this.audio.play();
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.handleTap();
};
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.Touch, this.touchCallback, this);
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.RecordStart, this.onRecordStart);
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.RecordEnd, this.onRecordEnd);
}
onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.Touch, this.touchCallback, this);
}
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.RecordStart, this.onRecordStart);
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.RecordEnd, this.onRecordEnd);
}
onUpdate(dt: number): void {
this.elapsed += dt;
if (this.detector) {
const result = this.detector.getResult();
if (result !== this.lastResult && result > 0) {
this.pendingBeats.push({time: this.elapsed, beat: result, judged: false});
}
this.lastResult = result;
}
// auto-miss any unjudged beat past the GOOD window
for (const b of this.pendingBeats) {
if (!b.judged && this.elapsed - b.time > BeatTapScoring.GOOD_WINDOW_S) {
b.judged = true;
this.autoMisses += 1;
this.combo = 0;
this.lastJudgmentLabel = "MISS-auto";
}
}
if (this.pendingBeats.length > 50) {
this.pendingBeats = this.pendingBeats.filter(b => this.elapsed - b.time < 5);
}
this.refreshHUD();
}
private handleTap(): void {
this.taps += 1;
let closestBeat: {time: number; beat: number; judged: boolean} | null = null;
let closestDelta = 999;
for (const b of this.pendingBeats) {
if (b.judged) continue;
const delta = Math.abs(this.elapsed - b.time);
if (delta < closestDelta && delta < BeatTapScoring.GOOD_WINDOW_S) {
closestDelta = delta;
closestBeat = b;
}
}
if (closestBeat) {
closestBeat.judged = true;
const isPerfect = closestDelta < BeatTapScoring.PERFECT_WINDOW_S;
this.score += isPerfect ? BeatTapScoring.PERFECT_POINTS : BeatTapScoring.GOOD_POINTS;
if (isPerfect) this.perfects += 1; else this.goods += 1;
this.combo += 1;
if (this.combo > this.maxCombo) this.maxCombo = this.combo;
this.lastJudgmentLabel = isPerfect ? "PERFECT" : "GOOD";
} else {
this.misses += 1;
this.combo = 0;
this.lastJudgmentLabel = "MISS-tap";
}
}
private refreshHUD(): void {
if (this.scoreComp) this.scoreComp.text = "Score: " + this.score + " (" + this.lastJudgmentLabel + ")";
if (this.comboComp) this.comboComp.text = "Combo: x" + this.combo + " (max " + this.maxCombo + ")";
if (this.judgeComp) this.judgeComp.text = "P:" + this.perfects + " G:" + this.goods + " M-tap:" + this.misses + " M-auto:" + this.autoMisses + " taps:" + this.taps;
}
}