Public: anyone can read this; the crew can change it.
Touch targets and the export tail #4
Check · Review · Land · Live done — Landed and live in production.
vesper/3-touch-targets-and-the-export-tail → main 1 commit
7 files
changed +310
−8
client/main.ts +15 −3
| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { defaultTrack, parseProject, type InstrumentKind, type Project, type Track } from "../src/project.ts"; |
| 11 | import { exportNote } from "../src/export.ts"; | |
| 11 | 12 | import { midiToName, pentatonic } from "../src/music.ts"; |
| 12 | import { loopBeats, stepCount } from "../src/schedule.ts"; | |
| 13 | import { songSeconds, stepCount } from "../src/schedule.ts"; | |
| 13 | 14 | import { Engine } from "./engine.ts"; |
| 14 | 15 | import { Transport } from "./transport.ts"; |
| 15 | 16 | import { Grid } from "./grid.ts"; |
| @@ -60,6 +61,7 @@ function edit(change: (draft: Project) => void, options: { redraw?: boolean; sav | ||
| 60 | 61 | change(draft); |
| 61 | 62 | project = parseProject(draft, project.name); |
| 62 | 63 | audio?.engine.setProject(project); |
| 64 | showExportLength(); | |
| 63 | 65 | const track = project.tracks[selected]; |
| 64 | 66 | if (options.redraw !== false) render(); |
| 65 | 67 | else if (track) { |
| @@ -123,6 +125,14 @@ const viewButton = need<HTMLButtonElement>("view"); | ||
| 123 | 125 | const playButton = need<HTMLButtonElement>("play"); |
| 124 | 126 | const position = need("pos"); |
| 125 | 127 | const rowInfo = need("row-info"); |
| 128 | const loopsInput = need<HTMLSelectElement>("loops"); | |
| 129 | const exportNoteBox = need("export-note"); | |
| 130 | ||
| 131 | /** How long the file would be if you pressed Export now. Tempo, bars, loops | |
| 132 | * and every effect that rings move it, so it is written again on each edit. */ | |
| 133 | const showExportLength = (): void => { | |
| 134 | exportNoteBox.textContent = exportNote(project, Number(loopsInput.value)); | |
| 135 | }; | |
| 126 | 136 | |
| 127 | 137 | function trackRows(): void { |
| 128 | 138 | tracksBox.replaceChildren(); |
| @@ -200,6 +210,7 @@ function render(): void { | ||
| 200 | 210 | barsInput.value = String(project.bars); |
| 201 | 211 | viewButton.textContent = track.view === "roll" ? "piano roll" : "steps"; |
| 202 | 212 | rowInfo.textContent = `${track.name} · ${stepCount(project)} steps`; |
| 213 | showExportLength(); | |
| 203 | 214 | trackRows(); |
| 204 | 215 | pads(); |
| 205 | 216 | instrumentBox.replaceChildren( |
| @@ -317,15 +328,16 @@ need("new").addEventListener("click", () => { | ||
| 317 | 328 | }); |
| 318 | 329 | |
| 319 | 330 | const exportButton = need<HTMLButtonElement>("export"); |
| 331 | loopsInput.addEventListener("change", showExportLength); | |
| 320 | 332 | exportButton.addEventListener("click", async () => { |
| 321 | 333 | exportButton.disabled = true; |
| 322 | const loops = Number(need<HTMLSelectElement>("loops").value); | |
| 334 | const loops = Number(loopsInput.value); | |
| 323 | 335 | say(`rendering ${loops} loop${loops === 1 ? "" : "s"}…`); |
| 324 | 336 | const began = performance.now(); |
| 325 | 337 | try { |
| 326 | 338 | const bytes = await renderWav(project, loops); |
| 327 | 339 | download(bytes, `${project.name}.wav`); |
| 328 | const seconds = (loopBeats(project) * loops * 60) / project.tempo; | |
| 340 | const seconds = songSeconds(project, loops); | |
| 329 | 341 | say(`${(bytes.length / 1024).toFixed(0)} KB · ${seconds.toFixed(1)} s of music in ${Math.round(performance.now() - began)} ms`); |
| 330 | 342 | } catch (error) { |
| 331 | 343 | say(`export failed: ${error instanceof Error ? error.message : "unknown"}`); |
src/export.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { DEFAULT_LOOPS, exportLength, exportNote, LOOP_CHOICES } from "./export.ts"; | |
| 3 | import { defaultTrack, parseProject, type Project } from "./project.ts"; | |
| 4 | import { MAX_TAIL, renderSeconds, songSeconds } from "./schedule.ts"; | |
| 5 | ||
| 6 | const song = (change: (draft: Project) => void = () => {}): Project => { | |
| 7 | const draft = parseProject({ name: "t", tempo: 120, swing: 0, bars: 1, tracks: [defaultTrack("a")] }); | |
| 8 | change(draft); | |
| 9 | return parseProject(draft, "t"); | |
| 10 | }; | |
| 11 | ||
| 12 | test("a render is the music plus the tail, and the two add up to what is rendered", () => { | |
| 13 | const project = song(); | |
| 14 | const { music, tail, total } = exportLength(project, 2); | |
| 15 | // one bar at 120 bpm is two seconds; two loops are four | |
| 16 | expect(music).toBeCloseTo(4, 9); | |
| 17 | expect(music).toBe(songSeconds(project, 2)); | |
| 18 | expect(tail).toBeGreaterThan(0); | |
| 19 | expect(total).toBeCloseTo(music + tail, 9); | |
| 20 | expect(total).toBeCloseTo(renderSeconds(project, 2), 9); | |
| 21 | }); | |
| 22 | ||
| 23 | test("the tail grows with the effects and stops at eight seconds", () => { | |
| 24 | const dry = exportLength(song((d) => (d.tracks[0]!.effects.reverb.mix = 0))).tail; | |
| 25 | const wet = exportLength( | |
| 26 | song((d) => { | |
| 27 | d.tracks[0]!.effects.reverb.mix = 1; | |
| 28 | d.tracks[0]!.effects.reverb.seconds = 6; | |
| 29 | }), | |
| 30 | ).tail; | |
| 31 | expect(wet).toBeGreaterThan(dry); | |
| 32 | const endless = exportLength( | |
| 33 | song((d) => { | |
| 34 | d.tracks[0]!.effects.delay.mix = 1; | |
| 35 | d.tracks[0]!.effects.delay.steps = 16; | |
| 36 | d.tracks[0]!.effects.delay.feedback = 0.9; | |
| 37 | d.tempo = 40; | |
| 38 | }), | |
| 39 | ); | |
| 40 | expect(endless.tail).toBe(MAX_TAIL); | |
| 41 | expect(endless.total).toBe(endless.music + MAX_TAIL); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the note says the length, the two parts of it, and the cap", () => { | |
| 45 | const line = exportNote(song(), 2); | |
| 46 | expect(line).toContain("4.0 s of music"); | |
| 47 | expect(line).toContain("of WAV"); | |
| 48 | expect(line).toContain("effect tails"); | |
| 49 | expect(line).toContain("up to 8 s past the loop"); | |
| 50 | expect(line.split("\n")).toHaveLength(1); | |
| 51 | // it moves with the loop count and with the tempo | |
| 52 | expect(exportNote(song(), 4)).toContain("8.0 s of music"); | |
| 53 | expect(exportNote(song((d) => (d.tempo = 60)), 1)).toContain("4.0 s of music"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the loops the page offers include the one it starts on", () => { | |
| 57 | expect(LOOP_CHOICES).toContain(DEFAULT_LOOPS); | |
| 58 | }); |
src/export.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | /** | |
| 2 | * How long the exported file will be, and how to say it in one line. | |
| 3 | * | |
| 4 | * A render is the music plus the tails: the last note keeps ringing through | |
| 5 | * its release, its reverb and its delay, so the WAV is longer than the loop — | |
| 6 | * by up to `MAX_TAIL` seconds. The page says so next to Export before anyone | |
| 7 | * presses it, and the same sentence is printed in the terminal. | |
| 8 | */ | |
| 9 | ||
| 10 | import { MAX_TAIL, songSeconds, tailSeconds } from "./schedule.ts"; | |
| 11 | import type { Project } from "./project.ts"; | |
| 12 | ||
| 13 | /** How many passes of the loop the page offers to render. */ | |
| 14 | export const LOOP_CHOICES = [1, 2, 4] as const; | |
| 15 | export const DEFAULT_LOOPS = 2; | |
| 16 | ||
| 17 | export type ExportLength = { music: number; tail: number; total: number }; | |
| 18 | ||
| 19 | /** The seconds an export takes, split into the music and the tail after it. */ | |
| 20 | export function exportLength(project: Project, loops = 1): ExportLength { | |
| 21 | const music = songSeconds(project, loops); | |
| 22 | const tail = tailSeconds(project); | |
| 23 | return { music, tail, total: music + tail }; | |
| 24 | } | |
| 25 | ||
| 26 | const s = (seconds: number): string => `${seconds.toFixed(1)} s`; | |
| 27 | ||
| 28 | /** One line: what the file will be, and why it is longer than the loop. */ | |
| 29 | export function exportNote(project: Project, loops = 1): string { | |
| 30 | const { music, tail, total } = exportLength(project, loops); | |
| 31 | return `${s(total)} of WAV: ${s(music)} of music + ${s(tail)} of effect tails (up to ${MAX_TAIL} s past the loop)`; | |
| 32 | } |
src/pages/studio.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * The page's stylesheet, read the way a browser would read the parts that | |
| 3 | * matter here: how big a control is under a finger. The rules are parsed out | |
| 4 | * of `STYLES`, `rem` is resolved against the 15px root and `var(--tap)` | |
| 5 | * against its declaration, so a control that shrinks below 44px fails here | |
| 6 | * rather than on someone's phone. | |
| 7 | */ | |
| 8 | ||
| 9 | import { expect, test } from "bun:test"; | |
| 10 | import { exportNote } from "../export.ts"; | |
| 11 | import { demoProject } from "../project.ts"; | |
| 12 | import { studioPage, STYLES } from "./studio.ts"; | |
| 13 | ||
| 14 | const ROOT_PX = 15; // :root sets font: 15px/1.4 | |
| 15 | const TOUCH_BLOCK = "@media (pointer: coarse), (max-width: 900px)"; | |
| 16 | ||
| 17 | /** The text inside `header { … }`, brace-matched so nested rules come along. */ | |
| 18 | function block(css: string, header: string): string { | |
| 19 | const open = css.indexOf(header); | |
| 20 | expect(open).toBeGreaterThanOrEqual(0); | |
| 21 | let depth = 0; | |
| 22 | for (let i = css.indexOf("{", open); i < css.length; i++) { | |
| 23 | if (css[i] === "{") depth++; | |
| 24 | if (css[i] === "}" && --depth === 0) return css.slice(css.indexOf("{", open) + 1, i); | |
| 25 | } | |
| 26 | throw new Error(`unclosed ${header}`); | |
| 27 | } | |
| 28 | ||
| 29 | /** Every rule in one level of CSS, as selector -> declarations. */ | |
| 30 | function rules(css: string): Map<string, Record<string, string>> { | |
| 31 | const found = new Map<string, Record<string, string>>(); | |
| 32 | for (const [, selectors, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { | |
| 33 | if (selectors!.trim().startsWith("@")) continue; | |
| 34 | const decls = Object.fromEntries( | |
| 35 | body! | |
| 36 | .split(";") | |
| 37 | .map((part) => part.split(":").map((s) => s.trim())) | |
| 38 | .filter((pair) => pair.length === 2) | |
| 39 | .map(([name, value]) => [name!, value!]), | |
| 40 | ); | |
| 41 | for (const selector of selectors!.split(",")) { | |
| 42 | const key = selector.trim(); | |
| 43 | found.set(key, { ...(found.get(key) ?? {}), ...decls }); | |
| 44 | } | |
| 45 | } | |
| 46 | return found; | |
| 47 | } | |
| 48 | ||
| 49 | // the rules that apply to a mouse on a wide screen: the sheet with every | |
| 50 | // media block taken out of it | |
| 51 | const sheet = [ | |
| 52 | TOUCH_BLOCK, | |
| 53 | "@media (pointer: coarse) and (min-width: 901px)", | |
| 54 | "@media (max-width: 900px)", | |
| 55 | ].reduce((css, header) => css.replace(block(css, header), ""), STYLES); | |
| 56 | const base = rules(sheet); | |
| 57 | const touch = rules(block(STYLES, TOUCH_BLOCK)); | |
| 58 | ||
| 59 | const tap = Number(/--tap:\s*(\d+)px/.exec(STYLES)?.[1]); | |
| 60 | ||
| 61 | /** A length in px: a number, a rem, or the tap variable. */ | |
| 62 | function px(value: string): number { | |
| 63 | if (value === "var(--tap)") return tap; | |
| 64 | const rem = /^([\d.]+)rem$/.exec(value); | |
| 65 | if (rem) return Number(rem[1]) * ROOT_PX; | |
| 66 | const abs = /^([\d.]+)px$/.exec(value); | |
| 67 | if (abs) return Number(abs[1]); | |
| 68 | return NaN; | |
| 69 | } | |
| 70 | ||
| 71 | /** What a control ends up measuring on a touch screen: base rule, then the | |
| 72 | * touch block, later wins — which is how the cascade orders them too. */ | |
| 73 | function size(property: string, ...selectors: string[]): number { | |
| 74 | let out = 0; | |
| 75 | for (const level of [base, touch]) | |
| 76 | for (const selector of selectors) { | |
| 77 | const value = level.get(selector)?.[property]; | |
| 78 | if (value !== undefined) out = px(value); | |
| 79 | } | |
| 80 | return out; | |
| 81 | } | |
| 82 | ||
| 83 | test("the tap target is the 44px a thumb needs", () => { | |
| 84 | expect(tap).toBe(44); | |
| 85 | }); | |
| 86 | ||
| 87 | test("every control is at least a tap wide and tall under a finger", () => { | |
| 88 | // the flags the issue names: 2rem — 30px — for a cursor, a full tap for a thumb | |
| 89 | expect(px(base.get(".flag")!.width!)).toBe(30); | |
| 90 | expect(size("width", "button", ".flag")).toBe(44); | |
| 91 | expect(size("min-width", "button", ".flag")).toBe(44); | |
| 92 | ||
| 93 | // every control on the page, and the rules that decide how tall it is | |
| 94 | const controls: [string, ...string[]][] = [ | |
| 95 | ["min-height", "button"], | |
| 96 | ["min-height", "select"], | |
| 97 | ["min-height", "summary"], | |
| 98 | ["min-height", "input[type=text]"], | |
| 99 | ["min-height", "input[type=range]"], | |
| 100 | ["min-height", "button", "button.small"], | |
| 101 | ["min-width", "button", "button.small"], | |
| 102 | ["min-height", "select", ".track-add select"], | |
| 103 | ["min-height", "select", ".knob select"], | |
| 104 | ["min-height", "button", ".pad"], | |
| 105 | ["min-height", ".songs a"], | |
| 106 | ]; | |
| 107 | const measured = controls.map(([property, ...selectors]) => [ | |
| 108 | `${selectors.at(-1)} ${property}`, | |
| 109 | size(property, ...selectors), | |
| 110 | ]); | |
| 111 | // naming them this way says which control fell short, not just that one did | |
| 112 | expect(measured.filter(([, value]) => !(Number(value) >= 44))).toEqual([]); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the desktop grid is left alone: three columns, compact rows", () => { | |
| 116 | expect(base.get("main")!["grid-template-columns"]).toBe("11rem minmax(0, 1fr) 16rem"); | |
| 117 | expect(px(base.get("button")!["min-height"]!)).toBe(30); | |
| 118 | // the touch block never touches the layout — only a coarse pointer on a wide | |
| 119 | // screen widens the track column, by what the two flags grew | |
| 120 | expect(touch.has("main")).toBe(false); | |
| 121 | expect(block(STYLES, "@media (pointer: coarse) and (min-width: 901px)")).toContain("14rem minmax(0, 1fr) 16rem"); | |
| 122 | // and under a phone it is still one column | |
| 123 | expect(STYLES).toContain("@media (max-width: 900px) { main { grid-template-columns: 1fr; } }"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the open sheet clears a summary that has grown to a tap", () => { | |
| 127 | expect(touch.get("details .sheet")!.top).toBe("calc(var(--tap) + .3rem)"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the page says what Export will write, next to Export", () => { | |
| 131 | const project = demoProject(); | |
| 132 | const page = studioPage({ | |
| 133 | project, | |
| 134 | saved: [], | |
| 135 | app: "/static/main.js", | |
| 136 | ticker: "/static/ticker.js", | |
| 137 | env: "local", | |
| 138 | commit: "", | |
| 139 | }); | |
| 140 | const note = /<button id="export"[^>]*>Export WAV<\/button>\s*<span class="export-note" id="export-note">([^<]+)<\/span>/.exec( | |
| 141 | page, | |
| 142 | ); | |
| 143 | expect(note?.[1]).toContain("of music"); | |
| 144 | expect(note?.[1]).toContain("effect tails"); | |
| 145 | expect(note?.[1]).toContain("up to 8 s past the loop"); | |
| 146 | expect(note![1]).toMatch(/^\d+\.\d s of WAV: \d+\.\d s of music \+ \d+\.\d s of effect tails/); | |
| 147 | // it is the length for the loop count the select starts on, not for one loop | |
| 148 | expect(page).toContain('<option value="2" selected>2</option>'); | |
| 149 | expect(note![1]).toBe(exportNote(project, 2)); | |
| 150 | expect(note![1]).not.toBe(exportNote(project, 1)); | |
| 151 | }); |
src/pages/studio.ts +32 −2
| @@ -6,17 +6,21 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Saved } from "../db.ts"; |
| 9 | import { DEFAULT_LOOPS, exportNote, LOOP_CHOICES } from "../export.ts"; | |
| 9 | 10 | import { MAX_BARS, type Project } from "../project.ts"; |
| 10 | 11 | import { stepCount } from "../schedule.ts"; |
| 11 | 12 | |
| 12 | 13 | export const escape = (s: string): string => |
| 13 | 14 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); |
| 14 | 15 | |
| 15 | const STYLES = ` | |
| 16 | export const STYLES = ` | |
| 16 | 17 | :root { |
| 17 | 18 | color-scheme: dark; |
| 18 | 19 | --bg: #0c0d12; --panel: #14161f; --line: #262a37; --ink: #e6e8ef; --dim: #8b93a7; |
| 19 | 20 | --accent: #5eead4; --hot: #f4a25a; |
| 21 | /* What a thumb needs. Every control is at least this square where the | |
| 22 | pointer is a finger; see the coarse-pointer block at the end. */ | |
| 23 | --tap: 44px; | |
| 20 | 24 | font: 15px/1.4 ui-sans-serif, system-ui, sans-serif; |
| 21 | 25 | } |
| 22 | 26 | * { box-sizing: border-box; } |
| @@ -81,8 +85,31 @@ const STYLES = ` | ||
| 81 | 85 | details .sheet { position: absolute; z-index: 5; top: 2.4rem; left: 0; width: 19rem; max-width: 90vw; |
| 82 | 86 | background: var(--panel); border: 1px solid var(--line); border-radius: .5rem; padding: .6rem; } |
| 83 | 87 | noscript p { padding: .75rem; color: var(--hot); } |
| 88 | .export-note { color: var(--dim); font-size: .75rem; } | |
| 84 | 89 | footer { color: var(--dim); font-size: .75rem; padding: .5rem .75rem 2rem; } |
| 85 | 90 | footer code { color: var(--ink); } |
| 91 | ||
| 92 | /* A finger is not a mouse: where the pointer is coarse, or the layout has | |
| 93 | folded into one column, every control is at least --tap on both sides — | |
| 94 | the mute and solo flags most of all, which are 2rem for a cursor. The | |
| 95 | desktop grid is untouched; the only thing that moves with a touchscreen | |
| 96 | on a wide screen is the track column, which grows by what the two flags | |
| 97 | took, so a track name keeps its room. */ | |
| 98 | @media (pointer: coarse), (max-width: 900px) { | |
| 99 | button, select, summary, input[type=text] { min-height: var(--tap); } | |
| 100 | button, summary { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; } | |
| 101 | button.small { padding: .35rem .6rem; font-size: .8rem; min-width: var(--tap); } | |
| 102 | .track .pick { flex-direction: column; justify-content: center; align-items: flex-start; } | |
| 103 | .flag { width: var(--tap); min-width: var(--tap); padding: .2rem; } | |
| 104 | .track-add select, .knob select { min-height: var(--tap); font-size: .8rem; padding: .35rem .4rem; } | |
| 105 | input[type=range] { min-height: var(--tap); } | |
| 106 | .songs li { padding: .1rem 0; } | |
| 107 | .songs a { display: flex; align-items: center; min-height: var(--tap); } | |
| 108 | details .sheet { top: calc(var(--tap) + .3rem); } | |
| 109 | } | |
| 110 | @media (pointer: coarse) and (min-width: 901px) { | |
| 111 | main { grid-template-columns: 14rem minmax(0, 1fr) 16rem; } | |
| 112 | } | |
| 86 | 113 | `; |
| 87 | 114 | |
| 88 | 115 | const songList = (saved: Saved[], current: string): string => |
| @@ -141,8 +168,11 @@ export function studioPage({ project, saved, app, ticker, env, pull, commit, ssh | ||
| 141 | 168 | <button id="save" type="button">Save</button> |
| 142 | 169 | <button id="new" type="button">New</button> |
| 143 | 170 | <details><summary>Open</summary><div class="sheet">${songList(saved, project.name)}</div></details> |
| 144 | <label>loops <select id="loops"><option>1</option><option selected>2</option><option>4</option></select></label> | |
| 171 | <label>loops <select id="loops">${LOOP_CHOICES.map( | |
| 172 | (n) => `<option value="${n}"${n === DEFAULT_LOOPS ? " selected" : ""}>${n}</option>`, | |
| 173 | ).join("")}</select></label> | |
| 145 | 174 | <button id="export" type="button">Export WAV</button> |
| 175 | <span class="export-note" id="export-note">${escape(exportNote(project, DEFAULT_LOOPS))}</span> | |
| 146 | 176 | </section> |
| 147 | 177 | <noscript><p>The studio needs JavaScript to make sound: the synths, the sequencer and the export all run in this page. The song below is real and stored on the server.</p></noscript> |
| 148 | 178 | <main> |
src/schedule.ts +18 −3
| @@ -82,8 +82,19 @@ export function planWindow(project: Project, fromBeat: number, toBeat: number): | ||
| 82 | 82 | export const planSong = (project: Project, loops = 1): Event[] => |
| 83 | 83 | planWindow(project, 0, loopBeats(project) * loops); |
| 84 | 84 | |
| 85 | /** How long a render must be: the song, plus room for the tails to ring out. */ | |
| 86 | export function renderSeconds(project: Project, loops = 1): number { | |
| 85 | /** The most a render keeps going after the last note, whatever the effects ask for. */ | |
| 86 | export const MAX_TAIL = 8; | |
| 87 | ||
| 88 | /** The music itself: `loops` passes through the loop, in seconds. */ | |
| 89 | export const songSeconds = (project: Project, loops = 1): number => | |
| 90 | loopBeats(project) * loops * beatSeconds(project.tempo); | |
| 91 | ||
| 92 | /** | |
| 93 | * How long the tails ring after the last note: the slowest voice's release | |
| 94 | * plus whichever of its reverb and its delay lasts longer, capped at | |
| 95 | * `MAX_TAIL` so a long reverb on a slow delay cannot make an endless file. | |
| 96 | */ | |
| 97 | export function tailSeconds(project: Project): number { | |
| 87 | 98 | const tail = project.tracks.reduce((most, track) => { |
| 88 | 99 | const instrument = track.instrument; |
| 89 | 100 | const release = |
| @@ -97,5 +108,9 @@ export function renderSeconds(project: Project, loops = 1): number { | ||
| 97 | 108 | : 0; |
| 98 | 109 | return Math.max(most, release + Math.max(reverb, delay)); |
| 99 | 110 | }, 0.25); |
| 100 | return loopBeats(project) * loops * beatSeconds(project.tempo) + Math.min(tail, 8); | |
| 111 | return Math.min(tail, MAX_TAIL); | |
| 101 | 112 | } |
| 113 | ||
| 114 | /** How long a render must be: the song, plus room for the tails to ring out. */ | |
| 115 | export const renderSeconds = (project: Project, loops = 1): number => | |
| 116 | songSeconds(project, loops) + tailSeconds(project); |
src/tty.ts +4
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | type InstrumentKind, |
| 18 | 18 | type Project, |
| 19 | 19 | } from "./project.ts"; |
| 20 | import { exportNote } from "./export.ts"; | |
| 20 | 21 | import { midiToName } from "./music.ts"; |
| 21 | 22 | import { stepCount } from "./schedule.ts"; |
| 22 | 23 | |
| @@ -75,6 +76,9 @@ function drawSong(): string[] { | ||
| 75 | 76 | const steps = stepCount(project); |
| 76 | 77 | const lines = [ |
| 77 | 78 | `${bold(project.name)} ${dim(`${Math.round(project.tempo)} bpm · swing ${Math.round(project.swing * 100)}% · ${project.bars} bars · ${where}`)}`, |
| 79 | // The export itself is WebAudio and lives in the browser, but what it | |
| 80 | // would write is arithmetic, and it is the same sentence the page shows. | |
| 81 | dim(`export, one loop: ${exportNote(project, 1)}`), | |
| 78 | 82 | "", |
| 79 | 83 | ]; |
| 80 | 84 | project.tracks.forEach((t, index) => { |