dotframe 0.1.0
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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/assets/audio/hit.mp3 +0 -0
- package/assets/audio/loop.mp3 +0 -0
- package/assets/crafter.png +0 -0
- package/assets/fonts/archivo-black.json +133 -0
- package/assets/fonts/archivo-black.png +0 -0
- package/assets/fonts/bangers.json +133 -0
- package/assets/fonts/bangers.png +0 -0
- package/assets/suzanne.glb +0 -0
- package/cli/commands/config.ts +35 -0
- package/cli/commands/doctor.ts +71 -0
- package/cli/commands/new.ts +73 -0
- package/cli/commands/play.ts +244 -0
- package/cli/commands/ship.ts +94 -0
- package/cli/commands/skills.ts +66 -0
- package/cli/commands/snap.ts +88 -0
- package/cli/lib.ts +148 -0
- package/cli/main.ts +95 -0
- package/cli/simkit.ts +152 -0
- package/cli/tsconfig.json +8 -0
- package/native/df_audio.c +266 -0
- package/native/df_native.c +605 -0
- package/native/ffi.macos.json +318 -0
- package/native/ffi.windows.json +322 -0
- package/native/third_party/dr_mp3.h +5430 -0
- package/native/third_party/stb_image.h +7988 -0
- package/native/third_party/stb_image_write.h +1724 -0
- package/native/third_party/stb_truetype.h +5079 -0
- package/package.json +47 -0
- package/scripts/build-native.sh +33 -0
- package/scripts/build-web.sh +10 -0
- package/scripts/vendor.sh +36 -0
- package/skills/assets/SKILL.md +11 -0
- package/skills/core/SKILL.md +51 -0
- package/skills/core/references/errors.md +22 -0
- package/skills/discord/SKILL.md +12 -0
- package/skills/export-web/SKILL.md +20 -0
- package/skills/game-design/SKILL.md +27 -0
- package/skills/game-design/references/sim-walkthrough.md +32 -0
- package/skills/ios/SKILL.md +19 -0
- package/skills/macos/SKILL.md +16 -0
- package/skills/netplay/SKILL.md +34 -0
- package/skills/relay/SKILL.md +18 -0
- package/src/audio.ts +19 -0
- package/src/draw2d.ts +901 -0
- package/src/ecs.ts +30 -0
- package/src/gltf.ts +82 -0
- package/src/gpu.ts +86 -0
- package/src/input.ts +215 -0
- package/src/math.ts +113 -0
- package/src/native/backend.ts +142 -0
- package/src/native/ffi.ts +42 -0
- package/src/native/library.ts +65 -0
- package/src/native/run.ts +88 -0
- package/src/physics.ts +57 -0
- package/src/platform.ts +12 -0
- package/src/raster2d.ts +348 -0
- package/src/render.ts +132 -0
- package/src/shapes.ts +37 -0
- package/src/sim.ts +56 -0
- package/src/storage.ts +6 -0
- package/src/web/run.ts +338 -0
- package/templates/_base/AGENTS.md +7 -0
- package/templates/_base/dotframe.json +15 -0
- package/templates/_base/gitignore +5 -0
- package/templates/_base/index.html +14 -0
- package/templates/_base/main.web.ts +27 -0
- package/templates/_base/package.json +12 -0
- package/templates/_base/sim.ts +31 -0
- package/templates/blank/game.ts +64 -0
- package/templates/fighter/game.ts +131 -0
- package/templates/platformer/game.ts +120 -0
- package/tools/check-audio.ts +57 -0
- package/tools/gen-library-glue.ts +83 -0
- package/tools/make-sprite.ts +92 -0
package/cli/main.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import pkg from "../package.json" with { type: "json" };
|
|
4
|
+
import { build, deploy, device, relay } from "./commands/ship";
|
|
5
|
+
import { doctor } from "./commands/doctor";
|
|
6
|
+
import { configCmd } from "./commands/config";
|
|
7
|
+
import { desync, record, sim, verify } from "./commands/play";
|
|
8
|
+
import { snap } from "./commands/snap";
|
|
9
|
+
import { skills } from "./commands/skills";
|
|
10
|
+
import { create, dev } from "./commands/new";
|
|
11
|
+
import { CliError, type Ctx, fail } from "./lib";
|
|
12
|
+
|
|
13
|
+
const HELP = `dotframe ${pkg.version}: build, test, and export dotframe games
|
|
14
|
+
|
|
15
|
+
Agents: run \`dotframe skills get core\` before anything else.
|
|
16
|
+
|
|
17
|
+
Play (headless, deterministic)
|
|
18
|
+
sim [--inputs f.jsonl | --mash <seed>] [--frames 600] [--seed 1] [--options json] [--every n]
|
|
19
|
+
snap --frame <n> [--out frame.png] [--inputs f | --mash <seed>] [--seed 1] [--options json]
|
|
20
|
+
replay record <file> | verify <file...>
|
|
21
|
+
desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3]
|
|
22
|
+
|
|
23
|
+
Build and ship
|
|
24
|
+
build <target> [--release] targets come from dotframe.json
|
|
25
|
+
deploy <target> [--prod] gated: --yes, preview with --dry-run
|
|
26
|
+
relay deploy [--region eze] gated
|
|
27
|
+
device install <target> gated
|
|
28
|
+
doctor [--fix] toolchain, links, config, signing
|
|
29
|
+
config get [key] | set <key> <json>
|
|
30
|
+
|
|
31
|
+
Project
|
|
32
|
+
new <name> [--template fighter|platformer|blank] [--no-install]
|
|
33
|
+
dev [--port 5173] build web, serve it, rebuild on change
|
|
34
|
+
skills list | get <name...> [--full] | get --all | path [name]
|
|
35
|
+
|
|
36
|
+
Global: --json --yes --dry-run --help --version`;
|
|
37
|
+
|
|
38
|
+
const { values, positionals } = parseArgs({
|
|
39
|
+
allowPositionals: true,
|
|
40
|
+
strict: false,
|
|
41
|
+
options: {
|
|
42
|
+
json: { type: "boolean" },
|
|
43
|
+
yes: { type: "boolean", short: "y" },
|
|
44
|
+
"dry-run": { type: "boolean" },
|
|
45
|
+
help: { type: "boolean", short: "h" },
|
|
46
|
+
version: { type: "boolean" },
|
|
47
|
+
full: { type: "boolean" },
|
|
48
|
+
all: { type: "boolean" },
|
|
49
|
+
fix: { type: "boolean" },
|
|
50
|
+
prod: { type: "boolean" },
|
|
51
|
+
release: { type: "boolean" },
|
|
52
|
+
inputs: { type: "string" },
|
|
53
|
+
mash: { type: "string" },
|
|
54
|
+
frames: { type: "string" },
|
|
55
|
+
frame: { type: "string" },
|
|
56
|
+
seed: { type: "string" },
|
|
57
|
+
options: { type: "string" },
|
|
58
|
+
every: { type: "string" },
|
|
59
|
+
out: { type: "string" },
|
|
60
|
+
latency: { type: "string" },
|
|
61
|
+
jitter: { type: "string" },
|
|
62
|
+
delay: { type: "string" },
|
|
63
|
+
renders: { type: "string" },
|
|
64
|
+
region: { type: "string" },
|
|
65
|
+
template: { type: "string" },
|
|
66
|
+
port: { type: "string" },
|
|
67
|
+
"no-install": { type: "boolean" },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const ctx: Ctx = { json: values.json === true, yes: values.yes === true, dryRun: values["dry-run"] === true };
|
|
72
|
+
const v = values as Record<string, string | undefined>;
|
|
73
|
+
const [command, ...rest] = positionals;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (values.version) console.log(pkg.version);
|
|
77
|
+
else if (!command || values.help) console.log(HELP);
|
|
78
|
+
else if (command === "sim") await sim(ctx, v);
|
|
79
|
+
else if (command === "snap") await snap(ctx, v);
|
|
80
|
+
else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
|
|
81
|
+
else if (command === "replay" && rest[0] === "verify") await verify(ctx, rest.slice(1));
|
|
82
|
+
else if (command === "desync") await desync(ctx, v);
|
|
83
|
+
else if (command === "build") await build(ctx, rest[0], values.release === true);
|
|
84
|
+
else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
|
|
85
|
+
else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
|
|
86
|
+
else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
|
|
87
|
+
else if (command === "doctor") await doctor(ctx, values.fix === true);
|
|
88
|
+
else if (command === "config") await configCmd(ctx, rest);
|
|
89
|
+
else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
|
|
90
|
+
else if (command === "new") await create(ctx, rest[0], v.template ?? "blank", values["no-install"] !== true);
|
|
91
|
+
else if (command === "dev") await dev(ctx, Number(v.port ?? "5173"));
|
|
92
|
+
else throw new CliError("UNKNOWN_COMMAND", `unknown command: ${positionals.join(" ")}`, "dotframe --help");
|
|
93
|
+
} catch (error) {
|
|
94
|
+
fail(ctx, error);
|
|
95
|
+
}
|
package/cli/simkit.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { Sim, SimRun } from "../src/sim";
|
|
4
|
+
import type { RenderGpu, Texture } from "../src/gpu";
|
|
5
|
+
import { CliError, type Config } from "./lib";
|
|
6
|
+
|
|
7
|
+
let nextTexture = 1;
|
|
8
|
+
// Texture ids restart per run so two runs of the same game have identical state graphs.
|
|
9
|
+
export function resetStubGpu(): void {
|
|
10
|
+
nextTexture = 1;
|
|
11
|
+
}
|
|
12
|
+
export const stubGpu: RenderGpu = {
|
|
13
|
+
createBuffer: (): number => 0,
|
|
14
|
+
writeBuffer: (): void => {},
|
|
15
|
+
destroyBuffer: (): void => {},
|
|
16
|
+
createPipeline: (): number => 0,
|
|
17
|
+
bind: (): number => 0,
|
|
18
|
+
createTexture: (width: number, height: number): Texture => ({ id: nextTexture++, width, height }) as Texture,
|
|
19
|
+
frame: (): void => {},
|
|
20
|
+
aspect: (): number => 16 / 9,
|
|
21
|
+
} as RenderGpu;
|
|
22
|
+
|
|
23
|
+
export function simPath(config: Config): string {
|
|
24
|
+
if (!config.sim) throw new CliError("NO_SIM", `dotframe.json has no "sim" module`, `add "sim": "path/to/sim.ts" exporting defineSim(...)`, "core");
|
|
25
|
+
const path = resolve(config.root, config.sim);
|
|
26
|
+
if (!existsSync(path)) throw new CliError("SIM_MISSING", `sim module ${config.sim} does not exist`, "create it with defineSim from dotframe/src/sim", "core");
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadSim(config: Config): Promise<Sim> {
|
|
31
|
+
const mod = (await import(simPath(config))) as { default?: Sim };
|
|
32
|
+
if (!mod.default || typeof mod.default.create !== "function") throw new CliError("BAD_SIM", `${config.sim} must default-export defineSim({...})`, "see dotframe skills get core", "core");
|
|
33
|
+
return mod.default;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function headlessRun(sim: Sim, root: string): Promise<SimRun> {
|
|
37
|
+
resetStubGpu();
|
|
38
|
+
const run = sim.create({ gpu: stubGpu, load: async (p: string): Promise<Uint8Array> => new Uint8Array(await Bun.file(resolve(root, p)).arrayBuffer()), headless: true });
|
|
39
|
+
await run.ready;
|
|
40
|
+
return run;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function lcg(seed: number): () => number {
|
|
44
|
+
let s = seed >>> 0;
|
|
45
|
+
return (): number => {
|
|
46
|
+
s = (Math.imul(s, 1103515245) + 12345) >>> 0;
|
|
47
|
+
return s / 4294967296;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InputSource {
|
|
52
|
+
// Encoded inputs per frame, one number per player.
|
|
53
|
+
at: (frame: number) => number[];
|
|
54
|
+
describe: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// JSONL lines are either [p0, p1, ...] (one line per frame) or {"frame": N, "inputs": [...]} (held until the next).
|
|
58
|
+
export function inputsFromFile(sim: Sim, path: string): InputSource {
|
|
59
|
+
if (!existsSync(path)) throw new CliError("INPUTS_MISSING", `inputs file ${path} does not exist`, "pass --inputs <file.jsonl> or --mash <seed>");
|
|
60
|
+
const keyed: { frame: number; inputs: number[] }[] = [];
|
|
61
|
+
const lines = readFileSync(path, "utf8").split("\n").filter((l: string): boolean => l.trim() !== "");
|
|
62
|
+
lines.forEach((line: string, i: number): void => {
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(line);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CliError("BAD_INPUTS", `${path}:${i + 1} is not JSON`, "one JSON array or {frame, inputs} object per line");
|
|
68
|
+
}
|
|
69
|
+
const entry = Array.isArray(parsed) ? { frame: i, inputs: parsed } : (parsed as { frame: number; inputs: unknown[] });
|
|
70
|
+
if (!Array.isArray(entry.inputs) || entry.inputs.length !== sim.players) {
|
|
71
|
+
throw new CliError("BAD_INPUTS", `${path}:${i + 1} needs ${sim.players} inputs`, "one input per player");
|
|
72
|
+
}
|
|
73
|
+
keyed.push({ frame: entry.frame, inputs: entry.inputs.map((v: unknown): number => (typeof v === "number" ? v : sim.encode(v))) });
|
|
74
|
+
});
|
|
75
|
+
keyed.sort((a, b): number => a.frame - b.frame);
|
|
76
|
+
const neutral = new Array(sim.players).fill(sim.neutral);
|
|
77
|
+
return {
|
|
78
|
+
describe: path,
|
|
79
|
+
at: (frame: number): number[] => {
|
|
80
|
+
let cur = neutral;
|
|
81
|
+
for (const k of keyed) {
|
|
82
|
+
if (k.frame > frame) break;
|
|
83
|
+
cur = k.inputs;
|
|
84
|
+
}
|
|
85
|
+
return cur;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Mashing players: a new random input per player every 6 frames.
|
|
91
|
+
export function mash(sim: Sim, seed: number): InputSource {
|
|
92
|
+
const cache: number[][] = [];
|
|
93
|
+
const next = lcg(seed);
|
|
94
|
+
return {
|
|
95
|
+
describe: `mash:${seed}`,
|
|
96
|
+
at: (frame: number): number[] => {
|
|
97
|
+
while (cache.length <= frame) {
|
|
98
|
+
const f = cache.length;
|
|
99
|
+
cache.push(f % 6 === 0 ? Array.from({ length: sim.players }, (): number => sim.random(next)) : cache[f - 1].slice());
|
|
100
|
+
}
|
|
101
|
+
return cache[frame];
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function inputSource(sim: Sim, root: string, inputs: string | undefined, mashSeed: string | undefined): InputSource {
|
|
107
|
+
if (inputs) return inputsFromFile(sim, resolve(process.cwd(), inputs));
|
|
108
|
+
if (mashSeed !== undefined) return mash(sim, Number(mashSeed));
|
|
109
|
+
return { describe: "neutral", at: (): number[] => new Array(sim.players).fill(sim.neutral) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseOptions(sim: Sim, raw: string | undefined): Record<string, unknown> {
|
|
113
|
+
if (!raw) return { ...sim.options };
|
|
114
|
+
try {
|
|
115
|
+
return { ...sim.options, ...(JSON.parse(raw) as Record<string, unknown>) };
|
|
116
|
+
} catch {
|
|
117
|
+
throw new CliError("BAD_OPTIONS", "--options must be a JSON object", `example: --options '${JSON.stringify(sim.options)}'`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Flattens an object graph into path -> primitive, in key order, visiting each object once. Functions and typed
|
|
122
|
+
// arrays are skipped (closures and pixel data are not comparable state).
|
|
123
|
+
export function flatten(root: unknown): Map<string, unknown> {
|
|
124
|
+
const out = new Map<string, unknown>();
|
|
125
|
+
const seen = new Set<unknown>();
|
|
126
|
+
const walk = (v: unknown, path: string): void => {
|
|
127
|
+
if (typeof v === "function") return;
|
|
128
|
+
if (v === null || typeof v !== "object") {
|
|
129
|
+
out.set(path, typeof v === "number" ? Math.round(v * 1e6) / 1e6 : v);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (seen.has(v) || ArrayBuffer.isView(v)) return;
|
|
133
|
+
seen.add(v);
|
|
134
|
+
if (v instanceof Map) {
|
|
135
|
+
out.set(`${path}.size`, v.size);
|
|
136
|
+
for (const [k, item] of v) walk(item, `${path}[${String(k)}]`);
|
|
137
|
+
} else if (v instanceof Set) out.set(`${path}.size`, v.size);
|
|
138
|
+
else for (const k of Object.keys(v)) walk((v as Record<string, unknown>)[k], `${path}.${k}`);
|
|
139
|
+
};
|
|
140
|
+
walk(root, "state");
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function firstDifference(a: Map<string, unknown>, b: Map<string, unknown>): string {
|
|
145
|
+
for (const [k, v] of a) {
|
|
146
|
+
if (!b.has(k)) return `${k}: missing`;
|
|
147
|
+
const w = b.get(k);
|
|
148
|
+
if (v !== w && !(Number.isNaN(v) && Number.isNaN(w))) return `${k}: ${String(v)} vs ${String(w)}`;
|
|
149
|
+
}
|
|
150
|
+
for (const k of b.keys()) if (!a.has(k)) return `${k}: extra`;
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Software mixer over an SDL3 audio stream: decoded sound effects, square tones and one streamed music track.
|
|
2
|
+
// All entry points are called from the game thread; the SDL callback mixes on the audio thread under g_lock.
|
|
3
|
+
#include <SDL3/SDL.h>
|
|
4
|
+
#include <math.h>
|
|
5
|
+
#include <stdint.h>
|
|
6
|
+
#include <stdlib.h>
|
|
7
|
+
#include <string.h>
|
|
8
|
+
|
|
9
|
+
#define DR_MP3_IMPLEMENTATION
|
|
10
|
+
#include "third_party/dr_mp3.h"
|
|
11
|
+
|
|
12
|
+
#define OUTPUT_RATE 48000
|
|
13
|
+
#define MAX_SOUNDS 256
|
|
14
|
+
#define MAX_VOICES 64
|
|
15
|
+
#define MAX_TONES 16
|
|
16
|
+
#define MAX_TRACKS 32
|
|
17
|
+
|
|
18
|
+
typedef struct {
|
|
19
|
+
float *frames; // interleaved stereo
|
|
20
|
+
uint64_t count;
|
|
21
|
+
uint32_t rate;
|
|
22
|
+
} Sound;
|
|
23
|
+
|
|
24
|
+
typedef struct {
|
|
25
|
+
int32_t sound;
|
|
26
|
+
double position;
|
|
27
|
+
double step;
|
|
28
|
+
float volume;
|
|
29
|
+
} Voice;
|
|
30
|
+
|
|
31
|
+
typedef struct {
|
|
32
|
+
double phase;
|
|
33
|
+
double step;
|
|
34
|
+
double elapsed;
|
|
35
|
+
double duration;
|
|
36
|
+
float volume;
|
|
37
|
+
} Tone;
|
|
38
|
+
|
|
39
|
+
typedef struct {
|
|
40
|
+
uint8_t *data; // owned copy of the MP3 bytes the decoder streams from
|
|
41
|
+
size_t size;
|
|
42
|
+
} Track;
|
|
43
|
+
|
|
44
|
+
static SDL_AudioStream *g_stream;
|
|
45
|
+
static SDL_Mutex *g_lock;
|
|
46
|
+
static Sound g_sounds[MAX_SOUNDS];
|
|
47
|
+
static int32_t g_sound_count;
|
|
48
|
+
static Voice g_voices[MAX_VOICES];
|
|
49
|
+
static int32_t g_voice_count;
|
|
50
|
+
static Tone g_tones[MAX_TONES];
|
|
51
|
+
static int32_t g_tone_count;
|
|
52
|
+
static Track g_tracks[MAX_TRACKS];
|
|
53
|
+
static int32_t g_track_count;
|
|
54
|
+
|
|
55
|
+
static drmp3 g_music;
|
|
56
|
+
static int g_music_open;
|
|
57
|
+
static int g_music_loop;
|
|
58
|
+
static int g_music_paused;
|
|
59
|
+
static float g_music_volume = 1.0f;
|
|
60
|
+
static float g_music_buffer[4096 * 2];
|
|
61
|
+
static uint64_t g_music_buffered;
|
|
62
|
+
static double g_music_position;
|
|
63
|
+
static double g_music_step;
|
|
64
|
+
static float g_master = 0.8f;
|
|
65
|
+
|
|
66
|
+
static float clampf(float v) { return v < -1.0f ? -1.0f : v > 1.0f ? 1.0f : v; }
|
|
67
|
+
|
|
68
|
+
// Pulls the next music frame pair through a small linear resampler; returns 0 at the end of a non-looping track.
|
|
69
|
+
static int music_frame(float *left, float *right) {
|
|
70
|
+
while (g_music_position + 1.0 >= (double)g_music_buffered) {
|
|
71
|
+
if (g_music_buffered >= 1) {
|
|
72
|
+
// Keep the last frame so interpolation stays continuous across refills.
|
|
73
|
+
g_music_buffer[0] = g_music_buffer[(g_music_buffered - 1) * 2];
|
|
74
|
+
g_music_buffer[1] = g_music_buffer[(g_music_buffered - 1) * 2 + 1];
|
|
75
|
+
g_music_position -= (double)(g_music_buffered - 1);
|
|
76
|
+
g_music_buffered = 1;
|
|
77
|
+
}
|
|
78
|
+
uint64_t read = drmp3_read_pcm_frames_f32(&g_music, 4095, g_music_buffer + g_music_buffered * 2);
|
|
79
|
+
if (read == 0) {
|
|
80
|
+
if (!g_music_loop || !drmp3_seek_to_pcm_frame(&g_music, 0)) return 0;
|
|
81
|
+
read = drmp3_read_pcm_frames_f32(&g_music, 4095, g_music_buffer + g_music_buffered * 2);
|
|
82
|
+
if (read == 0) return 0;
|
|
83
|
+
}
|
|
84
|
+
g_music_buffered += read;
|
|
85
|
+
}
|
|
86
|
+
uint64_t i = (uint64_t)g_music_position;
|
|
87
|
+
float t = (float)(g_music_position - (double)i);
|
|
88
|
+
*left = g_music_buffer[i * 2] * (1 - t) + g_music_buffer[(i + 1) * 2] * t;
|
|
89
|
+
*right = g_music_buffer[i * 2 + 1] * (1 - t) + g_music_buffer[(i + 1) * 2 + 1] * t;
|
|
90
|
+
g_music_position += g_music_step;
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static void SDLCALL mix(void *userdata, SDL_AudioStream *stream, int additional, int total) {
|
|
95
|
+
(void)userdata;
|
|
96
|
+
(void)total;
|
|
97
|
+
float out[1024 * 2];
|
|
98
|
+
while (additional > 0) {
|
|
99
|
+
int frames = additional / (int)(sizeof(float) * 2);
|
|
100
|
+
if (frames > 1024) frames = 1024;
|
|
101
|
+
if (frames <= 0) break;
|
|
102
|
+
memset(out, 0, sizeof(float) * 2 * (size_t)frames);
|
|
103
|
+
SDL_LockMutex(g_lock);
|
|
104
|
+
for (int32_t v = 0; v < g_voice_count;) {
|
|
105
|
+
Voice *voice = &g_voices[v];
|
|
106
|
+
Sound *sound = &g_sounds[voice->sound];
|
|
107
|
+
int done = 0;
|
|
108
|
+
for (int f = 0; f < frames; f++) {
|
|
109
|
+
uint64_t i = (uint64_t)voice->position;
|
|
110
|
+
if (i + 1 >= sound->count) {
|
|
111
|
+
done = 1;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
float t = (float)(voice->position - (double)i);
|
|
115
|
+
out[f * 2] += (sound->frames[i * 2] * (1 - t) + sound->frames[(i + 1) * 2] * t) * voice->volume;
|
|
116
|
+
out[f * 2 + 1] += (sound->frames[i * 2 + 1] * (1 - t) + sound->frames[(i + 1) * 2 + 1] * t) * voice->volume;
|
|
117
|
+
voice->position += voice->step;
|
|
118
|
+
}
|
|
119
|
+
if (done) g_voices[v] = g_voices[--g_voice_count];
|
|
120
|
+
else v++;
|
|
121
|
+
}
|
|
122
|
+
for (int32_t k = 0; k < g_tone_count;) {
|
|
123
|
+
Tone *tone = &g_tones[k];
|
|
124
|
+
for (int f = 0; f < frames && tone->elapsed < tone->duration; f++) {
|
|
125
|
+
// Exponential decay from volume to 0.1% over the duration, like Web Audio's exponentialRamp.
|
|
126
|
+
float gain = tone->volume * powf(0.001f / tone->volume, (float)(tone->elapsed / tone->duration));
|
|
127
|
+
float value = fmod(tone->phase, 1.0) < 0.5 ? gain : -gain;
|
|
128
|
+
out[f * 2] += value;
|
|
129
|
+
out[f * 2 + 1] += value;
|
|
130
|
+
tone->phase += tone->step;
|
|
131
|
+
tone->elapsed += 1.0 / OUTPUT_RATE;
|
|
132
|
+
}
|
|
133
|
+
if (tone->elapsed >= tone->duration) g_tones[k] = g_tones[--g_tone_count];
|
|
134
|
+
else k++;
|
|
135
|
+
}
|
|
136
|
+
if (g_music_open && !g_music_paused) {
|
|
137
|
+
for (int f = 0; f < frames; f++) {
|
|
138
|
+
float left, right;
|
|
139
|
+
if (!music_frame(&left, &right)) {
|
|
140
|
+
drmp3_uninit(&g_music);
|
|
141
|
+
g_music_open = 0;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
out[f * 2] += left * g_music_volume;
|
|
145
|
+
out[f * 2 + 1] += right * g_music_volume;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
SDL_UnlockMutex(g_lock);
|
|
149
|
+
for (int f = 0; f < frames * 2; f++) out[f] = clampf(out[f] * g_master);
|
|
150
|
+
SDL_PutAudioStreamData(stream, out, (int)(sizeof(float) * 2 * (size_t)frames));
|
|
151
|
+
additional -= (int)(sizeof(float) * 2 * (size_t)frames);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
int32_t df_audio_open(void) {
|
|
156
|
+
if (g_stream) return 0;
|
|
157
|
+
if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) return -1;
|
|
158
|
+
g_lock = SDL_CreateMutex();
|
|
159
|
+
SDL_AudioSpec spec = {SDL_AUDIO_F32, 2, OUTPUT_RATE};
|
|
160
|
+
g_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, mix, NULL);
|
|
161
|
+
if (!g_stream) return -2;
|
|
162
|
+
SDL_ResumeAudioStreamDevice(g_stream);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Decodes MP3 bytes into a playable sound.
|
|
167
|
+
int32_t df_sound(const uint8_t *mp3, size_t len) {
|
|
168
|
+
if (g_sound_count >= MAX_SOUNDS) return -1;
|
|
169
|
+
drmp3_config config;
|
|
170
|
+
drmp3_uint64 count = 0;
|
|
171
|
+
float *mono_or_stereo = drmp3_open_memory_and_read_pcm_frames_f32(mp3, len, &config, &count, NULL);
|
|
172
|
+
if (!mono_or_stereo) return -2;
|
|
173
|
+
float *frames = malloc(sizeof(float) * 2 * (size_t)count);
|
|
174
|
+
for (drmp3_uint64 i = 0; i < count; i++) {
|
|
175
|
+
frames[i * 2] = mono_or_stereo[i * config.channels];
|
|
176
|
+
frames[i * 2 + 1] = mono_or_stereo[i * config.channels + (config.channels > 1 ? 1 : 0)];
|
|
177
|
+
}
|
|
178
|
+
drmp3_free(mono_or_stereo, NULL);
|
|
179
|
+
SDL_LockMutex(g_lock);
|
|
180
|
+
int32_t id = g_sound_count++;
|
|
181
|
+
g_sounds[id] = (Sound){frames, count, config.sampleRate};
|
|
182
|
+
SDL_UnlockMutex(g_lock);
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
void df_play(int32_t sound, double volume, double rate) {
|
|
187
|
+
if (!g_stream || sound < 0 || sound >= g_sound_count) return;
|
|
188
|
+
SDL_LockMutex(g_lock);
|
|
189
|
+
if (g_voice_count < MAX_VOICES) {
|
|
190
|
+
g_voices[g_voice_count++] =
|
|
191
|
+
(Voice){sound, 0.0, (double)g_sounds[sound].rate / OUTPUT_RATE * rate, (float)volume};
|
|
192
|
+
}
|
|
193
|
+
SDL_UnlockMutex(g_lock);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
void df_tone(double frequency, double duration, double volume) {
|
|
197
|
+
if (!g_stream || duration <= 0 || volume <= 0) return;
|
|
198
|
+
SDL_LockMutex(g_lock);
|
|
199
|
+
if (g_tone_count < MAX_TONES) g_tones[g_tone_count++] = (Tone){0.0, frequency / OUTPUT_RATE, 0.0, duration, (float)volume};
|
|
200
|
+
SDL_UnlockMutex(g_lock);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Keeps a copy of MP3 bytes for streaming playback.
|
|
204
|
+
int32_t df_track(const uint8_t *mp3, size_t len) {
|
|
205
|
+
if (g_track_count >= MAX_TRACKS) return -1;
|
|
206
|
+
uint8_t *copy = malloc(len);
|
|
207
|
+
memcpy(copy, mp3, len);
|
|
208
|
+
g_tracks[g_track_count] = (Track){copy, len};
|
|
209
|
+
return g_track_count++;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
int32_t df_music_play(int32_t track, uint8_t loop, double volume) {
|
|
213
|
+
if (!g_stream || track < 0 || track >= g_track_count) return -1;
|
|
214
|
+
SDL_LockMutex(g_lock);
|
|
215
|
+
if (g_music_open) drmp3_uninit(&g_music);
|
|
216
|
+
g_music_open = drmp3_init_memory(&g_music, g_tracks[track].data, g_tracks[track].size, NULL);
|
|
217
|
+
g_music_loop = loop;
|
|
218
|
+
g_music_paused = 0;
|
|
219
|
+
g_music_volume = (float)volume;
|
|
220
|
+
g_music_buffered = 0;
|
|
221
|
+
g_music_position = 0;
|
|
222
|
+
g_music_step = g_music_open ? (double)g_music.sampleRate / OUTPUT_RATE : 1.0;
|
|
223
|
+
SDL_UnlockMutex(g_lock);
|
|
224
|
+
return g_music_open ? 0 : -2;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
void df_music_stop(void) {
|
|
228
|
+
SDL_LockMutex(g_lock);
|
|
229
|
+
if (g_music_open) drmp3_uninit(&g_music);
|
|
230
|
+
g_music_open = 0;
|
|
231
|
+
SDL_UnlockMutex(g_lock);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
void df_music_pause(uint8_t paused) {
|
|
235
|
+
SDL_LockMutex(g_lock);
|
|
236
|
+
g_music_paused = paused;
|
|
237
|
+
SDL_UnlockMutex(g_lock);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
void df_music_volume(double volume) {
|
|
241
|
+
SDL_LockMutex(g_lock);
|
|
242
|
+
g_music_volume = (float)volume;
|
|
243
|
+
SDL_UnlockMutex(g_lock);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
void df_master_volume(double volume) { g_master = (float)volume; }
|
|
247
|
+
|
|
248
|
+
// Number of currently mixing sound and tone voices, for tests and diagnostics.
|
|
249
|
+
int32_t df_audio_active(void) {
|
|
250
|
+
SDL_LockMutex(g_lock);
|
|
251
|
+
int32_t active = g_voice_count + g_tone_count + (g_music_open && !g_music_paused ? 1 : 0);
|
|
252
|
+
SDL_UnlockMutex(g_lock);
|
|
253
|
+
return active;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
void df_audio_close(void) {
|
|
257
|
+
if (g_stream) SDL_DestroyAudioStream(g_stream);
|
|
258
|
+
g_stream = NULL;
|
|
259
|
+
if (g_music_open) drmp3_uninit(&g_music);
|
|
260
|
+
g_music_open = 0;
|
|
261
|
+
for (int32_t i = 0; i < g_sound_count; i++) free(g_sounds[i].frames);
|
|
262
|
+
for (int32_t i = 0; i < g_track_count; i++) free(g_tracks[i].data);
|
|
263
|
+
g_sound_count = g_track_count = g_voice_count = g_tone_count = 0;
|
|
264
|
+
if (g_lock) SDL_DestroyMutex(g_lock);
|
|
265
|
+
g_lock = NULL;
|
|
266
|
+
}
|