feat(cli): beats analyses an audio or video file given directly

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-30 05:08:03 -04:00
parent 71b668010f
commit 497303696b
3 changed files with 58 additions and 17 deletions
+3 -2
View File
@@ -716,10 +716,11 @@ them.
```bash
npx hyperframes beats [dir]
npx hyperframes beats [dir] --json # { ok, file, count, bpm }
npx hyperframes beats <file> --json # { ok, file, count, bpm, beatTimes }
```
`beats` takes the project folder, not a media file. For the beats of one audio
or video file, run `npx hyperframes audio <file> --only beats --json`.
Given an audio or video file instead of a folder, `beats` analyses that file
with the same tracker and prints the result; it writes nothing.
`beats` finds the music track — an `<audio>` element with
`data-timeline-role="music"`, or an id like `music`, `bgm`, or `soundtrack` —
+15 -5
View File
@@ -90,11 +90,21 @@ describe.skipIf(!canDecode)("hyperframes beats", () => {
expect(Math.abs(report.bpm - 120)).toBeLessThanOrEqual(1);
});
it("points a media file argument to hyperframes audio instead of reading it as a project", async () => {
const song = join(project("file-arg", 120), "assets", "song.wav");
const { exitCode, out } = await runBeats(song);
expect(exitCode).toBe(1);
expect(out).toContain(`hyperframes audio ${JSON.stringify(song)} --only beats --json`);
it("analyses an audio file given directly and writes nothing", async () => {
const dir = project("file-arg", 120);
const song = join(dir, "assets", "song.wav");
const { exitCode, out } = await runBeats(song, true);
expect(exitCode).toBe(0);
const report = JSON.parse(out) as {
ok: boolean;
file: string;
bpm: number;
beatTimes: number[];
};
expect(report).toMatchObject({ ok: true, file: song });
expect(report.beatTimes.length).toBeGreaterThan(18);
expect(Math.abs(report.bpm - 120)).toBeLessThanOrEqual(1);
expect(existsSync(join(dir, "beats"))).toBe(false);
});
it("tells a composition file argument to pass its folder", async () => {
+40 -10
View File
@@ -21,7 +21,7 @@ import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Generate the beat file for the current project", "hyperframes beats"],
["Generate for a specific directory", "hyperframes beats ./my-video"],
["Beats of a single audio file", "hyperframes audio music.mp3 --only beats --json"],
["Beats of a single audio or video file", "hyperframes beats music.mp3 --json"],
];
function fail(message: string): never {
@@ -96,24 +96,54 @@ function report(file: string, result: MusicBeatAnalysis, json: boolean): void {
);
}
async function analyzeFile(audioPath: string, json: boolean): Promise<void> {
const result = await analyzeMusicFromSignal(await decode(audioPath));
if (result.beatTimes.length === 0) fail(`No beats detected in ${audioPath}.`);
if (json) {
console.log(
JSON.stringify(
{
ok: true,
file: audioPath,
count: result.beatTimes.length,
bpm: result.bpm,
beatTimes: result.beatTimes,
},
null,
2,
),
);
return;
}
console.log(
c.success(
`✓ ${result.beatTimes.length} beats in ${audioPath} (bpm ${result.bpm ?? "?"}, ${result.bpmConfidence})`,
),
);
}
export default defineCommand({
meta: {
name: "beats",
description: "Detect beats in the music track and write beats/<audio>.json",
description:
"Detect beats in the project music track (writes beats/<audio>.json) or in one audio file",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
dir: {
type: "positional",
description: "Project directory, or one audio or video file to analyse",
required: false,
},
json: { type: "boolean", description: "Output result as JSON", default: false },
},
async run({ args }) {
if (args.dir && existsSync(resolve(args.dir)) && statSync(resolve(args.dir)).isFile()) {
const file = JSON.stringify(args.dir);
fail(
/\.html?$/i.test(args.dir)
? `${args.dir} is a file; beats takes the project folder: hyperframes beats ${JSON.stringify(dirname(args.dir))}`
: `${args.dir} is a file; beats takes a project folder. ` +
`For one audio or video file run: hyperframes audio ${file} --only beats --json`,
);
if (/\.html?$/i.test(args.dir)) {
fail(
`${args.dir} is a file; beats takes the project folder: hyperframes beats ${JSON.stringify(dirname(args.dir))}`,
);
}
return await analyzeFile(resolve(args.dir), Boolean(args.json));
}
const project = resolveProject(args.dir);
const target = resolveMusicTarget(project);