@hraness/dawg 0.6.1 → 0.7.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 +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +160 -28
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +759 -177
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/project/sync.ts
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two-way sync between the project files and the session score.
|
|
3
3
|
*
|
|
4
|
-
* files → score: a change to `song.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* reported as a card and
|
|
4
|
+
* files → score: a change to any project source (`song.ts`, track files,
|
|
5
|
+
* helper modules they import, tuning files; fs.watch plus a slow poll,
|
|
6
|
+
* debounced 40 ms) evaluates the project, adopts the session's note ids,
|
|
7
|
+
* diffs against the current score and commits one `files.apply` revision
|
|
8
|
+
* through the session port. A failed evaluation is reported as a card and
|
|
9
|
+
* leaves the score alone.
|
|
9
10
|
*
|
|
10
11
|
* score → files: a new score revision reprints the project and writes only
|
|
11
|
-
* the files whose
|
|
12
|
-
*
|
|
12
|
+
* the files whose own slice of the score changed, so an author's formatting
|
|
13
|
+
* and comments in untouched files survive. A file edited since dawg last
|
|
14
|
+
* synced it (hash differs from `.dawg/sync.json`) is never overwritten: the
|
|
15
|
+
* edit wins and a card says so.
|
|
13
16
|
*
|
|
14
|
-
* Echo suppression: `.dawg/sync.json` records the hash of every file
|
|
15
|
-
*
|
|
16
|
-
* this window and every other one.
|
|
17
|
+
* Echo suppression: `.dawg/sync.json` records the hash of every file as of
|
|
18
|
+
* the last time files and score agreed; a watcher event whose changed files
|
|
19
|
+
* all still hash the same is ignored, in this window and every other one.
|
|
20
|
+
*
|
|
21
|
+
* Ownership: `.dawg/sync.json` names the session the files belong to and
|
|
22
|
+
* the live windows syncing it. A window on a different session while the
|
|
23
|
+
* owner is still open never reads or writes the files.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
|
-
import { createHash } from "node:crypto";
|
|
20
|
-
import { watch, type FSWatcher } from "node:fs";
|
|
21
|
-
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
26
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
27
|
+
import { watch, type Dirent, type FSWatcher } from "node:fs";
|
|
28
|
+
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
22
29
|
import { join } from "node:path";
|
|
23
30
|
import {
|
|
24
31
|
adoptNoteIds,
|
|
@@ -34,10 +41,15 @@ import {
|
|
|
34
41
|
type EvalResult,
|
|
35
42
|
} from "../../core/sdk/eval.ts";
|
|
36
43
|
import { printProject } from "../../core/sdk/print.ts";
|
|
44
|
+
import { acquireSessionLock } from "../session/lock.ts";
|
|
37
45
|
import { writeAtomic } from "./init.ts";
|
|
38
46
|
import { typecheckProject, type TypecheckResult } from "./typecheck.ts";
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Quiet time after a file event before evaluating: long enough to coalesce
|
|
50
|
+
* an editor's write-then-rename, short enough that a save feels immediate.
|
|
51
|
+
*/
|
|
52
|
+
export const SYNC_DEBOUNCE_MS = 40;
|
|
41
53
|
export const SYNC_POLL_MS = 1_500;
|
|
42
54
|
const SYNC_STATE_FILE = ".dawg/sync.json";
|
|
43
55
|
const MAX_TRACKED_FILES = 512;
|
|
@@ -73,6 +85,11 @@ export function applyFiles(
|
|
|
73
85
|
/** What the sync needs from its window. */
|
|
74
86
|
export type SyncHost = Readonly<{
|
|
75
87
|
project: string;
|
|
88
|
+
/**
|
|
89
|
+
* The session this window is attached to; it may change when the window
|
|
90
|
+
* switches sessions. Omitted, the files are never treated as owned.
|
|
91
|
+
*/
|
|
92
|
+
sessionId?: () => string | undefined;
|
|
76
93
|
/** Current session score. */
|
|
77
94
|
current(): TrackScore;
|
|
78
95
|
/**
|
|
@@ -111,7 +128,15 @@ export type ProjectSync = Readonly<{
|
|
|
111
128
|
stop(): Promise<void>;
|
|
112
129
|
}>;
|
|
113
130
|
|
|
114
|
-
type
|
|
131
|
+
type Holder = { pid: number; token: string };
|
|
132
|
+
type SyncOwner = { sessionId: string; holders: Holder[] };
|
|
133
|
+
type SyncState = { files: Record<string, string>; owner?: SyncOwner };
|
|
134
|
+
|
|
135
|
+
/** Tokens of the syncs running in this process (pids alone cannot tell them apart). */
|
|
136
|
+
const liveTokens = new Set<string>();
|
|
137
|
+
const SYNC_LOCK_FILE = ".dawg/sync.lock";
|
|
138
|
+
const MAX_HOLDERS = 64;
|
|
139
|
+
const TRACK_FILE = /^tracks\/[^/]+\/track\.ts$/;
|
|
115
140
|
|
|
116
141
|
export function startProjectSync(
|
|
117
142
|
host: SyncHost,
|
|
@@ -124,13 +149,24 @@ export function startProjectSync(
|
|
|
124
149
|
const debounceMs = options.debounceMs ?? SYNC_DEBOUNCE_MS;
|
|
125
150
|
const project = host.project;
|
|
126
151
|
const statePath = join(project, SYNC_STATE_FILE);
|
|
127
|
-
|
|
152
|
+
const lockPath = join(project, SYNC_LOCK_FILE);
|
|
153
|
+
const token = randomUUID();
|
|
154
|
+
liveTokens.add(token);
|
|
155
|
+
/** Hashes of the project sources as of this window's last look. */
|
|
128
156
|
let seen = new Map<string, string>();
|
|
129
157
|
/** Outcome of the last evaluation, returned when the files did not change. */
|
|
130
158
|
let lastOutcome = "files match the score";
|
|
131
159
|
/** Encoded score the files were last known to equal. */
|
|
132
160
|
let filesEqual = "";
|
|
161
|
+
/** Per file: the canonical print its current contents are known to evaluate to. */
|
|
162
|
+
let known = new Map<string, string>();
|
|
133
163
|
let lastRejection = "";
|
|
164
|
+
let lastConflict = "";
|
|
165
|
+
/** Session id this window last claimed the files for; undefined while detached. */
|
|
166
|
+
let attached: string | undefined;
|
|
167
|
+
let detachedNotice = "";
|
|
168
|
+
/** The next look at the files decides who wins (startup, session switch). */
|
|
169
|
+
let needsStartup = true;
|
|
134
170
|
let stopped = false;
|
|
135
171
|
let queue: Promise<void> = Promise.resolve();
|
|
136
172
|
let fileTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -152,25 +188,99 @@ export function startProjectSync(
|
|
|
152
188
|
try {
|
|
153
189
|
const value: unknown = JSON.parse(await readFile(statePath, "utf8"));
|
|
154
190
|
if (typeof value !== "object" || value === null) return { files: {} };
|
|
155
|
-
const
|
|
156
|
-
|
|
191
|
+
const record = value as Record<string, unknown>;
|
|
192
|
+
const files = record.files;
|
|
157
193
|
const out: Record<string, string> = {};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
if (typeof files === "object" && files !== null)
|
|
195
|
+
for (const [path, hash] of Object.entries(
|
|
196
|
+
files as Record<string, unknown>,
|
|
197
|
+
))
|
|
198
|
+
if (
|
|
199
|
+
typeof hash === "string" &&
|
|
200
|
+
/^[0-9a-f]{64}$/.test(hash) &&
|
|
201
|
+
path.length < 512
|
|
202
|
+
)
|
|
203
|
+
out[path] = hash;
|
|
204
|
+
const state: SyncState = { files: out };
|
|
205
|
+
const owner = parseOwner(record.owner);
|
|
206
|
+
if (owner) state.owner = owner;
|
|
207
|
+
return state;
|
|
168
208
|
} catch {
|
|
169
209
|
return { files: {} };
|
|
170
210
|
}
|
|
171
211
|
};
|
|
172
212
|
const writeState = (state: SyncState) =>
|
|
173
213
|
writeAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
214
|
+
/** Serializes every read-modify-write of sync.json and the files across windows. */
|
|
215
|
+
const locked = async <T>(task: () => Promise<T>): Promise<T> => {
|
|
216
|
+
await mkdir(join(project, ".dawg"), { recursive: true });
|
|
217
|
+
const release = await acquireSessionLock(lockPath);
|
|
218
|
+
try {
|
|
219
|
+
return await task();
|
|
220
|
+
} finally {
|
|
221
|
+
await release();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Claims the files for this window's session. A different session owns
|
|
227
|
+
* them while any of its windows is still open: this window then stays
|
|
228
|
+
* detached and never touches the files. Returns whether the files are
|
|
229
|
+
* this window's; a changed session makes the next look a fresh startup.
|
|
230
|
+
*/
|
|
231
|
+
const claim = async (): Promise<{ ok: boolean }> => {
|
|
232
|
+
const sessionId = host.sessionId?.();
|
|
233
|
+
if (sessionId === undefined) return { ok: true };
|
|
234
|
+
const result = await locked(async () => {
|
|
235
|
+
const state = await readState();
|
|
236
|
+
const owner = state.owner;
|
|
237
|
+
const others = (owner?.holders ?? []).filter(
|
|
238
|
+
(holder) => holder.token !== token && holderAlive(holder),
|
|
239
|
+
);
|
|
240
|
+
if (owner && owner.sessionId !== sessionId && others.length > 0)
|
|
241
|
+
return { ok: false, owner: owner.sessionId };
|
|
242
|
+
const holders =
|
|
243
|
+
owner?.sessionId === sessionId
|
|
244
|
+
? [...others, { pid: process.pid, token }]
|
|
245
|
+
: [{ pid: process.pid, token }];
|
|
246
|
+
const next: SyncOwner = {
|
|
247
|
+
sessionId,
|
|
248
|
+
holders: holders.slice(-MAX_HOLDERS),
|
|
249
|
+
};
|
|
250
|
+
if (JSON.stringify(next) !== JSON.stringify(owner))
|
|
251
|
+
await writeState({ ...state, owner: next });
|
|
252
|
+
return { ok: true, owner: sessionId };
|
|
253
|
+
});
|
|
254
|
+
if (!result.ok) {
|
|
255
|
+
attached = undefined;
|
|
256
|
+
const notice = `files belong to session ${result.owner}`;
|
|
257
|
+
if (notice !== detachedNotice) {
|
|
258
|
+
detachedNotice = notice;
|
|
259
|
+
host.card(
|
|
260
|
+
`${notice} · not synced here`,
|
|
261
|
+
"warning",
|
|
262
|
+
"another window has them open; close it or switch to that session",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return { ok: false };
|
|
266
|
+
}
|
|
267
|
+
detachedNotice = "";
|
|
268
|
+
if (attached !== sessionId) needsStartup = true;
|
|
269
|
+
attached = sessionId;
|
|
270
|
+
return { ok: true };
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const recordSynced = async (hashes: Map<string, string>): Promise<void> => {
|
|
274
|
+
await locked(async () => {
|
|
275
|
+
const state = await readState();
|
|
276
|
+
const files = { ...state.files };
|
|
277
|
+
for (const [path, hash] of hashes) files[path] = hash;
|
|
278
|
+
for (const path of Object.keys(files))
|
|
279
|
+
if (!hashes.has(path) && !(await exists(join(project, path))))
|
|
280
|
+
delete files[path];
|
|
281
|
+
await writeState({ ...state, files });
|
|
282
|
+
});
|
|
283
|
+
};
|
|
174
284
|
|
|
175
285
|
/**
|
|
176
286
|
* Evaluates the files. In `files-win` mode (edits) the result is committed;
|
|
@@ -178,11 +288,51 @@ export function startProjectSync(
|
|
|
178
288
|
* the files from the score instead.
|
|
179
289
|
*/
|
|
180
290
|
const checkFilesNow = async (
|
|
181
|
-
|
|
291
|
+
requested: "files-win" | "score-wins" = "files-win",
|
|
182
292
|
): Promise<string> => {
|
|
293
|
+
let mode = requested;
|
|
183
294
|
if (stopped) return lastOutcome;
|
|
295
|
+
const claimed = await claim();
|
|
296
|
+
if (!claimed.ok) return (lastOutcome = detachedNotice);
|
|
297
|
+
if (needsStartup) {
|
|
298
|
+
// First look (or this window switched sessions): files edited since
|
|
299
|
+
// dawg last synced them (or never recorded) win; otherwise the
|
|
300
|
+
// session score wins and reprints what the files lack.
|
|
301
|
+
needsStartup = false;
|
|
302
|
+
seen = new Map();
|
|
303
|
+
known = new Map();
|
|
304
|
+
filesEqual = "";
|
|
305
|
+
const state = await readState();
|
|
306
|
+
const hashes = await hashProject(project);
|
|
307
|
+
const edited =
|
|
308
|
+
hashes.size === 0 ||
|
|
309
|
+
[...hashes].some(([path, hash]) => state.files[path] !== hash);
|
|
310
|
+
mode = edited ? "files-win" : "score-wins";
|
|
311
|
+
}
|
|
184
312
|
const current = await hashProject(project);
|
|
185
|
-
if (mode === "files-win"
|
|
313
|
+
if (mode === "files-win") {
|
|
314
|
+
// Nothing re-evaluated: a standing rejection still holds, anything
|
|
315
|
+
// else is reported as unchanged rather than repeating an old apply.
|
|
316
|
+
if (sameHashes(current, seen))
|
|
317
|
+
return lastRejection ? lastOutcome : (lastOutcome = UNCHANGED);
|
|
318
|
+
// An echo: every changed file is exactly what dawg (this window or
|
|
319
|
+
// another) last synced, so the session already holds it.
|
|
320
|
+
const state = await readState();
|
|
321
|
+
const changed = changedPaths(current, seen);
|
|
322
|
+
if (
|
|
323
|
+
seen.size > 0 &&
|
|
324
|
+
!lastRejection &&
|
|
325
|
+
changed.every(
|
|
326
|
+
(path) =>
|
|
327
|
+
current.get(path) !== undefined &&
|
|
328
|
+
state.files[path] === current.get(path),
|
|
329
|
+
)
|
|
330
|
+
) {
|
|
331
|
+
for (const path of changed) known.delete(path);
|
|
332
|
+
seen = current;
|
|
333
|
+
return (lastOutcome = "files match the score");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
186
336
|
const result = await evaluate(project);
|
|
187
337
|
if (!result.ok) {
|
|
188
338
|
const text = result.diagnostics.map(formatDiagnostic).join("; ");
|
|
@@ -194,19 +344,23 @@ export function startProjectSync(
|
|
|
194
344
|
"fix the file to apply",
|
|
195
345
|
);
|
|
196
346
|
}
|
|
347
|
+
seen = current;
|
|
197
348
|
void runTypecheck();
|
|
198
349
|
return (lastOutcome = `files rejected · ${text}`);
|
|
199
350
|
}
|
|
200
351
|
lastRejection = "";
|
|
201
352
|
seen = current;
|
|
353
|
+
known = printedTexts(result.score);
|
|
202
354
|
const plan = applyFiles(host.current(), result.score);
|
|
203
355
|
if (plan.operations.length === 0) {
|
|
204
356
|
filesEqual = encodeLoop(host.current());
|
|
357
|
+
await recordSynced(current);
|
|
205
358
|
void runTypecheck();
|
|
206
359
|
return (lastOutcome = "files match the score");
|
|
207
360
|
}
|
|
208
361
|
if (mode === "score-wins") {
|
|
209
362
|
pendingScore = host.current();
|
|
363
|
+
await recordSynced(current);
|
|
210
364
|
await flushScoreNow();
|
|
211
365
|
void runTypecheck();
|
|
212
366
|
return (lastOutcome = "files reprinted from the score");
|
|
@@ -215,6 +369,7 @@ export function startProjectSync(
|
|
|
215
369
|
try {
|
|
216
370
|
const committed = await host.commit(plan, summary);
|
|
217
371
|
filesEqual = encodeLoop(committed);
|
|
372
|
+
await recordSynced(current);
|
|
218
373
|
host.card(`applied from files · ${summary}`, "success");
|
|
219
374
|
lastOutcome = `applied from files · ${summary}`;
|
|
220
375
|
} catch (error) {
|
|
@@ -229,35 +384,81 @@ export function startProjectSync(
|
|
|
229
384
|
};
|
|
230
385
|
|
|
231
386
|
const flushScoreNow = async (): Promise<void> => {
|
|
387
|
+
if (stopped || !pendingScore) return;
|
|
388
|
+
const claimed = await claim();
|
|
389
|
+
if (!claimed.ok) return;
|
|
390
|
+
if (needsStartup) await checkFilesNow("files-win");
|
|
232
391
|
if (stopped || !pendingScore) return;
|
|
233
392
|
const score = pendingScore;
|
|
234
393
|
pendingScore = undefined;
|
|
235
394
|
const encoded = encodeLoop(score);
|
|
236
395
|
if (encoded === filesEqual) return;
|
|
237
396
|
const printed = printProject(score);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
397
|
+
const conflicts: string[] = [];
|
|
398
|
+
/** Paths this flush wrote (hash) or removed (undefined). */
|
|
399
|
+
const wrote = new Map<string, string | undefined>();
|
|
400
|
+
await locked(async () => {
|
|
401
|
+
const state = await readState();
|
|
402
|
+
const files = { ...state.files };
|
|
403
|
+
const expected = new Set(printed.files.map((file) => file.path));
|
|
404
|
+
for (const file of printed.files) {
|
|
405
|
+
const path = join(project, file.path);
|
|
406
|
+
const existing = await readOptional(path);
|
|
407
|
+
if (existing === file.text) {
|
|
408
|
+
files[file.path] = sha256(existing);
|
|
409
|
+
known.set(file.path, file.text);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
// This file's slice of the score is unchanged: keep the author's text.
|
|
413
|
+
if (existing !== undefined && known.get(file.path) === file.text)
|
|
414
|
+
continue;
|
|
415
|
+
// Edited since dawg last synced it (or never synced): the edit wins.
|
|
416
|
+
if (existing !== undefined && files[file.path] !== sha256(existing)) {
|
|
417
|
+
conflicts.push(file.path);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
await writeAtomic(path, file.text);
|
|
421
|
+
files[file.path] = sha256(file.text);
|
|
422
|
+
wrote.set(file.path, files[file.path]);
|
|
423
|
+
known.set(file.path, file.text);
|
|
424
|
+
}
|
|
425
|
+
// Track files dawg printed earlier for tracks that no longer exist.
|
|
426
|
+
for (const [path, hash] of Object.entries(state.files)) {
|
|
427
|
+
if (expected.has(path) || !TRACK_FILE.test(path)) continue;
|
|
428
|
+
const existing = await readOptional(join(project, path));
|
|
429
|
+
if (existing !== undefined && sha256(existing) !== hash) continue;
|
|
430
|
+
if (existing !== undefined)
|
|
431
|
+
await rm(join(project, path), { force: true });
|
|
432
|
+
wrote.set(path, undefined);
|
|
433
|
+
delete files[path];
|
|
434
|
+
known.delete(path);
|
|
435
|
+
}
|
|
436
|
+
await writeState({ ...state, files });
|
|
437
|
+
});
|
|
438
|
+
// Only our own writes are folded into `seen`: an edit the author saved
|
|
439
|
+
// to any other file since the last look must still be evaluated. While
|
|
440
|
+
// the files are rejected nothing is folded, so the next look re-evaluates.
|
|
441
|
+
if (!lastRejection) {
|
|
442
|
+
seen = new Map(seen);
|
|
443
|
+
for (const [path, hash] of wrote)
|
|
444
|
+
if (hash === undefined) seen.delete(path);
|
|
445
|
+
else seen.set(path, hash);
|
|
446
|
+
}
|
|
447
|
+
if (conflicts.length === 0) {
|
|
448
|
+
filesEqual = encoded;
|
|
449
|
+
lastConflict = "";
|
|
450
|
+
return;
|
|
249
451
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
452
|
+
filesEqual = "";
|
|
453
|
+
const text = conflicts.join(", ");
|
|
454
|
+
if (text !== lastConflict) {
|
|
455
|
+
lastConflict = text;
|
|
456
|
+
host.card(
|
|
457
|
+
`${text} edited · not overwritten`,
|
|
458
|
+
"warning",
|
|
459
|
+
"save it to apply, or delete it to reprint",
|
|
460
|
+
);
|
|
257
461
|
}
|
|
258
|
-
await writeState({ files });
|
|
259
|
-
seen = await hashProject(project);
|
|
260
|
-
filesEqual = encoded;
|
|
261
462
|
};
|
|
262
463
|
|
|
263
464
|
let typecheckRunning = false;
|
|
@@ -283,12 +484,29 @@ export function startProjectSync(
|
|
|
283
484
|
}
|
|
284
485
|
};
|
|
285
486
|
|
|
487
|
+
/** Errors (a lock timeout, a full disk) become a card, never an unhandled rejection. */
|
|
488
|
+
const guarded =
|
|
489
|
+
<T>(task: () => Promise<T>, fallback: T) =>
|
|
490
|
+
async (): Promise<T> => {
|
|
491
|
+
try {
|
|
492
|
+
return await task();
|
|
493
|
+
} catch (error) {
|
|
494
|
+
host.card(
|
|
495
|
+
`file sync failed · ${firstLine(error instanceof Error ? error.message : String(error))}`,
|
|
496
|
+
"warning",
|
|
497
|
+
);
|
|
498
|
+
return fallback;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const filesWin = guarded(() => checkFilesNow("files-win"), "");
|
|
502
|
+
const flush = guarded(flushScoreNow, undefined);
|
|
503
|
+
|
|
286
504
|
const scheduleFiles = () => {
|
|
287
505
|
if (stopped) return;
|
|
288
506
|
if (fileTimer) clearTimeout(fileTimer);
|
|
289
507
|
fileTimer = setTimeout(() => {
|
|
290
508
|
fileTimer = undefined;
|
|
291
|
-
void enqueue(
|
|
509
|
+
void enqueue(filesWin);
|
|
292
510
|
}, debounceMs);
|
|
293
511
|
};
|
|
294
512
|
|
|
@@ -312,21 +530,12 @@ export function startProjectSync(
|
|
|
312
530
|
watchers = [];
|
|
313
531
|
}
|
|
314
532
|
poll = setInterval(
|
|
315
|
-
() => void enqueue(
|
|
533
|
+
() => void enqueue(filesWin),
|
|
316
534
|
options.pollMs ?? SYNC_POLL_MS,
|
|
317
535
|
);
|
|
318
536
|
poll.unref?.();
|
|
319
537
|
}
|
|
320
|
-
|
|
321
|
-
// win; otherwise the session score wins and reprints what files lack.
|
|
322
|
-
void enqueue(async () => {
|
|
323
|
-
const state = await readState();
|
|
324
|
-
const current = await hashProject(project);
|
|
325
|
-
const edited =
|
|
326
|
-
current.size === 0 ||
|
|
327
|
-
[...current].some(([path, hash]) => state.files[path] !== hash);
|
|
328
|
-
await checkFilesNow(edited ? "files-win" : "score-wins");
|
|
329
|
-
});
|
|
538
|
+
void enqueue(filesWin);
|
|
330
539
|
|
|
331
540
|
return Object.freeze({
|
|
332
541
|
scoreChanged(score: TrackScore) {
|
|
@@ -335,7 +544,7 @@ export function startProjectSync(
|
|
|
335
544
|
if (scoreTimer) clearTimeout(scoreTimer);
|
|
336
545
|
scoreTimer = setTimeout(() => {
|
|
337
546
|
scoreTimer = undefined;
|
|
338
|
-
void enqueue(
|
|
547
|
+
void enqueue(flush);
|
|
339
548
|
}, debounceMs);
|
|
340
549
|
},
|
|
341
550
|
checkFiles: () => enqueue(() => checkFilesNow("files-win")),
|
|
@@ -348,48 +557,171 @@ export function startProjectSync(
|
|
|
348
557
|
watchers.forEach((watcher) => watcher.close());
|
|
349
558
|
watchers = [];
|
|
350
559
|
await queue.catch(() => undefined);
|
|
560
|
+
liveTokens.delete(token);
|
|
561
|
+
// Release this window's hold so another session may take the files.
|
|
562
|
+
await locked(async () => {
|
|
563
|
+
const state = await readState();
|
|
564
|
+
if (!state.owner?.holders.some((holder) => holder.token === token))
|
|
565
|
+
return;
|
|
566
|
+
await writeState({
|
|
567
|
+
...state,
|
|
568
|
+
owner: {
|
|
569
|
+
...state.owner,
|
|
570
|
+
holders: state.owner.holders.filter(
|
|
571
|
+
(holder) => holder.token !== token,
|
|
572
|
+
),
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}).catch(() => undefined);
|
|
351
576
|
},
|
|
352
577
|
});
|
|
353
578
|
}
|
|
354
579
|
|
|
355
|
-
/**
|
|
580
|
+
/** Text extensions a project's evaluation can read: modules, data and tuning. */
|
|
581
|
+
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|json|scl|kbm)$/;
|
|
582
|
+
const MAX_SOURCE_DEPTH = 6;
|
|
583
|
+
/** Outcome when no project source changed since the last look. */
|
|
584
|
+
export const UNCHANGED = "files unchanged";
|
|
585
|
+
/** Project configuration, not evaluated: editing it never re-evaluates. */
|
|
586
|
+
const CONFIG_FILES = new Set([
|
|
587
|
+
"dawg.json",
|
|
588
|
+
"tsconfig.json",
|
|
589
|
+
"jsconfig.json",
|
|
590
|
+
"package.json",
|
|
591
|
+
]);
|
|
592
|
+
/** Larger files are fingerprinted by size and mtime instead of content. */
|
|
593
|
+
const MAX_HASHED_BYTES = 2 * 1024 * 1024;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Project-relative source paths: `song.ts`, each `tracks/<slug>/track.ts`,
|
|
597
|
+
* and every other module, JSON or tuning file in the project that an
|
|
598
|
+
* evaluation could import or read. Dot directories (`.dawg`, `.git`) and
|
|
599
|
+
* `node_modules` are skipped; bounded by count and depth.
|
|
600
|
+
*/
|
|
356
601
|
export async function projectSourceFiles(project: string): Promise<string[]> {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
.filter((entry) => entry.isDirectory())
|
|
362
|
-
.map((entry) => entry.name)
|
|
363
|
-
.sort();
|
|
364
|
-
} catch {
|
|
365
|
-
dirs = [];
|
|
366
|
-
}
|
|
367
|
-
for (const dir of dirs.slice(0, MAX_TRACKED_FILES)) {
|
|
368
|
-
const path = `tracks/${dir}/track.ts`;
|
|
602
|
+
const out: string[] = [];
|
|
603
|
+
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
604
|
+
if (depth > MAX_SOURCE_DEPTH || out.length >= MAX_TRACKED_FILES) return;
|
|
605
|
+
let entries: Dirent[];
|
|
369
606
|
try {
|
|
370
|
-
|
|
607
|
+
entries = await readdir(join(project, dir), { withFileTypes: true });
|
|
371
608
|
} catch {
|
|
372
|
-
|
|
609
|
+
return;
|
|
373
610
|
}
|
|
374
|
-
|
|
375
|
-
|
|
611
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
612
|
+
for (const entry of entries) {
|
|
613
|
+
if (out.length >= MAX_TRACKED_FILES) return;
|
|
614
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
615
|
+
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
616
|
+
if (CONFIG_FILES.has(path)) continue;
|
|
617
|
+
if (entry.isDirectory()) await walk(path, depth + 1);
|
|
618
|
+
else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name))
|
|
619
|
+
out.push(path);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
await walk("", 0);
|
|
623
|
+
return out.sort(
|
|
624
|
+
(a, b) => sourceRank(a) - sourceRank(b) || (a < b ? -1 : a > b ? 1 : 0),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** `song.ts` first, then track files, then everything else. */
|
|
629
|
+
function sourceRank(path: string): number {
|
|
630
|
+
return path === "song.ts" ? 0 : TRACK_FILE.test(path) ? 1 : 2;
|
|
376
631
|
}
|
|
377
632
|
|
|
378
633
|
function isProjectSource(name: string): boolean {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
);
|
|
634
|
+
const base = name.split(/[\\/]/).pop() ?? name;
|
|
635
|
+
if (base.startsWith(".") && base.length > 0) return false;
|
|
636
|
+
return SOURCE_EXTENSION.test(base) || !base.includes(".");
|
|
382
637
|
}
|
|
383
638
|
|
|
384
639
|
async function hashProject(project: string): Promise<Map<string, string>> {
|
|
385
640
|
const out = new Map<string, string>();
|
|
386
641
|
for (const path of await projectSourceFiles(project)) {
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
642
|
+
const hash = await hashFile(join(project, path));
|
|
643
|
+
if (hash !== undefined) out.set(path, hash);
|
|
389
644
|
}
|
|
390
645
|
return out;
|
|
391
646
|
}
|
|
392
647
|
|
|
648
|
+
async function hashFile(path: string): Promise<string | undefined> {
|
|
649
|
+
try {
|
|
650
|
+
const info = await stat(path);
|
|
651
|
+
if (info.size > MAX_HASHED_BYTES)
|
|
652
|
+
return sha256(`${info.size}:${info.mtimeMs}`);
|
|
653
|
+
return sha256(await readFile(path, "utf8"));
|
|
654
|
+
} catch {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function printedTexts(score: TrackScore): Map<string, string> {
|
|
660
|
+
return new Map(
|
|
661
|
+
printProject(score).files.map((file) => [file.path, file.text]),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function changedPaths(
|
|
666
|
+
a: Map<string, string>,
|
|
667
|
+
b: Map<string, string>,
|
|
668
|
+
): string[] {
|
|
669
|
+
const out = new Set<string>();
|
|
670
|
+
for (const [path, hash] of a) if (b.get(path) !== hash) out.add(path);
|
|
671
|
+
for (const path of b.keys()) if (!a.has(path)) out.add(path);
|
|
672
|
+
return [...out];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseOwner(value: unknown): SyncOwner | undefined {
|
|
676
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
677
|
+
const record = value as Record<string, unknown>;
|
|
678
|
+
if (
|
|
679
|
+
typeof record.sessionId !== "string" ||
|
|
680
|
+
record.sessionId.length === 0 ||
|
|
681
|
+
record.sessionId.length > 128 ||
|
|
682
|
+
!Array.isArray(record.holders)
|
|
683
|
+
)
|
|
684
|
+
return undefined;
|
|
685
|
+
const holders: Holder[] = [];
|
|
686
|
+
for (const holder of record.holders.slice(0, MAX_HOLDERS)) {
|
|
687
|
+
if (typeof holder !== "object" || holder === null) continue;
|
|
688
|
+
const { pid, token } = holder as Record<string, unknown>;
|
|
689
|
+
if (
|
|
690
|
+
typeof pid === "number" &&
|
|
691
|
+
Number.isSafeInteger(pid) &&
|
|
692
|
+
pid > 0 &&
|
|
693
|
+
typeof token === "string" &&
|
|
694
|
+
token.length > 0 &&
|
|
695
|
+
token.length <= 64
|
|
696
|
+
)
|
|
697
|
+
holders.push({ pid, token });
|
|
698
|
+
}
|
|
699
|
+
return { sessionId: record.sessionId, holders };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function holderAlive(holder: Holder): boolean {
|
|
703
|
+
if (holder.pid === process.pid) return liveTokens.has(holder.token);
|
|
704
|
+
try {
|
|
705
|
+
process.kill(holder.pid, 0);
|
|
706
|
+
return true;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
return !(
|
|
709
|
+
error instanceof Error &&
|
|
710
|
+
"code" in error &&
|
|
711
|
+
error.code === "ESRCH"
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function exists(path: string): Promise<boolean> {
|
|
717
|
+
try {
|
|
718
|
+
await stat(path);
|
|
719
|
+
return true;
|
|
720
|
+
} catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
393
725
|
function sameHashes(a: Map<string, string>, b: Map<string, string>): boolean {
|
|
394
726
|
if (a.size !== b.size) return false;
|
|
395
727
|
for (const [path, hash] of a) if (b.get(path) !== hash) return false;
|