Record a take from the microphone in the Song tab (#342)
A song could only be filled with sounds that were made somewhere else. Record plays the song from the cursor and takes the input meanwhile, so a part played along lands on the bar it was played to. - An audio worklet takes the input's samples on the clock the song plays on. Each run carries the frame it was taken at, so the take is cut where the song was heard to start. The output's and the input's latency are taken off, as far as the browser reports them. - The take goes up as mono 16-bit WAV. Sound, a new track "Recording" under the track clicked last and the item at the cursor are one undo step. The sound is named "Song – Take 1", 2, 3 and so on. - While recording the take grows red with its waveform on its track, and a meter beside the input dropdown shows the level. The lanes take no edits then, only a middle-drag pans. - The loop is not used while recording. The song plays once through and on past its end until Stop, Play or Space keep the take. Esc throws it away and nothing goes up. - Echo cancelling, noise suppression and auto gain are off, nothing is played back. The input picked is kept in localStorage. - A take stops by itself at the size a sound may have. A failed upload offers Retry, Download WAV and Discard. - The API takes sounds up to 256 MB and bodies of the same size. nginx and PHP were raised to match outside the repo (body 256m, upload and post 256M, memory_limit 512M). - The pure rules (cut, cap, WAV header, name, track) live in core/music/take.ts. The scheduler tells where it started on the audio clock. - The e2e browser gets chromium's fake microphone and skips the permission prompt. Each run leaves its take in the sound store. Found on the way: - The header was too full with the new widgets and cut the track and item count on the right. Input dropdown and meter are narrower now. Tests: - Unit take.test.ts: 7 new, svg-icons adjusted to 8 toolbar icons - e2e 65 (melektaus-atx): ALL PASSED – 31 checks (+4) - Gate (melektaus-atx): CHECK EXIT 0, 252 files, 3923 tests - Probe on stein2: a 3 s take of the test tone, peak 32767, 285100 bytes, waveform seen in the screenshot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32051b5097
commit
2fdf97f7de
17 files changed
+988
-21
No files matched your search
@@ -317,7 +317,7 @@ export class Editor implements PlayHost {
|
||||
this.soundFxEditor = new SoundFxEditor(state);
|
||||
this.instrumentEditor = new InstrumentEditor(state);
|
||||
this.patternEditor = new PatternEditor(state);
|
||||
this.songEditor = new SongEditor(state);
|
||||
this.songEditor = new SongEditor(state, sounds);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.vectorArtEditor = new VectorArtEditor(state);
|
||||
this.pixelArtEditor = new PixelArtEditor(state);
|
||||
|
||||
@@ -34,6 +34,9 @@ export function songHooks({ state, editor }: HookContext) {
|
||||
playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }),
|
||||
fitAt: () => centreOf(songEditor.fitButton),
|
||||
playing: () => songEditor.isPlaying,
|
||||
/** Record (#342): its button, whether a take is being made, and the input's level. */
|
||||
recordAt: () => ({ ...centreOf(songEditor.recordButton), text: songEditor.recordButton.text }),
|
||||
recording: () => songEditor.isRecording,
|
||||
/** The notes and clips the scheduler handed to the player since Play, and where it stands. */
|
||||
planned: () => [...songEditor.music.planned],
|
||||
clips: () => [...songEditor.music.clips],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="record" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<circle cx="0" cy="0" r="5.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 160 B |
@@ -7,6 +7,7 @@ import eye from './icons/eye.svg?raw';
|
||||
import menu from './icons/menu.svg?raw';
|
||||
import pause from './icons/pause.svg?raw';
|
||||
import play from './icons/play.svg?raw';
|
||||
import record from './icons/record.svg?raw';
|
||||
import redo from './icons/redo.svg?raw';
|
||||
import shader from './icons/shader.svg?raw';
|
||||
import showLabel from './icons/show-label.svg?raw';
|
||||
@@ -15,7 +16,7 @@ import showSymbol from './icons/show-symbol.svg?raw';
|
||||
import stop from './icons/stop.svg?raw';
|
||||
import undo from './icons/undo.svg?raw';
|
||||
|
||||
export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop };
|
||||
export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop, record };
|
||||
|
||||
/** The switches behind the eye: a type's symbol, its name, a collider's outline, a camera's shader. */
|
||||
export const VISIBILITY_ICON_SVG: Record<VisibilityPart, string> = {
|
||||
|
||||
+305
-11
@@ -1,5 +1,8 @@
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { CreateSoundCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { SetSongCommand } from '../../core/commands/songCommands';
|
||||
import {
|
||||
newItem,
|
||||
SONG_GRID_KEY,
|
||||
SONG_GRID_OPTIONS,
|
||||
songBuses,
|
||||
@@ -8,37 +11,77 @@ import {
|
||||
songSequence,
|
||||
songTempo,
|
||||
} from '../../core/music/song';
|
||||
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
||||
import { ticksOfSeconds } from '../../core/music/time';
|
||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { Button, Dropdown, Label, Menu } from '../../ui';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import {
|
||||
type InputDevice,
|
||||
inputDevices,
|
||||
inputProblem,
|
||||
PEAK_SPAN,
|
||||
TakeRecorder,
|
||||
} from '../../viewport/takeRecorder';
|
||||
import { activateAsset } from '../actions';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
import { SongTimeline } from './SongTimeline';
|
||||
import { TakeMeter } from './TakeMeter';
|
||||
|
||||
const INFO_WIDTH = 200;
|
||||
const GRID_WIDTH = 64;
|
||||
const INPUT_WIDTH = 104;
|
||||
/** Where the browser keeps the input picked last. */
|
||||
const INPUT_KEY = 'game-maker.record-input';
|
||||
|
||||
/** A take being recorded: the song it goes into, where, and the input it comes from. */
|
||||
interface Take {
|
||||
recorder: TakeRecorder;
|
||||
songId: string;
|
||||
tick: number;
|
||||
index: number;
|
||||
tempo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the
|
||||
* same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an
|
||||
* edit is heard from its next window on, a track's level and pan at once.
|
||||
*
|
||||
* Record (#342) plays the song from where the ruler says, once through and on past its end, and
|
||||
* takes the input meanwhile. Stop keeps the take: it goes up as WAV and lands on a track of its own
|
||||
* under the one clicked last, sound and item one undo step. Esc throws it away.
|
||||
*/
|
||||
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
/** Public for the check scripts. */
|
||||
readonly playButton: Button;
|
||||
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
|
||||
readonly recordButton: Button;
|
||||
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
|
||||
readonly meter = new TakeMeter();
|
||||
readonly music = new Music(() => this.state.project);
|
||||
/** The song playing, `''` while none is. */
|
||||
private playingId = '';
|
||||
private take: Take | null = null;
|
||||
/** Waiting for the browser to give the input, or for a take to go up: Record waits too. */
|
||||
private busy = false;
|
||||
private inputs: InputDevice[] = [];
|
||||
private inputId = localStorage.getItem(INPUT_KEY) ?? '';
|
||||
/** What the scheduler was last told, so only a change is passed on. */
|
||||
private told = '';
|
||||
private readonly halt = () => this.stop();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
constructor(
|
||||
state: EditorState,
|
||||
private readonly sounds: SoundStore,
|
||||
) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.playButton = new Button('Play', {
|
||||
icon: TOOLBAR_ICON_SVG.play,
|
||||
@@ -46,6 +89,13 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
tooltip: 'Play the song round its loop (Space)',
|
||||
onClick: () => this.togglePlay(),
|
||||
});
|
||||
this.recordButton = new Button('Record', {
|
||||
icon: TOOLBAR_ICON_SVG.record,
|
||||
minWidth: 80,
|
||||
tooltip: 'Record from the input while the song plays – headphones keep the song out of the take',
|
||||
onClick: () => this.toggleRecord(),
|
||||
});
|
||||
this.inputButton.onClick = () => void this.openInputMenu();
|
||||
this.gridButton.onClick = () => this.openGridMenu();
|
||||
const timeline = new SongTimeline(state);
|
||||
timeline.onTogglePlay = () => this.togglePlay();
|
||||
@@ -57,8 +107,17 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
|
||||
};
|
||||
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
||||
this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
||||
state.on('load', () => this.stop());
|
||||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||||
this.mount(timeline, [
|
||||
this.playButton,
|
||||
this.recordButton,
|
||||
this.inputButton,
|
||||
this.meter,
|
||||
new Label('Grid', { style: 'muted' }),
|
||||
this.gridButton,
|
||||
]);
|
||||
state.on('load', () => this.stop({ discard: true }));
|
||||
void this.listInputs();
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
@@ -73,6 +132,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
return this.playingId !== '';
|
||||
}
|
||||
|
||||
get isRecording(): boolean {
|
||||
return this.take !== null;
|
||||
}
|
||||
|
||||
togglePlay(): void {
|
||||
if (this.isPlaying) this.stop();
|
||||
else this.play();
|
||||
@@ -95,27 +158,235 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.syncPlayButton();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
/** Ends playing; a take being recorded is kept, or with `discard` thrown away. */
|
||||
stop(options: { discard?: boolean } = {}): void {
|
||||
releaseTransport(this.halt);
|
||||
this.music.stop();
|
||||
this.playingId = '';
|
||||
this.canvas.playhead = null;
|
||||
const take = this.take;
|
||||
this.take = null;
|
||||
this.meter.set(0);
|
||||
if (take && options.discard) {
|
||||
take.recorder.cancel();
|
||||
this.canvas.take = null;
|
||||
this.state.setNotice('Threw the take away');
|
||||
} else if (take) void this.keep(take);
|
||||
this.canvas.invalidate();
|
||||
this.syncPlayButton();
|
||||
}
|
||||
|
||||
/** Only called while the tab is visible: the playhead follows what is heard. */
|
||||
toggleRecord(): void {
|
||||
if (this.isRecording) this.stop();
|
||||
else void this.record();
|
||||
}
|
||||
|
||||
/** Asks for the input, then plays the song from the cursor – once, on past its end – and takes it in. */
|
||||
async record(): Promise<void> {
|
||||
const asked = this.asset();
|
||||
if (!asked || this.busy) return;
|
||||
this.stop();
|
||||
this.busy = true;
|
||||
this.syncPlayButton();
|
||||
let recorder: TakeRecorder;
|
||||
try {
|
||||
recorder = await TakeRecorder.open(this.inputId);
|
||||
} catch (error) {
|
||||
this.state.setNotice(inputProblem(error), 'error');
|
||||
return;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.syncPlayButton();
|
||||
}
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.id !== asked.id) {
|
||||
recorder.cancel();
|
||||
return;
|
||||
}
|
||||
const tempo = songTempo(asset.props);
|
||||
const take: Take = {
|
||||
recorder,
|
||||
songId: asset.id,
|
||||
tick: this.canvas.start,
|
||||
index: takeTrackIndex(asset.tracks, this.canvas.pickedTrack),
|
||||
tempo,
|
||||
};
|
||||
claimTransport(this.halt);
|
||||
this.take = take;
|
||||
this.playingId = asset.id;
|
||||
this.told = this.settingsOf(asset);
|
||||
this.showTake(take, false);
|
||||
this.syncPlayButton();
|
||||
ui().focus.focus(this.canvas);
|
||||
this.music.setBuses(songBuses(asset));
|
||||
await this.music.play(songSequence(this.state.project, asset), {
|
||||
tempo,
|
||||
meter: songMeter(asset.props),
|
||||
loop: null,
|
||||
from: take.tick,
|
||||
});
|
||||
const anchor = this.music.current?.startedAt;
|
||||
if (this.take !== take || anchor === null || anchor === undefined) return;
|
||||
recorder.startAt(takeStart(anchor, soundLibrary.latency(), recorder.inputLatency));
|
||||
this.state.setNotice('Recording – Space or Stop keeps the take, Esc throws it away');
|
||||
void this.listInputs();
|
||||
}
|
||||
|
||||
/** Only called while the tab is visible: the playhead follows what is heard, a take grows. */
|
||||
tick(): void {
|
||||
if (!this.isPlaying) return;
|
||||
this.canvas.playhead = this.music.current?.position().ticks ?? null;
|
||||
const take = this.take;
|
||||
if (take) {
|
||||
this.showTake(take, false);
|
||||
this.meter.set(take.recorder.level);
|
||||
if (take.recorder.full) {
|
||||
this.state.setNotice('The take is as long as a sound may be, so it was stopped');
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
this.canvas.invalidate();
|
||||
}
|
||||
|
||||
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
|
||||
this.music.stop();
|
||||
this.take?.recorder.cancel();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
private showTake(take: Take, uploading: boolean): void {
|
||||
const shown = this.canvas.currentAssetId === take.songId;
|
||||
this.canvas.take = shown
|
||||
? {
|
||||
index: take.index,
|
||||
tick: take.tick,
|
||||
length: ticksOfSeconds(take.recorder.seconds, take.tempo),
|
||||
peaks: take.recorder.peaks,
|
||||
ticksPerPeak: ticksOfSeconds(PEAK_SPAN / take.recorder.sampleRate, take.tempo),
|
||||
uploading,
|
||||
}
|
||||
: null;
|
||||
this.canvas.invalidate();
|
||||
}
|
||||
|
||||
/** The take as WAV to the server, then onto its track. */
|
||||
private async keep(take: Take): Promise<void> {
|
||||
this.busy = true;
|
||||
this.showTake(take, true);
|
||||
this.syncPlayButton();
|
||||
const parts = await take.recorder.finish();
|
||||
if (sampleCount(parts) === 0) {
|
||||
this.endUpload();
|
||||
this.state.setNotice('Nothing was recorded');
|
||||
return;
|
||||
}
|
||||
const song = findAssetOf(this.state.project, take.songId, 'song');
|
||||
const name = takeName(
|
||||
song?.name ?? 'Recording',
|
||||
this.state.project.assets.map((a) => a.name),
|
||||
);
|
||||
const seconds = sampleCount(parts) / take.recorder.sampleRate;
|
||||
await this.upload(take, wavBytes(parts, take.recorder.sampleRate), name, seconds);
|
||||
}
|
||||
|
||||
private async upload(
|
||||
take: Take,
|
||||
wav: Uint8Array<ArrayBuffer>,
|
||||
name: string,
|
||||
seconds: number,
|
||||
): Promise<void> {
|
||||
this.busy = true;
|
||||
this.syncPlayButton();
|
||||
try {
|
||||
const blob = new Blob([wav], { type: 'audio/wav' });
|
||||
const uploaded = await this.sounds.upload(blob, 'audio/wav', `${name}.wav`);
|
||||
this.endUpload();
|
||||
this.place(take, uploaded.file, name, seconds);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
new Dialog('The take could not be uploaded', reason, [
|
||||
{
|
||||
label: 'Download WAV',
|
||||
onClick: () => {
|
||||
this.endUpload();
|
||||
downloadWav(wav, name);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Discard',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
this.endUpload();
|
||||
this.state.setNotice('Threw the take away');
|
||||
},
|
||||
},
|
||||
{ label: 'Retry', primary: true, onClick: () => void this.upload(take, wav, name, seconds) },
|
||||
]).openCentered();
|
||||
}
|
||||
}
|
||||
|
||||
private endUpload(): void {
|
||||
this.busy = false;
|
||||
this.canvas.take = null;
|
||||
this.canvas.invalidate();
|
||||
this.syncPlayButton();
|
||||
}
|
||||
|
||||
/** The sound, its track and its item, one undo step; the song gone meanwhile, only the sound. */
|
||||
private place(take: Take, file: string, name: string, seconds: number): void {
|
||||
const create = new CreateSoundCommand(name, file);
|
||||
const song = findAssetOf(this.state.project, take.songId, 'song');
|
||||
if (!song) {
|
||||
if (this.state.execute(create)) this.state.setNotice(`Recorded "${name}" – its song is gone`);
|
||||
return;
|
||||
}
|
||||
const track = takeTrack();
|
||||
const tracks = [...song.tracks];
|
||||
tracks.splice(Math.min(take.index, tracks.length), 0, track);
|
||||
const length = Math.max(1, Math.floor(ticksOfSeconds(seconds, songTempo(song.props))));
|
||||
const item = newItem({ track: track.id, kind: 'sound', asset: create.asset.id, tick: take.tick, length });
|
||||
const placed = new SetSongCommand(song.id, { tracks, items: [...song.items, item] }, 'Record take');
|
||||
if (!this.state.execute(new CompoundCommand('Record take', [create, placed]))) return;
|
||||
if (this.canvas.currentAssetId === song.id) this.canvas.pick([item.id]);
|
||||
this.state.setNotice(`Recorded "${name}" (${clockText(seconds)})`);
|
||||
}
|
||||
|
||||
private async listInputs(): Promise<void> {
|
||||
try {
|
||||
this.inputs = await inputDevices();
|
||||
} catch {
|
||||
this.inputs = [];
|
||||
}
|
||||
this.syncInputButton();
|
||||
}
|
||||
|
||||
private async openInputMenu(): Promise<void> {
|
||||
await this.listInputs();
|
||||
new Menu([
|
||||
{
|
||||
label: 'Default input',
|
||||
current: !this.inputs.some((d) => d.id === this.inputId),
|
||||
onSelect: () => this.pickInput(''),
|
||||
},
|
||||
...this.inputs.map((device) => ({
|
||||
label: device.label,
|
||||
current: device.id === this.inputId,
|
||||
onSelect: () => this.pickInput(device.id),
|
||||
})),
|
||||
]).openBelow(this.inputButton);
|
||||
}
|
||||
|
||||
private pickInput(id: string): void {
|
||||
this.inputId = id;
|
||||
if (id) localStorage.setItem(INPUT_KEY, id);
|
||||
else localStorage.removeItem(INPUT_KEY);
|
||||
this.syncInputButton();
|
||||
}
|
||||
|
||||
private syncInputButton(): void {
|
||||
this.inputButton.setText(this.inputs.find((d) => d.id === this.inputId)?.label ?? 'Default input');
|
||||
}
|
||||
|
||||
private openGridMenu(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
@@ -149,12 +420,19 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
const scheduler = this.music.current;
|
||||
scheduler?.setTempo(songTempo(asset.props));
|
||||
scheduler?.setMeter(songMeter(asset.props));
|
||||
scheduler?.setLoop(songLoop(asset));
|
||||
scheduler?.setLoop(this.take ? null : songLoop(asset));
|
||||
}
|
||||
|
||||
private syncPlayButton(): void {
|
||||
this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
||||
this.playButton.setText(this.isPlaying ? 'Stop' : 'Play');
|
||||
const recording = this.isRecording;
|
||||
this.playButton.setIcon(this.isPlaying && !recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play);
|
||||
this.playButton.setText(this.isPlaying && !recording ? 'Stop' : 'Play');
|
||||
this.recordButton.setIcon(recording ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.record);
|
||||
this.recordButton.setText(recording ? 'Stop' : 'Record');
|
||||
const asset = this.asset();
|
||||
this.playButton.setEnabled(asset !== null && !this.busy);
|
||||
this.recordButton.setEnabled(asset !== null && (recording || !this.busy));
|
||||
this.inputButton.setEnabled(!recording && !this.busy);
|
||||
}
|
||||
|
||||
protected override infoText(asset: SongAsset): string {
|
||||
@@ -165,10 +443,26 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
|
||||
protected override syncExtras(asset: SongAsset | null): void {
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
this.playButton.setEnabled(asset !== null);
|
||||
this.gridButton.setEnabled(asset !== null);
|
||||
this.syncPlayButton();
|
||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||||
this.follow(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/** "1:05" */
|
||||
function clockText(seconds: number): string {
|
||||
const whole = Math.round(seconds);
|
||||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Hands the take to the browser as a file, for when the server would not take it. */
|
||||
function downloadWav(wav: Uint8Array<ArrayBuffer>, name: string): void {
|
||||
const url = URL.createObjectURL(new Blob([wav], { type: 'audio/wav' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${name}.wav`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
@@ -108,6 +108,9 @@ const PATTERN_COLOR = 0x35598f;
|
||||
const SOUND_COLOR = 0x2f6f52;
|
||||
const MISSING_COLOR = 0x6b3a3a;
|
||||
const MUTE_COLOR = 0xd08a2c;
|
||||
const TAKE_COLOR = 0xb33a3a;
|
||||
/** The id the track of a take being recorded is drawn under, until it is made. */
|
||||
const TAKE_TRACK = '__take';
|
||||
const SOLO_COLOR = 0xc9c23a;
|
||||
/** Height of an item's name strip; what it plays is drawn below. */
|
||||
const LABEL_STRIP = 15;
|
||||
@@ -159,6 +162,19 @@ type Gesture =
|
||||
| { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
|
||||
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
|
||||
|
||||
/**
|
||||
* A take being recorded (#342): the track it gets at `index`, where it starts, how far it got, and
|
||||
* its waveform – a peak per `ticksPerPeak` ticks.
|
||||
*/
|
||||
export interface TakeView {
|
||||
index: number;
|
||||
tick: number;
|
||||
length: number;
|
||||
peaks: readonly number[];
|
||||
ticksPerPeak: number;
|
||||
uploading: boolean;
|
||||
}
|
||||
|
||||
/** An asset dragged in from the panel, where it would land. */
|
||||
interface Drop {
|
||||
kind: SongItemKind;
|
||||
@@ -208,6 +224,8 @@ export class SongTimeline extends Widget {
|
||||
private readonly loading = new Set<string>();
|
||||
/** Where the playhead stands while the song plays, in ticks; null while it does not. */
|
||||
playhead: number | null = null;
|
||||
/** While set, the lanes take no edits: only a pan, Space to stop and Esc to throw it away. */
|
||||
take: TakeView | null = null;
|
||||
onTogglePlay: () => void = () => {};
|
||||
/** Where playing starts was moved: a playing song jumps there. */
|
||||
onStart: (tick: number) => void = () => {};
|
||||
@@ -215,6 +233,7 @@ export class SongTimeline extends Widget {
|
||||
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
||||
/** A pattern item was double-clicked. */
|
||||
onOpenPattern: (patternId: string) => void = () => {};
|
||||
onDiscardTake: () => void = () => {};
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
@@ -451,6 +470,11 @@ export class SongTimeline extends Widget {
|
||||
this.onTogglePlay();
|
||||
return true;
|
||||
}
|
||||
if (this.take) {
|
||||
if (event.key !== 'Escape') return false;
|
||||
this.onDiscardTake();
|
||||
return true;
|
||||
}
|
||||
if (mod && key === 'c') return this.copy();
|
||||
if (mod && key === 'v') return this.paste();
|
||||
if (mod && key === 'd') return this.duplicate() || true;
|
||||
@@ -527,6 +551,7 @@ export class SongTimeline extends Widget {
|
||||
drop: Drop | null;
|
||||
hover: { id: string; part: ItemPart } | null;
|
||||
cursor: string;
|
||||
take: (Omit<TakeView, 'peaks'> & { peaks: number }) | null;
|
||||
} {
|
||||
return {
|
||||
...this.view,
|
||||
@@ -536,6 +561,7 @@ export class SongTimeline extends Widget {
|
||||
drop: this.drop,
|
||||
hover: this.hover,
|
||||
cursor: String(this.cursor ?? ''),
|
||||
take: this.take ? { ...this.take, peaks: this.take.peaks.length } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -604,6 +630,7 @@ export class SongTimeline extends Widget {
|
||||
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
|
||||
return;
|
||||
}
|
||||
if (this.take) return;
|
||||
const g = this.inLanes(p);
|
||||
const tick = xToTick(this.view, g.x);
|
||||
if (area === 'heads') return this.pressHead(asset, e, g.y, p.x);
|
||||
@@ -727,7 +754,7 @@ export class SongTimeline extends Widget {
|
||||
const gesture = this.gesture;
|
||||
if (!asset) return;
|
||||
if (!gesture) {
|
||||
this.followHover(asset, local, g);
|
||||
if (!this.take) this.followHover(asset, local, g);
|
||||
return;
|
||||
}
|
||||
const tick = xToTick(this.view, g.x);
|
||||
@@ -1072,7 +1099,18 @@ export class SongTimeline extends Widget {
|
||||
|
||||
private shownTracks(asset: SongAsset): SongTrack[] {
|
||||
const g = this.gesture;
|
||||
return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks;
|
||||
const tracks = g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks;
|
||||
if (!this.take) return tracks;
|
||||
const shown = [...tracks];
|
||||
shown.splice(Math.min(this.take.index, shown.length), 0, {
|
||||
id: TAKE_TRACK,
|
||||
name: 'Recording',
|
||||
volume: 0,
|
||||
pan: 0,
|
||||
mute: false,
|
||||
solo: false,
|
||||
});
|
||||
return shown;
|
||||
}
|
||||
|
||||
private shownTracksWith(asset: SongAsset, g: Extract<Gesture, { kind: 'slider' }>): SongTrack[] {
|
||||
@@ -1241,7 +1279,7 @@ export class SongTimeline extends Widget {
|
||||
|
||||
private drawItems(asset: SongAsset, lw: number, lh: number): void {
|
||||
const c = Theme.colors;
|
||||
const rows = indexOf(asset.tracks);
|
||||
const rows = indexOf(this.shownTracks(asset));
|
||||
const picked = new Set(this.pickedIds);
|
||||
const labels: Placed[] = [];
|
||||
for (const item of this.shownItems(asset)) {
|
||||
@@ -1267,9 +1305,36 @@ export class SongTimeline extends Widget {
|
||||
if (this.hover?.id === item.id && this.hover.part !== 'body') this.drawGrip(box, this.hover.part);
|
||||
if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 });
|
||||
}
|
||||
const take = this.take && this.drawTake(this.take, lw);
|
||||
if (take) labels.push(take);
|
||||
this.syncTexts(this.itemTexts, this.itemLabels, labels);
|
||||
}
|
||||
|
||||
/** The take growing on its track as it is recorded, red, with what came in so far. */
|
||||
private drawTake(take: TakeView, lw: number): Placed | null {
|
||||
const box = itemBox(this.view, take.index, { tick: take.tick, length: Math.max(1, take.length) });
|
||||
if (box.x > lw || box.x + box.w < 0) return null;
|
||||
this.itemsG
|
||||
.roundRect(box.x, box.y, box.w, box.h, 3)
|
||||
.fill({ color: TAKE_COLOR, alpha: take.uploading ? 0.55 : 0.9 })
|
||||
.stroke({ width: 1, color: 0x000000, alpha: 0.45, alignment: 1 });
|
||||
const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2;
|
||||
const half = (box.h - LABEL_STRIP - 4) / 2;
|
||||
const perPx = 1 / this.view.pxPerTick / take.ticksPerPeak;
|
||||
for (let x = Math.max(0, Math.ceil(box.x)); x < Math.min(lw, box.x + box.w); x += 2) {
|
||||
const from = Math.floor((x - box.x) * perPx);
|
||||
const to = Math.max(from + 1, Math.floor((x + 2 - box.x) * perPx));
|
||||
let peak = 0;
|
||||
for (let i = from; i < to && i < take.peaks.length; i++) peak = Math.max(peak, take.peaks[i]!);
|
||||
if (from >= take.peaks.length) break;
|
||||
const reach = Math.max(0.5, peak * half);
|
||||
this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 });
|
||||
}
|
||||
if (box.w <= 18) return null;
|
||||
const text = take.uploading ? 'Uploading…' : 'Recording…';
|
||||
return { text, x: box.x + 4, y: box.y + 1, w: box.w - 8 };
|
||||
}
|
||||
|
||||
/** A line where what the item plays starts over, the label strip left clear. */
|
||||
private drawSeams(item: SongItem, box: Box, lw: number): void {
|
||||
for (const tick of this.seamsOf(item)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
|
||||
const WIDTH = 36;
|
||||
const HEIGHT = 8;
|
||||
/** From here on the bar turns red: the take is close to clipping. */
|
||||
const HOT = 0.9;
|
||||
|
||||
/** The input's level while a take is recorded (#342), a bar that is empty otherwise. */
|
||||
export class TakeMeter extends Widget {
|
||||
private readonly bar = new Graphics();
|
||||
private level = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.bar);
|
||||
this.eventMode = 'none';
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: WIDTH, h: HEIGHT };
|
||||
}
|
||||
|
||||
set(level: number): void {
|
||||
const held = Math.max(0, Math.min(1, level));
|
||||
if (Math.abs(held - this.level) < 0.005) return;
|
||||
this.level = held;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
const y = Math.round((this.h - HEIGHT) / 2);
|
||||
this.bar
|
||||
.clear()
|
||||
.rect(0, y, WIDTH, HEIGHT)
|
||||
.fill(c.viewport)
|
||||
.stroke({ width: 1, color: c.border, alignment: 1 });
|
||||
if (this.level > 0)
|
||||
this.bar
|
||||
.rect(1, y + 1, Math.max(1, (WIDTH - 2) * this.level), HEIGHT - 2)
|
||||
.fill(this.level >= HOT ? 0xd04040 : 0x4fae6a);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user