@hraness/dawg 0.7.0 → 0.8.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 +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool for range edits (op1-ux §6.4): `edit_range` builds the same
|
|
3
|
+
* line the prompt runs (`loop 5-6`, `copy bass 5-6 to 7 x2`, `bars insert
|
|
4
|
+
* 2 at 3`, `section split chorus at 13`) and applies it through
|
|
5
|
+
* src/commands/range.ts or src/commands/arrange.ts, so the tool, the typed
|
|
6
|
+
* command and the menu agree. Its operations are the score diff.
|
|
7
|
+
*
|
|
8
|
+
* Bars count from 1, like the prompt and the bar ruler.
|
|
9
|
+
*/
|
|
10
|
+
import { diffScores } from "../../core/diff.ts";
|
|
11
|
+
import { SCORE_LIMITS, type TrackScore } from "../../core/score.ts";
|
|
12
|
+
import {
|
|
13
|
+
applySectionCommand,
|
|
14
|
+
loopSpan,
|
|
15
|
+
parseSectionCommand,
|
|
16
|
+
} from "../commands/arrange.ts";
|
|
17
|
+
import { parseLoopCommand } from "../commands/grammar.ts";
|
|
18
|
+
import { applyRangeCommand, parseRangeCommand } from "../commands/range.ts";
|
|
19
|
+
import type { AgentTool, ToolContext, ToolPlan } from "./tools.ts";
|
|
20
|
+
import { ToolArgumentError } from "./tool-error.ts";
|
|
21
|
+
|
|
22
|
+
export const RANGE_ACTIONS = [
|
|
23
|
+
"loop",
|
|
24
|
+
"unloop",
|
|
25
|
+
"copy",
|
|
26
|
+
"move",
|
|
27
|
+
"clear",
|
|
28
|
+
"reverse",
|
|
29
|
+
"insert_bars",
|
|
30
|
+
"remove_bars",
|
|
31
|
+
"split",
|
|
32
|
+
"join",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
type RangeAction = (typeof RANGE_ACTIONS)[number];
|
|
36
|
+
|
|
37
|
+
const bar = (description: string) =>
|
|
38
|
+
({
|
|
39
|
+
type: "integer",
|
|
40
|
+
minimum: 1,
|
|
41
|
+
maximum: SCORE_LIMITS.maxBars,
|
|
42
|
+
description,
|
|
43
|
+
}) as const;
|
|
44
|
+
|
|
45
|
+
function whole(value: unknown, what: string, max: number): number {
|
|
46
|
+
if (
|
|
47
|
+
typeof value !== "number" ||
|
|
48
|
+
!Number.isInteger(value) ||
|
|
49
|
+
value < 1 ||
|
|
50
|
+
value > max
|
|
51
|
+
)
|
|
52
|
+
throw new ToolArgumentError(`${what} must be a whole number 1..${max}`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function word(value: unknown, what: string): string {
|
|
57
|
+
if (typeof value !== "string" || !/^[\w .'-]{1,64}$/u.test(value.trim()))
|
|
58
|
+
throw new ToolArgumentError(`${what} must be a name`);
|
|
59
|
+
return value.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `5-6`, `5`, or a section name: the range words of the typed command. */
|
|
63
|
+
function rangeWords(args: Record<string, unknown>): string {
|
|
64
|
+
if (args.section !== undefined) return word(args.section, "section");
|
|
65
|
+
if (args.fromBar === undefined)
|
|
66
|
+
throw new ToolArgumentError("give fromBar (and toBar) or section");
|
|
67
|
+
const from = whole(args.fromBar, "fromBar", SCORE_LIMITS.maxBars);
|
|
68
|
+
const to =
|
|
69
|
+
args.toBar === undefined
|
|
70
|
+
? from
|
|
71
|
+
: whole(args.toBar, "toBar", SCORE_LIMITS.maxBars);
|
|
72
|
+
if (to < from) throw new ToolArgumentError("toBar comes at or after fromBar");
|
|
73
|
+
return from === to ? `${from}` : `${from}-${to}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function trackWord(
|
|
77
|
+
args: Record<string, unknown>,
|
|
78
|
+
context: ToolContext,
|
|
79
|
+
): string {
|
|
80
|
+
if (args.allTracks === true) return "all";
|
|
81
|
+
const id =
|
|
82
|
+
args.trackId === undefined
|
|
83
|
+
? context.focusedTrackId
|
|
84
|
+
: word(args.trackId, "trackId");
|
|
85
|
+
if (!context.score.tracks.some((track) => track.id === id))
|
|
86
|
+
throw new ToolArgumentError(`no track ${id}`);
|
|
87
|
+
return id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The typed command a call stands for (what show-me would type). */
|
|
91
|
+
export function rangeToolCommand(
|
|
92
|
+
args: Record<string, unknown>,
|
|
93
|
+
context: ToolContext,
|
|
94
|
+
): string {
|
|
95
|
+
const action = args.action as RangeAction;
|
|
96
|
+
if (!RANGE_ACTIONS.includes(action))
|
|
97
|
+
throw new ToolArgumentError(`action must be ${RANGE_ACTIONS.join(", ")}`);
|
|
98
|
+
switch (action) {
|
|
99
|
+
case "loop":
|
|
100
|
+
return `loop ${rangeWords(args)}`;
|
|
101
|
+
case "unloop":
|
|
102
|
+
return "loop off";
|
|
103
|
+
case "copy":
|
|
104
|
+
case "move": {
|
|
105
|
+
if (args.toBar === undefined && args.atBar === undefined)
|
|
106
|
+
throw new ToolArgumentError(`${action} needs atBar, where it lands`);
|
|
107
|
+
const at = whole(args.atBar, "atBar", SCORE_LIMITS.maxBars);
|
|
108
|
+
const times =
|
|
109
|
+
action === "copy" && args.times !== undefined
|
|
110
|
+
? ` x${whole(args.times, "times", 64)}`
|
|
111
|
+
: "";
|
|
112
|
+
const mode =
|
|
113
|
+
args.insert === true
|
|
114
|
+
? " insert"
|
|
115
|
+
: action === "copy" && args.merge === true
|
|
116
|
+
? " merge"
|
|
117
|
+
: "";
|
|
118
|
+
return `${action} ${trackWord(args, context)} ${rangeWords(args)} to ${at}${times}${mode}`;
|
|
119
|
+
}
|
|
120
|
+
case "clear":
|
|
121
|
+
case "reverse":
|
|
122
|
+
return `${action} ${trackWord(args, context)} ${rangeWords(args)}`;
|
|
123
|
+
case "insert_bars":
|
|
124
|
+
return `bars insert ${whole(args.bars, "bars", SCORE_LIMITS.maxBars)} at ${whole(args.atBar, "atBar", SCORE_LIMITS.maxBars)}`;
|
|
125
|
+
case "remove_bars":
|
|
126
|
+
return `bars remove ${rangeWords(args)}`;
|
|
127
|
+
case "split":
|
|
128
|
+
return `section split ${word(args.section, "section")} at ${whole(args.atBar, "atBar", SCORE_LIMITS.maxBars)}`;
|
|
129
|
+
case "join":
|
|
130
|
+
return `section join ${word(args.section, "section")}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function run(
|
|
135
|
+
line: string,
|
|
136
|
+
context: ToolContext,
|
|
137
|
+
): { next?: TrackScore; message: string } {
|
|
138
|
+
const score = context.score;
|
|
139
|
+
const loop = parseLoopCommand(line);
|
|
140
|
+
if (loop?.type === "loop-bars") {
|
|
141
|
+
const result = loopSpan(score, loop.from, loop.to);
|
|
142
|
+
if (!result.ok) throw new ToolArgumentError(result.message);
|
|
143
|
+
return { next: result.next, message: result.message };
|
|
144
|
+
}
|
|
145
|
+
if (loop?.type === "loop-off")
|
|
146
|
+
return score.loop
|
|
147
|
+
? { next: score.withLoop(null), message: "loop · off · playing the song" }
|
|
148
|
+
: run("section loop off", context);
|
|
149
|
+
if (loop?.type === "loop-section")
|
|
150
|
+
return run(`section loop ${loop.name}`, context);
|
|
151
|
+
const range = parseRangeCommand(line, score);
|
|
152
|
+
if (range) {
|
|
153
|
+
const result = applyRangeCommand(
|
|
154
|
+
score,
|
|
155
|
+
{ trackId: context.focusedTrackId, playheadBar: 0 },
|
|
156
|
+
range,
|
|
157
|
+
);
|
|
158
|
+
if (!result.ok) throw new ToolArgumentError(result.message);
|
|
159
|
+
return {
|
|
160
|
+
...(result.next ? { next: result.next } : {}),
|
|
161
|
+
message: result.message,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const section = parseSectionCommand(line, score);
|
|
165
|
+
if (!section) throw new ToolArgumentError(`cannot run: ${line}`);
|
|
166
|
+
const result = applySectionCommand(score, context.focusedTrackId, section);
|
|
167
|
+
if (!result.ok) throw new ToolArgumentError(result.message);
|
|
168
|
+
return {
|
|
169
|
+
...(result.next ? { next: result.next } : {}),
|
|
170
|
+
message: result.message,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const RANGE_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
175
|
+
{
|
|
176
|
+
name: "edit_range",
|
|
177
|
+
description:
|
|
178
|
+
"Edit bar ranges (bars count from 1; a range is fromBar..toBar or a section name). loop sets the loop range playback cycles (no section is made); unloop plays the song. copy copies a track's (or allTracks') notes, automation and clips in the range to atBar, overwriting there (merge keeps what is there; insert shifts later music right), times tiles it end to end. move is copy plus clearing the source. clear empties the range (the bars stay). reverse mirrors it in time. insert_bars adds `bars` empty bars at atBar and moves later sections, clips, automation and tempo points right; remove_bars cuts the range out. split cuts a section in two at atBar; join merges a section with the one after it. Each call is one undo step and runs the same command the prompt does.",
|
|
179
|
+
parameters: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
action: { type: "string", enum: [...RANGE_ACTIONS] },
|
|
183
|
+
fromBar: bar("first bar of the range"),
|
|
184
|
+
toBar: bar("last bar of the range (default fromBar)"),
|
|
185
|
+
section: {
|
|
186
|
+
type: "string",
|
|
187
|
+
maxLength: 32,
|
|
188
|
+
description: "a section as the range (split/join: the section)",
|
|
189
|
+
},
|
|
190
|
+
atBar: bar("copy/move: where it lands; insert_bars/split: the bar"),
|
|
191
|
+
bars: bar("insert_bars: how many"),
|
|
192
|
+
times: { type: "integer", minimum: 1, maximum: 64 },
|
|
193
|
+
trackId: {
|
|
194
|
+
type: "string",
|
|
195
|
+
maxLength: SCORE_LIMITS.maxIdLength,
|
|
196
|
+
description: "the track (default: the focused track)",
|
|
197
|
+
},
|
|
198
|
+
allTracks: { type: "boolean" },
|
|
199
|
+
insert: { type: "boolean" },
|
|
200
|
+
merge: { type: "boolean" },
|
|
201
|
+
},
|
|
202
|
+
required: ["action"],
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
},
|
|
205
|
+
plan(args, context): ToolPlan {
|
|
206
|
+
const line = rangeToolCommand(args, context);
|
|
207
|
+
const result = run(line, context);
|
|
208
|
+
return {
|
|
209
|
+
kind: "score",
|
|
210
|
+
operations: result.next ? diffScores(context.score, result.next) : [],
|
|
211
|
+
trackId: context.focusedTrackId,
|
|
212
|
+
summary: result.message,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
]);
|
package/src/agent/show-me.ts
CHANGED
|
@@ -90,7 +90,7 @@ function cleanLine(line: string): string {
|
|
|
90
90
|
* quit or change this window's settings, none of which edits the song.
|
|
91
91
|
*/
|
|
92
92
|
const WINDOW_ONLY =
|
|
93
|
-
/^\/(?:help|guide|menu|play|login|logout|auth|model|quit|exit|sessions?|new|showme|status|undo|redo|tracks|euclid|try|record|grid|count-?in|click|chords|theme|motion|view|transcript)\b/i;
|
|
93
|
+
/^\/(?:help|guide|menu|play|login|logout|auth|model|quit|exit|sessions?|new|showme|status|undo|redo|tracks|euclid|try|record|grid|count-?in|click|chords|theme|motion|view|transcript)\b|^\/?(?:tape|knobs|mix|panes?|pin|unpin|follow|unfollow|audio)(?:\s|$)/i;
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Whether a complete line is a command the agent runs: one a human could
|
package/src/agent/tools.ts
CHANGED
|
@@ -114,6 +114,7 @@ import { STYLE_TOOLS } from "./style-tools.ts";
|
|
|
114
114
|
import { DRUM_TOOLS } from "./drum-tools.ts";
|
|
115
115
|
import { TIME_TOOLS } from "./time-tools.ts";
|
|
116
116
|
import { SECTION_TOOLS } from "./section-tools.ts";
|
|
117
|
+
import { RANGE_TOOLS } from "./range-tools.ts";
|
|
117
118
|
import { GRANULAR_TOOLS } from "./granular-tools.ts";
|
|
118
119
|
import { RESAMPLE_TOOLS } from "./resample-tool.ts";
|
|
119
120
|
import { VOICE_TOOLS } from "./voice-tools.ts";
|
|
@@ -2035,6 +2036,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
|
|
|
2035
2036
|
...DRUM_TOOLS,
|
|
2036
2037
|
...TIME_TOOLS,
|
|
2037
2038
|
...SECTION_TOOLS,
|
|
2039
|
+
...RANGE_TOOLS,
|
|
2038
2040
|
...MEDIA_TOOLS,
|
|
2039
2041
|
...PACK_TOOLS,
|
|
2040
2042
|
...MASTER_TOOLS,
|
package/src/agent/xcb-agent.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { newId } from "../../core/ids.ts";
|
|
1
2
|
import { GRANULAR_PARAMS } from "../../core/granular.ts";
|
|
2
3
|
import { compositionBrief } from "./brief.ts";
|
|
3
4
|
import { CHORD_PROCESS } from "../../core/chords.ts";
|
|
@@ -125,11 +126,9 @@ export async function runTextAgentTurn(
|
|
|
125
126
|
};
|
|
126
127
|
const tools = options.tools ?? AGENT_TOOLS;
|
|
127
128
|
const catalog = renderToolCatalog(tools);
|
|
128
|
-
const nonce = Math.random().toString(36).slice(2, 6);
|
|
129
129
|
const newNoteId =
|
|
130
130
|
options.newNoteId ??
|
|
131
|
-
((trackId: string,
|
|
132
|
-
`${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
|
|
131
|
+
((trackId: string, _revision: number, _index: number) => newId(trackId));
|
|
133
132
|
const startedAt = Date.now();
|
|
134
133
|
const deadline = turnDeadline(limits.timeoutMs);
|
|
135
134
|
const signal = options.signal
|
package/src/audio/arrange.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ScoreValidationError,
|
|
16
16
|
TrackScore,
|
|
17
17
|
type Note,
|
|
18
|
+
type Section,
|
|
18
19
|
withNoteCap,
|
|
19
20
|
} from "../../core/score.ts";
|
|
20
21
|
import {
|
|
@@ -103,8 +104,17 @@ export function sectionCues(
|
|
|
103
104
|
}));
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The bars playback loops: the loop range (as an unnamed section with no
|
|
109
|
+
* mutes or variations), else the section `loopSection` names. A range is
|
|
110
|
+
* cut to the song's bars; one wholly past the end loops nothing.
|
|
111
|
+
*/
|
|
112
|
+
export function loopedSection(score: TrackScore): Section | undefined {
|
|
113
|
+
if (score.loop) {
|
|
114
|
+
const startBar = Math.min(score.loop.startBar, score.bars - 1);
|
|
115
|
+
const bars = Math.max(1, Math.min(score.loop.bars, score.bars - startBar));
|
|
116
|
+
return { name: "loop", startBar, bars };
|
|
117
|
+
}
|
|
108
118
|
return score.loopSection === undefined
|
|
109
119
|
? undefined
|
|
110
120
|
: findSection(score, score.loopSection);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `audio`, `audio out <name|default>`, `audio in <name|default>` and
|
|
3
|
+
* `audio test`: the typed form of Ctrl-K › Project › audio. One output and
|
|
4
|
+
* one input; the choice lives in `<config>/audio.json` on this machine.
|
|
5
|
+
*/
|
|
6
|
+
import type { AudioBackendInfo } from "./engine.ts";
|
|
7
|
+
import {
|
|
8
|
+
audioChoicePath,
|
|
9
|
+
blipSamples,
|
|
10
|
+
deviceLabel,
|
|
11
|
+
deviceSupport,
|
|
12
|
+
levelMeter,
|
|
13
|
+
listDevices,
|
|
14
|
+
matchDevice,
|
|
15
|
+
measureInput,
|
|
16
|
+
parseAudioCommand,
|
|
17
|
+
playOnce,
|
|
18
|
+
readAudioChoice,
|
|
19
|
+
writeAudioChoice,
|
|
20
|
+
type AudioChoice,
|
|
21
|
+
} from "./devices.ts";
|
|
22
|
+
import type { MenuAudioDevices } from "../tui/menu.ts";
|
|
23
|
+
|
|
24
|
+
export const AUDIO_USAGE =
|
|
25
|
+
"usage · audio · audio out <name|default> · audio in <name|default> · audio test";
|
|
26
|
+
|
|
27
|
+
export type AudioCommandContext = Readonly<{
|
|
28
|
+
info: AudioBackendInfo;
|
|
29
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** Where the choice is saved (default `<config>/audio.json`). */
|
|
31
|
+
path?: string;
|
|
32
|
+
/** Move this window's playback (the engine's `setDevice`). */
|
|
33
|
+
setOutput?: (name: string | undefined) => void;
|
|
34
|
+
/** Injected by tests: no real waiting for the blip, tone and meter. */
|
|
35
|
+
wait?: (ms: number) => Promise<void>;
|
|
36
|
+
/** Run the blip without blocking the reply (the default awaits it). */
|
|
37
|
+
background?: (task: Promise<unknown>) => void;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
export type AudioCommandResult = Readonly<{
|
|
41
|
+
ok: boolean;
|
|
42
|
+
message: string;
|
|
43
|
+
/** Extra lines for a panel (the device lists, the test result). */
|
|
44
|
+
lines?: readonly string[];
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
function names(devices: readonly { name: string }[]): string {
|
|
48
|
+
return devices.length > 0
|
|
49
|
+
? devices.map((device) => device.name).join(" · ")
|
|
50
|
+
: "none found";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What Project › audio shows. */
|
|
54
|
+
export function audioMenuState(
|
|
55
|
+
info: AudioBackendInfo,
|
|
56
|
+
choice: AudioChoice = readAudioChoice(),
|
|
57
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
58
|
+
): MenuAudioDevices {
|
|
59
|
+
const support = deviceSupport(info);
|
|
60
|
+
if (support.kind !== "native") {
|
|
61
|
+
const output =
|
|
62
|
+
support.kind === "sox"
|
|
63
|
+
? ((env.DAWG_AUDIO_DEVICE?.trim() || choice.output) ?? "default")
|
|
64
|
+
: "default";
|
|
65
|
+
return {
|
|
66
|
+
output,
|
|
67
|
+
input: "default",
|
|
68
|
+
outputs: [],
|
|
69
|
+
inputs: [],
|
|
70
|
+
unavailable:
|
|
71
|
+
support.kind === "sox"
|
|
72
|
+
? "sox cannot list devices · type audio out <name> (sox AUDIODEV) · the native sink lists them"
|
|
73
|
+
: support.reason,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const outputs = listDevices(support.library, false);
|
|
77
|
+
const inputs = listDevices(support.library, true);
|
|
78
|
+
const override = env.DAWG_AUDIO_DEVICE?.trim();
|
|
79
|
+
return {
|
|
80
|
+
output: deviceLabel(override || choice.output, outputs),
|
|
81
|
+
input: deviceLabel(choice.input, inputs),
|
|
82
|
+
outputs: outputs.map((device) => device.name),
|
|
83
|
+
inputs: inputs.map((device) => device.name),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function runAudioCommand(
|
|
88
|
+
args: string,
|
|
89
|
+
context: AudioCommandContext,
|
|
90
|
+
): Promise<AudioCommandResult> {
|
|
91
|
+
const parsed = parseAudioCommand(args);
|
|
92
|
+
if (!parsed) return { ok: false, message: AUDIO_USAGE };
|
|
93
|
+
const env = context.env ?? process.env;
|
|
94
|
+
const path = context.path ?? audioChoicePath(env);
|
|
95
|
+
const choice = readAudioChoice(path);
|
|
96
|
+
const support = deviceSupport(context.info);
|
|
97
|
+
const override = env.DAWG_AUDIO_DEVICE?.trim() || undefined;
|
|
98
|
+
const overrideNote = override
|
|
99
|
+
? ` · DAWG_AUDIO_DEVICE=${override} overrides the output`
|
|
100
|
+
: "";
|
|
101
|
+
|
|
102
|
+
if (parsed.kind === "show") {
|
|
103
|
+
const state = audioMenuState(context.info, choice, env);
|
|
104
|
+
const lines = [
|
|
105
|
+
`output ${state.output}`,
|
|
106
|
+
`input ${state.input}`,
|
|
107
|
+
`player ${context.info.backend}`,
|
|
108
|
+
];
|
|
109
|
+
if (state.unavailable) lines.push(state.unavailable);
|
|
110
|
+
else {
|
|
111
|
+
lines.push(`outputs ${state.outputs.join(" · ") || "none found"}`);
|
|
112
|
+
lines.push(`inputs ${state.inputs.join(" · ") || "none found"}`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
message: `audio · out ${state.output} · in ${state.input}${overrideNote}`,
|
|
117
|
+
lines,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (support.kind === "none") return { ok: false, message: support.reason };
|
|
122
|
+
|
|
123
|
+
if (parsed.kind === "set" && parsed.side === "output") {
|
|
124
|
+
if (support.kind === "sox") {
|
|
125
|
+
writeAudioChoice("output", parsed.name, path);
|
|
126
|
+
context.setOutput?.(override ?? parsed.name);
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
message: `audio out ${parsed.name ?? "default"} · sox plays on AUDIODEV (it cannot list devices; the native sink can)${overrideNote}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const devices = listDevices(support.library, false);
|
|
133
|
+
const device = parsed.name ? matchDevice(devices, parsed.name) : undefined;
|
|
134
|
+
if (parsed.name && !device)
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: `no output named "${parsed.name}" · ${names(devices)}`,
|
|
138
|
+
};
|
|
139
|
+
writeAudioChoice("output", device?.name, path);
|
|
140
|
+
const playing = override ?? device?.name;
|
|
141
|
+
context.setOutput?.(playing);
|
|
142
|
+
// The soft blip says where sound now comes out.
|
|
143
|
+
const blip = playOnce(
|
|
144
|
+
support.library,
|
|
145
|
+
playing,
|
|
146
|
+
blipSamples(48_000),
|
|
147
|
+
48_000,
|
|
148
|
+
context.wait,
|
|
149
|
+
).catch(() => false);
|
|
150
|
+
if (context.background) context.background(blip);
|
|
151
|
+
else await blip;
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
message: `audio out ${deviceLabel(device?.name, devices)}${overrideNote}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (parsed.kind === "set") {
|
|
159
|
+
if (support.kind === "sox")
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message:
|
|
163
|
+
"choosing an input needs the native sink (it captures by name; sox here only plays)",
|
|
164
|
+
};
|
|
165
|
+
const devices = listDevices(support.library, true);
|
|
166
|
+
const device = parsed.name ? matchDevice(devices, parsed.name) : undefined;
|
|
167
|
+
if (parsed.name && !device)
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
message: `no input named "${parsed.name}" · ${names(devices)}`,
|
|
171
|
+
};
|
|
172
|
+
writeAudioChoice("input", device?.name, path);
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
message: `audio in ${deviceLabel(device?.name, devices)} · audio test meters it`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// audio test: a short tone on the output, then the input's level.
|
|
180
|
+
if (support.kind === "sox")
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
message:
|
|
184
|
+
"audio test needs the native sink (it plays the tone and meters the input)",
|
|
185
|
+
};
|
|
186
|
+
const output = override ?? choice.output;
|
|
187
|
+
const outputs = listDevices(support.library, false);
|
|
188
|
+
const inputs = listDevices(support.library, true);
|
|
189
|
+
const played = await playOnce(
|
|
190
|
+
support.library,
|
|
191
|
+
output,
|
|
192
|
+
blipSamples(48_000, 0.4, 440, 0.15),
|
|
193
|
+
48_000,
|
|
194
|
+
context.wait,
|
|
195
|
+
).catch(() => false);
|
|
196
|
+
let meter: string;
|
|
197
|
+
try {
|
|
198
|
+
const level = await measureInput(
|
|
199
|
+
support.library,
|
|
200
|
+
choice.input,
|
|
201
|
+
1,
|
|
202
|
+
48_000,
|
|
203
|
+
context.wait,
|
|
204
|
+
);
|
|
205
|
+
meter = levelMeter(level.peakDb);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
meter = `could not open (${error instanceof Error ? error.message : String(error)})`;
|
|
208
|
+
}
|
|
209
|
+
const lines = [
|
|
210
|
+
`output ${deviceLabel(output, outputs)} · ${played ? "tone played" : "could not open"}`,
|
|
211
|
+
`input ${deviceLabel(choice.input, inputs)} · peak ${meter}`,
|
|
212
|
+
];
|
|
213
|
+
return {
|
|
214
|
+
ok: played,
|
|
215
|
+
message: `audio test · ${lines[0]!.replace(/\s+/, " ")} · ${lines[1]!.replace(/\s+/, " ")}`,
|
|
216
|
+
lines,
|
|
217
|
+
};
|
|
218
|
+
}
|