@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -0
  3. package/dist/index.d.ts +127 -0
  4. package/dist/index.js +3076 -0
  5. package/generated/AVFoundation.d.ts +112 -0
  6. package/generated/ActivityKit.d.ts +7 -0
  7. package/generated/AppKit.d.ts +268 -0
  8. package/generated/CoreGraphics.d.ts +18 -0
  9. package/generated/CoreLocation.d.ts +28 -0
  10. package/generated/CoreMedia.d.ts +8 -0
  11. package/generated/Dispatch.d.ts +2 -0
  12. package/generated/Foundation.d.ts +13 -0
  13. package/generated/MapKit.d.ts +28 -0
  14. package/generated/Metal.d.ts +91 -0
  15. package/generated/MetalKit.d.ts +22 -0
  16. package/generated/Photos.d.ts +5 -0
  17. package/generated/QuartzCore.d.ts +12 -0
  18. package/generated/SwiftData.d.ts +11 -0
  19. package/generated/UIKit.d.ts +187 -0
  20. package/package.json +123 -0
  21. package/runtime/AVFoundation.js +136 -0
  22. package/runtime/ActivityKit.js +18 -0
  23. package/runtime/AppKit.js +313 -0
  24. package/runtime/CoreGraphics.js +15 -0
  25. package/runtime/CoreLocation.js +30 -0
  26. package/runtime/CoreMedia.js +7 -0
  27. package/runtime/Dispatch.js +11 -0
  28. package/runtime/Foundation.js +21 -0
  29. package/runtime/MapKit.js +22 -0
  30. package/runtime/Metal.js +126 -0
  31. package/runtime/MetalKit.js +15 -0
  32. package/runtime/Photos.js +9 -0
  33. package/runtime/QuartzCore.js +9 -0
  34. package/runtime/SwiftData.js +29 -0
  35. package/runtime/UIKit.js +242 -0
  36. package/runtime/empty.js +1 -0
  37. package/targets/ios/Info.plist.in +25 -0
  38. package/targets/ios/README.md +46 -0
  39. package/targets/ios/build-ios.sh +395 -0
  40. package/targets/ios/detect-development-team.mjs +109 -0
  41. package/targets/ios/generate-resident-entry.mjs +67 -0
  42. package/targets/ios/generate-resident-registry.mjs +162 -0
  43. package/targets/ios/generate-xcode-project.mjs +520 -0
  44. package/targets/ios/main/canvas_view.h +7 -0
  45. package/targets/ios/main/canvas_view.mm +94 -0
  46. package/targets/ios/main/font_registry.h +17 -0
  47. package/targets/ios/main/font_registry.mm +129 -0
  48. package/targets/ios/main/image_bridge.h +13 -0
  49. package/targets/ios/main/image_bridge.mm +53 -0
  50. package/targets/ios/main/ios_apps.c +28 -0
  51. package/targets/ios/main/ios_camera.mm +446 -0
  52. package/targets/ios/main/ios_display.mm +344 -0
  53. package/targets/ios/main/ios_main.mm +811 -0
  54. package/targets/ios/main/ios_memory.cpp +55 -0
  55. package/targets/ios/main/ios_renderer.h +20 -0
  56. package/targets/ios/main/ios_renderer.mm +27 -0
  57. package/targets/ios/main/ios_root_background.cpp +20 -0
  58. package/targets/ios/main/ios_root_background.h +11 -0
  59. package/targets/ios/main/ios_sensors.mm +244 -0
  60. package/targets/ios/main/ios_timers.mm +53 -0
  61. package/targets/ios/main/renderer/ios_renderer_internal.h +65 -0
  62. package/targets/ios/main/renderer/ios_renderer_support.mm +111 -0
  63. package/targets/ios/main/renderer/native_button.mm +179 -0
  64. package/targets/ios/main/renderer/native_label.mm +161 -0
  65. package/targets/ios/main/renderer/native_scroll_container.mm +279 -0
  66. package/targets/ios/main/renderer/view_reconciler.mm +274 -0
  67. package/targets/macos/Info.plist.in +26 -0
  68. package/targets/macos/README.md +131 -0
  69. package/targets/macos/build-child-lifecycle.sh +119 -0
  70. package/targets/macos/build-macos.sh +2156 -0
  71. package/targets/macos/check-module-graph-freshness.mjs +82 -0
  72. package/targets/macos/compiler-input-fingerprint.mjs +32 -0
  73. package/targets/macos/finalize-apple-native-output.mjs +77 -0
  74. package/targets/macos/generate-resident-entry.mjs +80 -0
  75. package/targets/macos/generate-resident-registry.mjs +141 -0
  76. package/targets/macos/heavy-build-lock.sh +176 -0
  77. package/targets/macos/main/canvas_view.h +9 -0
  78. package/targets/macos/main/canvas_view.mm +176 -0
  79. package/targets/macos/main/color_convert.h +18 -0
  80. package/targets/macos/main/color_convert.mm +39 -0
  81. package/targets/macos/main/font_registry.h +35 -0
  82. package/targets/macos/main/font_registry.mm +350 -0
  83. package/targets/macos/main/image_bridge.h +16 -0
  84. package/targets/macos/main/image_bridge.mm +52 -0
  85. package/targets/macos/main/macos_app_platform.mm +95 -0
  86. package/targets/macos/main/macos_apps.c +31 -0
  87. package/targets/macos/main/macos_device_control.mm +56 -0
  88. package/targets/macos/main/macos_display.mm +484 -0
  89. package/targets/macos/main/macos_host_device_control.mm +95 -0
  90. package/targets/macos/main/macos_main.mm +839 -0
  91. package/targets/macos/main/macos_memory.cpp +31 -0
  92. package/targets/macos/main/macos_native_shell.h +31 -0
  93. package/targets/macos/main/macos_native_shell.mm +396 -0
  94. package/targets/macos/main/macos_network.mm +253 -0
  95. package/targets/macos/main/macos_renderer.h +39 -0
  96. package/targets/macos/main/macos_renderer.mm +1969 -0
  97. package/targets/macos/main/macos_sensors.cpp +148 -0
  98. package/targets/macos/main/macos_smoke_app.cpp +65 -0
  99. package/targets/macos/main/macos_timers.mm +52 -0
  100. package/targets/macos/main/press_bridge.h +21 -0
  101. package/targets/macos/main/press_bridge.mm +72 -0
  102. package/targets/macos/main/thermalright_hid_display.h +14 -0
  103. package/targets/macos/main/thermalright_hid_display.mm +995 -0
  104. package/targets/macos/native-artifact-provenance.mjs +421 -0
  105. package/targets/macos/order-compile-edges.mjs +64 -0
  106. package/targets/macos/preflight-hard-muted-artifact.mjs +93 -0
  107. package/targets/macos/prune-generated-modules.mjs +147 -0
  108. package/targets/macos/sync-resources.mjs +153 -0
  109. package/targets/shared/analyze-audio-capture.mjs +131 -0
  110. package/targets/shared/apple_audio.mm +1194 -0
  111. package/targets/shared/apple_audio_selftest.mm +602 -0
  112. package/targets/shared/apple_audio_testing.h +34 -0
  113. 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`)