@geastack/apple 0.2.8
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/LICENSE +202 -0
- package/README.md +54 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +3076 -0
- package/generated/AVFoundation.d.ts +112 -0
- package/generated/ActivityKit.d.ts +7 -0
- package/generated/AppKit.d.ts +268 -0
- package/generated/CoreGraphics.d.ts +18 -0
- package/generated/CoreLocation.d.ts +28 -0
- package/generated/CoreMedia.d.ts +8 -0
- package/generated/Dispatch.d.ts +2 -0
- package/generated/Foundation.d.ts +13 -0
- package/generated/MapKit.d.ts +28 -0
- package/generated/Metal.d.ts +91 -0
- package/generated/MetalKit.d.ts +22 -0
- package/generated/Photos.d.ts +5 -0
- package/generated/QuartzCore.d.ts +12 -0
- package/generated/SwiftData.d.ts +11 -0
- package/generated/UIKit.d.ts +187 -0
- package/package.json +123 -0
- package/runtime/AVFoundation.js +136 -0
- package/runtime/ActivityKit.js +18 -0
- package/runtime/AppKit.js +313 -0
- package/runtime/CoreGraphics.js +15 -0
- package/runtime/CoreLocation.js +30 -0
- package/runtime/CoreMedia.js +7 -0
- package/runtime/Dispatch.js +11 -0
- package/runtime/Foundation.js +21 -0
- package/runtime/MapKit.js +22 -0
- package/runtime/Metal.js +126 -0
- package/runtime/MetalKit.js +15 -0
- package/runtime/Photos.js +9 -0
- package/runtime/QuartzCore.js +9 -0
- package/runtime/SwiftData.js +29 -0
- package/runtime/UIKit.js +242 -0
- package/runtime/empty.js +1 -0
- package/targets/ios/Info.plist.in +25 -0
- package/targets/ios/README.md +46 -0
- package/targets/ios/build-ios.sh +395 -0
- package/targets/ios/detect-development-team.mjs +109 -0
- package/targets/ios/generate-resident-entry.mjs +67 -0
- package/targets/ios/generate-resident-registry.mjs +162 -0
- package/targets/ios/generate-xcode-project.mjs +520 -0
- package/targets/ios/main/canvas_view.h +7 -0
- package/targets/ios/main/canvas_view.mm +94 -0
- package/targets/ios/main/font_registry.h +17 -0
- package/targets/ios/main/font_registry.mm +129 -0
- package/targets/ios/main/image_bridge.h +13 -0
- package/targets/ios/main/image_bridge.mm +53 -0
- package/targets/ios/main/ios_apps.c +28 -0
- package/targets/ios/main/ios_camera.mm +446 -0
- package/targets/ios/main/ios_display.mm +344 -0
- package/targets/ios/main/ios_main.mm +811 -0
- package/targets/ios/main/ios_memory.cpp +55 -0
- package/targets/ios/main/ios_renderer.h +20 -0
- package/targets/ios/main/ios_renderer.mm +27 -0
- package/targets/ios/main/ios_root_background.cpp +20 -0
- package/targets/ios/main/ios_root_background.h +11 -0
- package/targets/ios/main/ios_sensors.mm +244 -0
- package/targets/ios/main/ios_timers.mm +53 -0
- package/targets/ios/main/renderer/ios_renderer_internal.h +65 -0
- package/targets/ios/main/renderer/ios_renderer_support.mm +111 -0
- package/targets/ios/main/renderer/native_button.mm +179 -0
- package/targets/ios/main/renderer/native_label.mm +161 -0
- package/targets/ios/main/renderer/native_scroll_container.mm +279 -0
- package/targets/ios/main/renderer/view_reconciler.mm +274 -0
- package/targets/macos/Info.plist.in +26 -0
- package/targets/macos/README.md +131 -0
- package/targets/macos/build-child-lifecycle.sh +119 -0
- package/targets/macos/build-macos.sh +2156 -0
- package/targets/macos/check-module-graph-freshness.mjs +82 -0
- package/targets/macos/compiler-input-fingerprint.mjs +32 -0
- package/targets/macos/finalize-apple-native-output.mjs +77 -0
- package/targets/macos/generate-resident-entry.mjs +80 -0
- package/targets/macos/generate-resident-registry.mjs +141 -0
- package/targets/macos/heavy-build-lock.sh +176 -0
- package/targets/macos/main/canvas_view.h +9 -0
- package/targets/macos/main/canvas_view.mm +176 -0
- package/targets/macos/main/color_convert.h +18 -0
- package/targets/macos/main/color_convert.mm +39 -0
- package/targets/macos/main/font_registry.h +35 -0
- package/targets/macos/main/font_registry.mm +350 -0
- package/targets/macos/main/image_bridge.h +16 -0
- package/targets/macos/main/image_bridge.mm +52 -0
- package/targets/macos/main/macos_app_platform.mm +95 -0
- package/targets/macos/main/macos_apps.c +31 -0
- package/targets/macos/main/macos_device_control.mm +56 -0
- package/targets/macos/main/macos_display.mm +484 -0
- package/targets/macos/main/macos_host_device_control.mm +95 -0
- package/targets/macos/main/macos_main.mm +839 -0
- package/targets/macos/main/macos_memory.cpp +31 -0
- package/targets/macos/main/macos_native_shell.h +31 -0
- package/targets/macos/main/macos_native_shell.mm +396 -0
- package/targets/macos/main/macos_network.mm +253 -0
- package/targets/macos/main/macos_renderer.h +39 -0
- package/targets/macos/main/macos_renderer.mm +1969 -0
- package/targets/macos/main/macos_sensors.cpp +148 -0
- package/targets/macos/main/macos_smoke_app.cpp +65 -0
- package/targets/macos/main/macos_timers.mm +52 -0
- package/targets/macos/main/press_bridge.h +21 -0
- package/targets/macos/main/press_bridge.mm +72 -0
- package/targets/macos/main/thermalright_hid_display.h +14 -0
- package/targets/macos/main/thermalright_hid_display.mm +995 -0
- package/targets/macos/native-artifact-provenance.mjs +421 -0
- package/targets/macos/order-compile-edges.mjs +64 -0
- package/targets/macos/preflight-hard-muted-artifact.mjs +93 -0
- package/targets/macos/prune-generated-modules.mjs +147 -0
- package/targets/macos/sync-resources.mjs +153 -0
- package/targets/shared/analyze-audio-capture.mjs +131 -0
- package/targets/shared/apple_audio.mm +1194 -0
- package/targets/shared/apple_audio_selftest.mm +602 -0
- package/targets/shared/apple_audio_testing.h +34 -0
- package/targets/shared/build-audio-selftest.sh +50 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const args = new Set(process.argv.slice(2))
|
|
7
|
+
const outArgIndex = process.argv.indexOf('--out-dir')
|
|
8
|
+
const outDirValue = outArgIndex >= 0 ? process.argv[outArgIndex + 1] : ''
|
|
9
|
+
const dryRun = args.has('--dry-run')
|
|
10
|
+
|
|
11
|
+
if (!outDirValue) {
|
|
12
|
+
process.stderr.write('usage: prune-generated-modules.mjs --out-dir <generated-dir> [--dry-run]\n')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const outDir = path.resolve(outDirValue)
|
|
17
|
+
const modulesDir = path.join(outDir, 'modules')
|
|
18
|
+
const sourceListPath = path.join(outDir, 'geatsc-sources.txt')
|
|
19
|
+
const ownedManifestPath = path.join(outDir, '.gea-owned-modules.json')
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(modulesDir) || !fs.existsSync(sourceListPath)) {
|
|
22
|
+
process.stdout.write(`${JSON.stringify({ activeFamilies: 0, keptFiles: 0, removedFiles: 0 })}\n`)
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ownedModulePattern = /^(?:\d{4}_.+\.(?:cpp|hpp|types\.hpp)|__gea_data_batch_\d{4}\.cpp)$/
|
|
27
|
+
|
|
28
|
+
function familyOf(filename) {
|
|
29
|
+
return filename.replace(/\.types\.hpp$|\.hpp$|\.cpp$/, '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function familyFiles(family) {
|
|
33
|
+
return [`${family}.cpp`, `${family}.hpp`, `${family}.types.hpp`]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isInside(parent, candidate) {
|
|
37
|
+
const relative = path.relative(parent, candidate)
|
|
38
|
+
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A killed prior prune may have moved only part of its stale set. Restore that
|
|
42
|
+
// set before making a new plan; active files therefore never depend on the
|
|
43
|
+
// precise point at which the previous process died.
|
|
44
|
+
if (!dryRun) {
|
|
45
|
+
for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) {
|
|
46
|
+
if (!entry.isDirectory() || !entry.name.startsWith('.gea-prune-quarantine-')) continue
|
|
47
|
+
const quarantine = path.join(outDir, entry.name)
|
|
48
|
+
for (const filename of fs.readdirSync(quarantine)) {
|
|
49
|
+
const source = path.join(quarantine, filename)
|
|
50
|
+
const destination = path.join(modulesDir, filename)
|
|
51
|
+
if (!fs.existsSync(destination)) fs.renameSync(source, destination)
|
|
52
|
+
else fs.rmSync(source, { force: true })
|
|
53
|
+
}
|
|
54
|
+
fs.rmdirSync(quarantine)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const listedSources = fs.readFileSync(sourceListPath, 'utf8').split(/\r?\n/).filter(Boolean)
|
|
59
|
+
const activeFamilies = new Set()
|
|
60
|
+
for (const source of listedSources) {
|
|
61
|
+
const resolved = path.resolve(source)
|
|
62
|
+
if (!fs.existsSync(resolved)) {
|
|
63
|
+
throw new Error(`Refusing to prune: active source is missing: ${resolved}`)
|
|
64
|
+
}
|
|
65
|
+
if (path.dirname(resolved) === modulesDir && resolved.endsWith('.cpp')) {
|
|
66
|
+
activeFamilies.add(familyOf(path.basename(resolved)))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const moduleEntries = new Set(fs.readdirSync(modulesDir, { withFileTypes: true })
|
|
71
|
+
.filter((entry) => entry.isFile())
|
|
72
|
+
.map((entry) => entry.name))
|
|
73
|
+
const keptFiles = new Set()
|
|
74
|
+
const scanQueue = []
|
|
75
|
+
|
|
76
|
+
function keepFile(filename) {
|
|
77
|
+
if (!moduleEntries.has(filename) || keptFiles.has(filename)) return
|
|
78
|
+
keptFiles.add(filename)
|
|
79
|
+
scanQueue.push(filename)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function keepFamily(family) {
|
|
83
|
+
for (const filename of familyFiles(family)) keepFile(filename)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const family of activeFamilies) keepFamily(family)
|
|
87
|
+
|
|
88
|
+
// Follow local quoted includes from every active family. This is the safety
|
|
89
|
+
// valve for a genuinely header-only generated support family: it remains even
|
|
90
|
+
// though it has no entry in geatsc-sources.txt.
|
|
91
|
+
const includePattern = /^\s*#\s*include\s+"([^"]+)"/gm
|
|
92
|
+
for (let index = 0; index < scanQueue.length; index += 1) {
|
|
93
|
+
const filename = scanQueue[index]
|
|
94
|
+
const filePath = path.join(modulesDir, filename)
|
|
95
|
+
const contents = fs.readFileSync(filePath, 'utf8')
|
|
96
|
+
includePattern.lastIndex = 0
|
|
97
|
+
let match
|
|
98
|
+
while ((match = includePattern.exec(contents)) !== null) {
|
|
99
|
+
const included = path.resolve(path.dirname(filePath), match[1])
|
|
100
|
+
if (!isInside(modulesDir, included) || !fs.existsSync(included)) continue
|
|
101
|
+
const includedName = path.basename(included)
|
|
102
|
+
keepFile(includedName)
|
|
103
|
+
keepFamily(familyOf(includedName))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ownedFiles = [...moduleEntries].filter((filename) => ownedModulePattern.test(filename)).sort()
|
|
108
|
+
const staleFiles = ownedFiles.filter((filename) => !keptFiles.has(filename))
|
|
109
|
+
const remainingOwnedFiles = ownedFiles.filter((filename) => keptFiles.has(filename))
|
|
110
|
+
|
|
111
|
+
const result = {
|
|
112
|
+
activeFamilies: activeFamilies.size,
|
|
113
|
+
keptFiles: keptFiles.size,
|
|
114
|
+
removedFiles: staleFiles.length,
|
|
115
|
+
staleFiles,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (dryRun) {
|
|
119
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
120
|
+
process.exit(0)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const quarantine = path.join(outDir, `.gea-prune-quarantine-${process.pid}`)
|
|
124
|
+
fs.mkdirSync(quarantine)
|
|
125
|
+
const moved = []
|
|
126
|
+
try {
|
|
127
|
+
for (const filename of staleFiles) {
|
|
128
|
+
fs.renameSync(path.join(modulesDir, filename), path.join(quarantine, filename))
|
|
129
|
+
moved.push(filename)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const manifest = `${JSON.stringify({ version: 1, files: remainingOwnedFiles }, null, 2)}\n`
|
|
133
|
+
const manifestTemporary = `${ownedManifestPath}.tmp.${process.pid}`
|
|
134
|
+
fs.writeFileSync(manifestTemporary, manifest)
|
|
135
|
+
fs.renameSync(manifestTemporary, ownedManifestPath)
|
|
136
|
+
fs.rmSync(quarantine, { recursive: true, force: true })
|
|
137
|
+
} catch (error) {
|
|
138
|
+
for (const filename of moved.reverse()) {
|
|
139
|
+
const source = path.join(quarantine, filename)
|
|
140
|
+
const destination = path.join(modulesDir, filename)
|
|
141
|
+
if (fs.existsSync(source) && !fs.existsSync(destination)) fs.renameSync(source, destination)
|
|
142
|
+
}
|
|
143
|
+
fs.rmSync(quarantine, { recursive: true, force: true })
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2)
|
|
7
|
+
|
|
8
|
+
function values(name) {
|
|
9
|
+
const out = []
|
|
10
|
+
const prefix = `${name}=`
|
|
11
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
12
|
+
if (args[index] === name) out.push(args[index + 1] || '')
|
|
13
|
+
else if (args[index].startsWith(prefix)) out.push(args[index].slice(prefix.length))
|
|
14
|
+
}
|
|
15
|
+
return out.filter(Boolean)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function value(name) {
|
|
19
|
+
return values(name).at(-1) || ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fail(message) {
|
|
23
|
+
process.stderr.write(`${message}\n`)
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resourcesDir = path.resolve(value('--resources-dir') || '')
|
|
28
|
+
const manifestPath = path.resolve(value('--manifest') || '')
|
|
29
|
+
const configDir = value('--config-dir') ? path.resolve(value('--config-dir')) : ''
|
|
30
|
+
const appDirs = values('--app-dir').map((item) => path.resolve(item))
|
|
31
|
+
const sharedCssDirs = values('--shared-css-dir').map((item) => path.resolve(item))
|
|
32
|
+
|
|
33
|
+
if (!value('--resources-dir') || !value('--manifest')) {
|
|
34
|
+
fail('usage: sync-resources.mjs --resources-dir <dir> --manifest <file> [--config-dir <dir>] [--app-dir <dir> ...] [--shared-css-dir <dir> ...]')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(resourcesDir, { recursive: true })
|
|
38
|
+
|
|
39
|
+
let changed = false
|
|
40
|
+
|
|
41
|
+
function sameFileContent(source, destination) {
|
|
42
|
+
if (!fs.existsSync(destination)) return false
|
|
43
|
+
const sourceStat = fs.statSync(source)
|
|
44
|
+
const destinationStat = fs.statSync(destination)
|
|
45
|
+
if (sourceStat.size !== destinationStat.size) return false
|
|
46
|
+
return fs.readFileSync(source).equals(fs.readFileSync(destination))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function syncFile(source, destination) {
|
|
50
|
+
if (sameFileContent(source, destination)) return
|
|
51
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
|
52
|
+
const temporary = `${destination}.tmp.${process.pid}`
|
|
53
|
+
fs.copyFileSync(source, temporary)
|
|
54
|
+
fs.renameSync(temporary, destination)
|
|
55
|
+
changed = true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function removeFile(file) {
|
|
59
|
+
try {
|
|
60
|
+
fs.lstatSync(file)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === 'ENOENT') return
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
fs.rmSync(file, { force: true })
|
|
66
|
+
changed = true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isSafeBasename(value) {
|
|
70
|
+
return value !== '.' && value !== '..' && path.basename(value) === value && !value.includes('/') && !value.includes('\\')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walkCss(root, visit) {
|
|
74
|
+
if (!root || !fs.existsSync(root)) return
|
|
75
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
76
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue
|
|
77
|
+
const full = path.join(root, entry.name)
|
|
78
|
+
if (entry.isDirectory()) walkCss(full, visit)
|
|
79
|
+
else if (entry.isFile() && entry.name.endsWith('.css')) visit(full)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Match the build's historical first-source-wins behavior for basename
|
|
84
|
+
// collisions, but keep an owned manifest so removed @font-face declarations do
|
|
85
|
+
// not leave stale resources in the app bundle.
|
|
86
|
+
const desiredFonts = new Map()
|
|
87
|
+
for (const root of [...appDirs, ...sharedCssDirs]) {
|
|
88
|
+
walkCss(root, (cssPath) => {
|
|
89
|
+
const css = fs.readFileSync(cssPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
90
|
+
const facePattern = /@font-face\s*\{([^}]+)\}/gi
|
|
91
|
+
let face
|
|
92
|
+
while ((face = facePattern.exec(css)) !== null) {
|
|
93
|
+
const source = face[1].match(/src\s*:\s*url\(\s*(?:"([^"]+)"|'([^']+)'|([^)"']+))\s*\)/i)
|
|
94
|
+
if (!source) continue
|
|
95
|
+
const raw = (source[1] || source[2] || source[3] || '').trim()
|
|
96
|
+
if (!raw || /^https?:\/\//i.test(raw) || raw.startsWith('data:')) continue
|
|
97
|
+
const clean = raw.split(/[?#]/, 1)[0]
|
|
98
|
+
const resolved = path.resolve(path.dirname(cssPath), clean)
|
|
99
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) continue
|
|
100
|
+
const basename = path.basename(resolved)
|
|
101
|
+
if (!desiredFonts.has(basename)) desiredFonts.set(basename, resolved)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let previousFonts = []
|
|
107
|
+
try {
|
|
108
|
+
const previous = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
109
|
+
if (Array.isArray(previous.fonts)) {
|
|
110
|
+
previousFonts = previous.fonts.filter((item) => typeof item === 'string' && isSafeBasename(item))
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// A missing/old manifest simply means there are no previously-owned fonts.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fontsDir = path.join(resourcesDir, 'Fonts')
|
|
117
|
+
// Fonts/ is wholly owned by this stage. Removing every undesired regular entry
|
|
118
|
+
// also cleans resources left by builds from before the ownership manifest was
|
|
119
|
+
// introduced and temporary files left by a killed copy.
|
|
120
|
+
if (fs.existsSync(fontsDir)) {
|
|
121
|
+
for (const entry of fs.readdirSync(fontsDir, { withFileTypes: true })) {
|
|
122
|
+
if ((entry.isFile() || entry.isSymbolicLink()) && !desiredFonts.has(entry.name)) {
|
|
123
|
+
removeFile(path.join(fontsDir, entry.name))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const basename of previousFonts) {
|
|
128
|
+
if (!desiredFonts.has(basename) && isSafeBasename(basename)) removeFile(path.join(fontsDir, basename))
|
|
129
|
+
}
|
|
130
|
+
for (const [basename, source] of desiredFonts) {
|
|
131
|
+
syncFile(source, path.join(fontsDir, basename))
|
|
132
|
+
}
|
|
133
|
+
if (fs.existsSync(fontsDir) && fs.readdirSync(fontsDir).length === 0) {
|
|
134
|
+
fs.rmdirSync(fontsDir)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const windowConfig = path.join(resourcesDir, 'window.json')
|
|
138
|
+
for (const entry of fs.readdirSync(resourcesDir, { withFileTypes: true })) {
|
|
139
|
+
if (entry.isFile() && /^window\.json\.tmp\.\d+$/.test(entry.name)) removeFile(path.join(resourcesDir, entry.name))
|
|
140
|
+
}
|
|
141
|
+
const configSource = configDir ? path.join(configDir, 'macos.json') : ''
|
|
142
|
+
if (configSource && fs.existsSync(configSource)) syncFile(configSource, windowConfig)
|
|
143
|
+
else removeFile(windowConfig)
|
|
144
|
+
|
|
145
|
+
const manifest = `${JSON.stringify({ fonts: [...desiredFonts.keys()].sort() }, null, 2)}\n`
|
|
146
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
|
|
147
|
+
if (!fs.existsSync(manifestPath) || fs.readFileSync(manifestPath, 'utf8') !== manifest) {
|
|
148
|
+
const temporary = `${manifestPath}.tmp.${process.pid}`
|
|
149
|
+
fs.writeFileSync(temporary, manifest)
|
|
150
|
+
fs.renameSync(temporary, manifestPath)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
process.stdout.write(changed ? 'changed=1\n' : 'changed=0\n')
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Measures a capture produced by GEA_AUDIO_CAPTURE_RAW (see apple_audio.mm):
|
|
3
|
+
// raw interleaved float32 stereo at 48 kHz, written by the audio backend while a
|
|
4
|
+
// real app runs. Reports level, note onsets and the measured fundamental of each
|
|
5
|
+
// note, so "the app really made sound" is a measurement rather than a claim.
|
|
6
|
+
//
|
|
7
|
+
// node analyze-audio-capture.mjs <capture.f32> [--rate 48000] [--notes 20]
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2)
|
|
12
|
+
const path = args.find((a) => !a.startsWith('--'))
|
|
13
|
+
if (!path) {
|
|
14
|
+
console.error('usage: analyze-audio-capture.mjs <capture.f32> [--rate 48000] [--notes 20]')
|
|
15
|
+
process.exit(2)
|
|
16
|
+
}
|
|
17
|
+
const option = (name, fallback) => {
|
|
18
|
+
const index = args.indexOf(`--${name}`)
|
|
19
|
+
return index >= 0 && args[index + 1] ? Number(args[index + 1]) : fallback
|
|
20
|
+
}
|
|
21
|
+
const rate = option('rate', 48000)
|
|
22
|
+
const maxNotes = option('notes', 20)
|
|
23
|
+
|
|
24
|
+
const bytes = readFileSync(path)
|
|
25
|
+
const samples = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.length / 4))
|
|
26
|
+
const frames = Math.floor(samples.length / 2)
|
|
27
|
+
const left = new Float32Array(frames)
|
|
28
|
+
for (let frame = 0; frame < frames; frame++) left[frame] = samples[frame * 2]
|
|
29
|
+
|
|
30
|
+
const rms = (from, to) => {
|
|
31
|
+
let sum = 0
|
|
32
|
+
for (let i = from; i < to; i++) sum += left[i] * left[i]
|
|
33
|
+
return Math.sqrt(sum / Math.max(1, to - from))
|
|
34
|
+
}
|
|
35
|
+
const peak = (from, to) => {
|
|
36
|
+
let worst = 0
|
|
37
|
+
for (let i = from; i < to; i++) worst = Math.max(worst, Math.abs(left[i]))
|
|
38
|
+
return worst
|
|
39
|
+
}
|
|
40
|
+
// Fundamental by hysteretic zero crossings — exact enough for the synth's
|
|
41
|
+
// single-tone output.
|
|
42
|
+
const frequency = (from, to) => {
|
|
43
|
+
const threshold = 0.15 * peak(from, to)
|
|
44
|
+
if (threshold <= 0) return 0
|
|
45
|
+
let sign = 0
|
|
46
|
+
let crossings = 0
|
|
47
|
+
let first = 0
|
|
48
|
+
let last = 0
|
|
49
|
+
for (let i = from; i < to; i++) {
|
|
50
|
+
const value = left[i]
|
|
51
|
+
let next = sign
|
|
52
|
+
if (value > threshold) next = 1
|
|
53
|
+
else if (value < -threshold) next = -1
|
|
54
|
+
if (next !== sign && sign !== 0) {
|
|
55
|
+
if (crossings === 0) first = i
|
|
56
|
+
last = i
|
|
57
|
+
crossings++
|
|
58
|
+
}
|
|
59
|
+
sign = next
|
|
60
|
+
}
|
|
61
|
+
if (crossings < 3 || last <= first) return 0
|
|
62
|
+
return (crossings - 1) / (2 * ((last - first) / rate))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Envelope-follow in 1 ms hops; a note is a run above the gate.
|
|
66
|
+
const hop = Math.max(1, Math.round(rate / 1000))
|
|
67
|
+
const hops = Math.floor(frames / hop)
|
|
68
|
+
const envelope = new Float64Array(hops)
|
|
69
|
+
for (let h = 0; h < hops; h++) envelope[h] = rms(h * hop, (h + 1) * hop)
|
|
70
|
+
const loudest = envelope.reduce((a, b) => Math.max(a, b), 0)
|
|
71
|
+
const gate = Math.max(1e-4, loudest * 0.1)
|
|
72
|
+
|
|
73
|
+
const notes = []
|
|
74
|
+
let start = -1
|
|
75
|
+
for (let h = 0; h < hops; h++) {
|
|
76
|
+
const loud = envelope[h] > gate
|
|
77
|
+
if (loud && start < 0) start = h
|
|
78
|
+
if (!loud && start >= 0) {
|
|
79
|
+
if (h - start >= 8) notes.push([start, h])
|
|
80
|
+
start = -1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (start >= 0 && hops - start >= 8) notes.push([start, hops])
|
|
84
|
+
|
|
85
|
+
const soundingHops = envelope.reduce((count, value) => count + (value > gate ? 1 : 0), 0)
|
|
86
|
+
|
|
87
|
+
console.log(`file ${path}`)
|
|
88
|
+
console.log(`frames ${frames} stereo @ ${rate} Hz (${(frames / rate).toFixed(3)} s)`)
|
|
89
|
+
console.log(`overall rms ${rms(0, frames).toFixed(6)}`)
|
|
90
|
+
console.log(`peak ${peak(0, frames).toFixed(6)}`)
|
|
91
|
+
console.log(`sounding ${((100 * soundingHops) / hops).toFixed(1)}% of the capture`)
|
|
92
|
+
console.log(`silent ${((100 * (hops - soundingHops)) / hops).toFixed(1)}% of the capture`)
|
|
93
|
+
console.log(`gated runs ${notes.length} (a legato melody is one run, see the pitch track)`)
|
|
94
|
+
console.log('')
|
|
95
|
+
|
|
96
|
+
// Pitch track: a melody whose notes butt up against each other never dips below
|
|
97
|
+
// the gate, so segment by PITCH instead. 30 ms windows every 10 ms, merged into
|
|
98
|
+
// runs whose fundamental agrees within 3%.
|
|
99
|
+
const windowFrames = Math.round(0.03 * rate)
|
|
100
|
+
const stepFrames = Math.round(0.01 * rate)
|
|
101
|
+
const track = []
|
|
102
|
+
for (let from = 0; from + windowFrames <= frames; from += stepFrames) {
|
|
103
|
+
track.push({ from, hz: frequency(from, from + windowFrames), rms: rms(from, from + windowFrames) })
|
|
104
|
+
}
|
|
105
|
+
const runs = []
|
|
106
|
+
for (const point of track) {
|
|
107
|
+
if (point.hz <= 0 || point.rms <= gate) {
|
|
108
|
+
runs.push(null)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
|
112
|
+
if (last && Math.abs(point.hz - last.hz) / last.hz < 0.03) {
|
|
113
|
+
last.to = point.from + windowFrames
|
|
114
|
+
last.hz = (last.hz * last.count + point.hz) / (last.count + 1)
|
|
115
|
+
last.count++
|
|
116
|
+
} else {
|
|
117
|
+
runs.push({ from: point.from, to: point.from + windowFrames, hz: point.hz, count: 1 })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const melody = runs.filter((run) => run && run.to - run.from >= Math.round(0.05 * rate))
|
|
121
|
+
console.log(`pitch runs ${melody.length}`)
|
|
122
|
+
console.log('')
|
|
123
|
+
console.log(' # start(s) dur(ms) rms freq(Hz)')
|
|
124
|
+
for (const [index, run] of melody.slice(0, maxNotes).entries()) {
|
|
125
|
+
console.log(
|
|
126
|
+
` ${String(index + 1).padStart(2)} ${(run.from / rate).toFixed(4)} ` +
|
|
127
|
+
`${String(Math.round((1000 * (run.to - run.from)) / rate)).padStart(6)} ` +
|
|
128
|
+
`${rms(run.from, run.to).toFixed(5)} ${run.hz.toFixed(1).padStart(9)}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (melody.length > maxNotes) console.log(` ... ${melody.length - maxNotes} more`)
|