feat(theseus/screenshot): 0.6.4 — Polaroid sounds, trash icon, centred cluster, filename footer + open-in-folder

Rolling every user report from the 0.6.3 rollout into one bundle:

Sounds — the Web-Audio synth palette matches the metaphor now:
- Screenshot: Polaroid shutter — sharp metallic tick + curtain-close click
  chained to a film-advance whir (band-passed noise sweeping 900→400 Hz).
- Copy: printer "chika-chika-chika" — three descending percussive noise
  bursts pinned by short sine ticks. Reads as a print-head sweep.
- Discard: paper crumple — three overlapping band-limited noise beds
  with per-sample random-amplitude crackle, descending centre freq. No
  more descending sine "boop".
- Save: soft "photo dispensing" hiss (Polaroid ejects) + a small click.
- Both the panel and editor share the design so nothing sounds different
  depending on which surface fired it.

UI polish:
- Discard button now carries a trash-can icon so it's obviously not the
  same as the close-sidebar X (they both used to be plain X's).
- Toolbar drawing tools centre themselves via a new .tool-cluster
  wrapper (flex:1 1 auto, justify-content:center); the Copy/Save actions
  stay right-anchored via margin-left:auto on their own tgroup. Fixes
  the maximized-sidebar case where the drawing groups all crowded the
  left with a big empty gap before Copy/Save on the right.
- Filename moves out of the topbar into a dedicated footer strip under
  the canvas board, alongside a new "Open in folder" button. The topbar
  is now flex-wrap:nowrap and holds only fixed-width window controls,
  so a long filename can never push discard / sound / max / close onto
  a second row (the filename ellipsises instead).
- "Open in folder" invokes a new "openFolder" addon message that calls
  Electron's shell.showItemInFolder() to open the OS file explorer with
  the specific scratch PNG highlighted (falls back to shell.openPath()
  on the scratch dir when no capture is named).

Version bump so the OTA update endpoint picks it up on the next tick.
This commit is contained in:
Local Dev 2026-09-09 22:34:47 +02:00
parent 8eda0433d7
commit 7b8539fb8d
6 changed files with 204 additions and 49 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "screenshot", "id": "screenshot",
"name": "Screenshot", "name": "Screenshot",
"version": "0.6.3", "version": "0.6.4",
"description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.", "description": "Capture the current tab — visible viewport, full page, or a rectangle you draw. Preview + annotate editor (crop, arrow, rect, ellipse, pen, text, blur/mosaic redaction, undo, copy, save) live inside the sidebar. Expand the sidebar to full window for a canvas-sized editor.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "📸", "icon": "📸",

View file

@ -23,13 +23,24 @@ body { background: var(--bg); color: var(--ink);
.topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; .topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
border-bottom: 1px solid var(--line); background: var(--panel); border-bottom: 1px solid var(--line); background: var(--panel);
user-select: none; flex-wrap: wrap; } user-select: none; flex-wrap: wrap; }
/* Toolbar layout: drawing groups pack left/centre, the Copy/Save actions /* The topbar has fixed-size window controls only; no reason to ever wrap
pin to the right. When the sidebar is too narrow to hold everything in its row. Filename lives in the footer now, so nothing can grow the row
one row, each group wraps as a unit (never a single dot orphaned) and past what the icons need. */
the actions cluster wraps as its own row on the right. */ .topbar { flex-wrap: nowrap; }
.toolbar { justify-content: flex-start; gap: 10px; row-gap: 6px; } /* Toolbar layout: drawing tools live inside `.tool-cluster` which is a
flex-grow container centring its own contents. Copy/Save (`.tgroup
.actions`) sit outside it and pin to the right edge. On a maximized
sidebar the cluster centres in the empty space between name-area and
the actions; at narrow widths the whole cluster shrinks and wraps by
category, and the actions can wrap to their own row on the right. */
.toolbar { align-items: flex-start; gap: 8px; row-gap: 6px; }
.tool-cluster {
display: flex; flex-wrap: wrap; align-items: center;
gap: 10px; row-gap: 6px;
flex: 1 1 auto; justify-content: center;
}
.tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } .tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.tgroup.actions { margin-left: auto; } .tgroup.actions { flex: 0 0 auto; align-self: center; }
/* Close-sidebar button — reads as danger without shouting. */ /* Close-sidebar button — reads as danger without shouting. */
#close-sidebar:hover, #close-sidebar:hover,
@ -38,6 +49,21 @@ body { background: var(--bg); color: var(--ink);
.btn.danger:hover { border-color: rgba(255,91,91,.55); } .btn.danger:hover { border-color: rgba(255,91,91,.55); }
.topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden; .topbar .name { color: var(--dim); font-size: 11.5px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; } text-overflow: ellipsis; white-space: nowrap; max-width: 200px; margin: 0 4px; }
/* Footer strip that carries the filename + "Open in folder" affordance,
sitting under the canvas board. The filename truncates first so the
button never falls off the edge; the button never wraps. */
.footer { display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-top: 1px solid var(--line);
background: var(--panel); font-size: 11.5px; }
.footer .fname { color: var(--dim); flex: 1 1 auto; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.footer .fbtn { border: 1px solid var(--line); background: var(--panel2);
color: var(--ink); border-radius: 6px; cursor: pointer;
padding: 4px 8px; display: inline-flex; gap: 6px; align-items: center;
flex: 0 0 auto; font: inherit; line-height: 1; }
.footer .fbtn svg { width: 14px; height: 14px; display: block; }
.footer .fbtn:hover { border-color: rgb(from var(--acid) r g b / .55); }
.topbar .spacer, .toolbar .spacer { flex: 1; } .topbar .spacer, .toolbar .spacer { flex: 1; }
.btn, .tool { .btn, .tool {

View file

@ -10,7 +10,6 @@
<button class="btn" id="back" title="Back to sidebar panel"> <button class="btn" id="back" title="Back to sidebar panel">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg>
</button> </button>
<span class="name" id="name">screenshot</span>
<!-- Crop mode splits the topbar into a confirm/cancel prompt while the <!-- Crop mode splits the topbar into a confirm/cancel prompt while the
user is dragging a crop rectangle. Copy / Save / Discard live in user is dragging a crop rectangle. Copy / Save / Discard live in
@ -27,7 +26,12 @@
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn danger" id="discard" title="Discard this capture and return to the panel"> <button class="btn danger" id="discard" title="Discard this capture and return to the panel">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 4h10"/>
<path d="M6 4V2h4v2"/>
<path d="M4 4l1 10h6l1-10"/>
<path d="M7 6.5v5M9 6.5v5"/>
</svg>
</button> </button>
<button class="btn" id="toggle-sound" title="Toggle sound"></button> <button class="btn" id="toggle-sound" title="Toggle sound"></button>
<button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore"> <button class="btn" id="toggle-max" title="Expand the sidebar to full window / restore">
@ -41,8 +45,13 @@
<!-- Toolbar rows are grouped so that when the sidebar is too narrow to <!-- Toolbar rows are grouped so that when the sidebar is too narrow to
hold everything, whole categories wrap together (tools stay with hold everything, whole categories wrap together (tools stay with
tools, swatches with swatches, widths with widths, history with tools, swatches with swatches, widths with widths, history with
history) instead of one dot spilling to a lonely second row. --> history) instead of one dot spilling to a lonely second row.
`.tool-cluster` centres itself so on a maximized sidebar the drawing
groups sit in the middle of the row and the Copy/Save actions
right-anchor cleanly against the edge, instead of the tools crowding
the left when there's a lot of empty space. -->
<div class="toolbar"> <div class="toolbar">
<div class="tool-cluster">
<div class="tgroup" role="group" aria-label="Tools"> <div class="tgroup" role="group" aria-label="Tools">
<button class="tool" data-tool="crop" title="Crop (C)"> <button class="tool" data-tool="crop" title="Crop (C)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1v11h11M1 4h11v11"/></svg>
@ -90,6 +99,7 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8c0-3-2-5-5-5S3 5 3 8s2 5 5 5"/><path d="M10 5l3 3-3 3"/></svg>
</button> </button>
</div> </div>
</div>
<!-- Actions cluster — Copy / Save — anchored to the right of the toolbar. <!-- Actions cluster — Copy / Save — anchored to the right of the toolbar.
Uses margin-left:auto so on wide sidebars it lives out at the end Uses margin-left:auto so on wide sidebars it lives out at the end
@ -118,6 +128,18 @@
</div> </div>
</div> </div>
<!-- Footer strip: the current capture's filename lives here now (moved out
of the topbar so its length can never push the window-control icons
to a second row), with an "Open in folder" affordance for jumping to
the scratch dir in the OS file explorer. -->
<div class="footer">
<span class="fname" id="name">screenshot</span>
<button class="fbtn" id="open-folder" title="Open the scratch folder in the file explorer">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h4l1.5 2H14v6H2z"/><path d="M2 5V3h5"/></svg>
<span>Open in folder</span>
</button>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<a id="download-link" style="display:none"></a> <a id="download-link" style="display:none"></a>

View file

@ -44,17 +44,21 @@ function _tone(freq, dur, type, vol, at) {
osc.start(t0); osc.start(t0);
osc.stop(t0 + dur + 0.02); osc.stop(t0 + dur + 0.02);
} }
// A short filtered noise burst — the mechanical component of the shutter // Filtered noise burst. `bp` = band-pass centre freq, `q` = sharpness of
// click and (paired lower down) the "paper feed" texture of the print // the band. Optional `pan` gives a random-amplitude micro-modulation
// sound. `bp` is the band-pass centre frequency, `q` how tight the band. // texture — used by the paper-crumple pass.
function _noise(dur, vol, bp, q, at) { function _noise(dur, vol, bp, q, at, texture) {
if (!soundOn) return; if (!soundOn) return;
const c = _actx(); if (!c) return; const c = _actx(); if (!c) return;
const t0 = c.currentTime + (at || 0); const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur)); const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate); const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0); const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; for (let i = 0; i < n; i++) {
let s = Math.random() * 2 - 1;
if (texture) s *= 0.4 + Math.random() * 0.6; // crackle
d[i] = s;
}
const src = c.createBufferSource(); src.buffer = buf; const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass"; const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(bp || 2000, t0); flt.frequency.setValueAtTime(bp || 2000, t0);
@ -66,26 +70,64 @@ function _noise(dur, vol, bp, q, at) {
src.start(t0); src.start(t0);
src.stop(t0 + dur + 0.02); src.stop(t0 + dur + 0.02);
} }
// Photoshoot: two clicks — mirror-slap up, mirror-slap down. A short // Slowly-sweeping band-pass noise — used as the film-advance "whir" tail
// high-band noise burst is the metallic ping, a low-band burst is the // under the shutter click.
// mechanical thud, and a very short low tone fills in the body of the function _sweep(dur, vol, fromHz, toHz, q, at) {
// shutter closing. if (!soundOn) return;
const c = _actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(fromHz, t0);
flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur);
flt.Q.setValueAtTime(q || 8, t0);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(c.destination);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
// Polaroid: sharp mechanical click (shutter mirror + curtain) followed by
// a short film-advance whir. Reads unmistakably as "camera taking a
// picture" rather than a UI beep.
function playShutter() { function playShutter() {
_noise(0.03, 0.28, 4200, 6, 0); // first click, up _noise(0.02, 0.32, 5200, 8, 0); // sharp metallic tick
_tone(180, 0.04, "square", 0.10, 0.005); _tone(160, 0.03, "square", 0.14, 0.003); // mirror thud
_noise(0.04, 0.32, 3600, 5, 0.08); // second click, down _noise(0.03, 0.24, 3200, 5, 0.03); // curtain close
_tone(140, 0.05, "square", 0.12, 0.09); _tone(120, 0.04, "square", 0.10, 0.035);
_sweep(0.28, 0.10, 900, 400, 12, 0.07); // film-advance whir tail
} }
// Copy: two-chirp "printer feed" — matches Firefox's Easy Screenshot copy // Printer chika-chika-chika — three descending percussive bursts modelled
// feedback (staccato ascending pair, no residual hum). // on a print head sweeping across a page. Fast and unmistakably "action
// happened", no residual hum.
function playCopy() { function playCopy() {
_noise(0.02, 0.14, 3200, 8, 0); _noise(0.028, 0.22, 3800, 9, 0.00);
_tone(1400, 0.05, "sine", 0.14, 0); _tone(1200, 0.03, "sine", 0.10, 0.00);
_noise(0.02, 0.14, 4200, 8, 0.06); _noise(0.028, 0.22, 3200, 9, 0.06);
_tone(2000, 0.06, "sine", 0.14, 0.06); _tone(1000, 0.03, "sine", 0.10, 0.06);
_noise(0.028, 0.22, 2600, 9, 0.12);
_tone(800, 0.03, "sine", 0.10, 0.12);
}
// Photo dispensing — a soft pneumatic hiss with a small final click, the
// way a Polaroid ejects its print.
function playSave() {
_sweep(0.18, 0.14, 3800, 1600, 3, 0);
_noise(0.02, 0.20, 2400, 8, 0.18);
_tone(900, 0.04, "sine", 0.12, 0.20);
}
// Paper crumple — a long band-limited noise with texture crackle and a
// descending centre frequency, reading as "a page being scrunched".
function playDiscard() {
_noise(0.16, 0.28, 2600, 3, 0.00, true); // main body
_noise(0.12, 0.22, 1800, 3, 0.06, true); // trailing scrunch
_noise(0.08, 0.16, 1200, 3, 0.12, true);
} }
function playSave() { _tone(700, 0.06, "sine", 0.16, 0); _tone(1100, 0.08, "sine", 0.16, 0.05); _tone(1500, 0.09, "sine", 0.16, 0.10); }
function playDiscard() { _tone(500, 0.06, "sine", 0.18, 0); _tone(280, 0.10, "sine", 0.14, 0.05); }
function playCropApply() { _tone(1200, 0.05, "sine", 0.14, 0); _tone(1600, 0.07, "sine", 0.14, 0.04); } function playCropApply() { _tone(1200, 0.05, "sine", 0.14, 0); _tone(1600, 0.07, "sine", 0.14, 0.04); }
const base = $("base"); const base = $("base");
@ -618,6 +660,21 @@ if (cancelCropBtn) cancelCropBtn.addEventListener("click", cancelCrop);
// -- top-bar navigation ---------------------------------------------- // -- top-bar navigation ----------------------------------------------
$("back").addEventListener("click", () => { location.href = "panel.html"; }); $("back").addEventListener("click", () => { location.href = "panel.html"; });
// "Open in folder" — sends the current capture's name to the addon's
// openFolder handler, which calls shell.showItemInFolder() so the file
// explorer opens with the exact scratch PNG highlighted.
const openFolderBtn = document.getElementById("open-folder");
if (openFolderBtn) openFolderBtn.addEventListener("click", async () => {
try {
if (window.silentmode?.invoke) {
await window.silentmode.invoke("openFolder", { name: NAME || "" });
}
} catch (e) {
console.warn("open-folder failed:", e);
toast("Couldn't open folder: " + (e && e.message || e), true);
}
});
// Discard — throw away this capture entirely (delete it from the Recent // Discard — throw away this capture entirely (delete it from the Recent
// ring) and return to the panel. Back is non-destructive; Discard is not. // ring) and return to the panel. Back is non-destructive; Discard is not.
const discardBtn = document.getElementById("discard"); const discardBtn = document.getElementById("discard");

View file

@ -126,6 +126,22 @@ module.exports = {
return { name: hit.name, dataUrl: readAsDataUrl(hit.path), bytes: hit.bytes, at: hit.at, mode: hit.mode }; return { name: hit.name, dataUrl: readAsDataUrl(hit.path), bytes: hit.bytes, at: hit.at, mode: hit.mode };
}); });
// Show the scratch dir (or a specific capture inside it) in the OS
// file explorer. `api.require("electron").shell.openPath` opens the
// folder; `showItemInFolder` opens the folder AND highlights the
// file — used when the panel/editor knows a specific capture.
api.onMessage("openFolder", (payload) => {
const { shell } = api.require("electron");
const wantedName = payload && payload.name ? String(payload.name) : "";
if (wantedName) {
const recent = api.storage.get("recent", []) || [];
const hit = recent.find((r) => r.id === wantedName);
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
}
shell.openPath(scratchDir);
return { ok: true, path: scratchDir };
});
api.onMessage("clearRecent", (payload) => { api.onMessage("clearRecent", (payload) => {
const wantedName = payload && payload.name ? String(payload.name) : ""; const wantedName = payload && payload.name ? String(payload.name) : "";
let recent = api.storage.get("recent", []) || []; let recent = api.storage.get("recent", []) || [];

View file

@ -236,17 +236,21 @@
osc.start(t0); osc.start(t0);
osc.stop(t0 + dur + 0.02); osc.stop(t0 + dur + 0.02);
} }
// Filtered noise burst — the metallic "click" of the shutter and the // Filtered noise burst — camera clicks, printer thwacks, and (with
// paper-feed texture of the print sound. Band-pass around `bp` with // texture=true) the crackle of crumpled paper. `bp` = band-pass centre
// sharpness `q` picks a resonance so we don't just play white noise. // freq, `q` = sharpness of the band.
function noise(dur, vol, bp, q, at) { function noise(dur, vol, bp, q, at, texture) {
if (!soundOn) return; if (!soundOn) return;
const c = actx(); if (!c) return; const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0); const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur)); const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate); const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0); const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; for (let i = 0; i < n; i++) {
let s = Math.random() * 2 - 1;
if (texture) s *= 0.4 + Math.random() * 0.6;
d[i] = s;
}
const src = c.createBufferSource(); src.buffer = buf; const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass"; const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(bp || 2000, t0); flt.frequency.setValueAtTime(bp || 2000, t0);
@ -258,23 +262,53 @@
src.start(t0); src.start(t0);
src.stop(t0 + dur + 0.02); src.stop(t0 + dur + 0.02);
} }
// Photoshoot shutter — mirror-slap up (metallic ping + soft thud), then // Sweeping band-pass noise — used as the film-advance whir tail of the
// mirror-slap down. Reads as a real camera click rather than a beep. // Polaroid shutter.
function sweep(dur, vol, fromHz, toHz, q, at) {
if (!soundOn) return;
const c = actx(); if (!c) return;
const t0 = c.currentTime + (at || 0);
const n = Math.max(1, Math.floor(c.sampleRate * dur));
const buf = c.createBuffer(1, n, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = "bandpass";
flt.frequency.setValueAtTime(fromHz, t0);
flt.frequency.exponentialRampToValueAtTime(toHz, t0 + dur);
flt.Q.setValueAtTime(q || 8, t0);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + dur * 0.2);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(c.destination);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
// Polaroid: mirror + curtain click, then a film-advance whir.
function playShutter() { function playShutter() {
noise(0.03, 0.28, 4200, 6, 0); noise(0.02, 0.32, 5200, 8, 0);
tone(180, 0.04, "square", 0.10, 0.005); tone(160, 0.03, "square", 0.14, 0.003);
noise(0.04, 0.32, 3600, 5, 0.08); noise(0.03, 0.24, 3200, 5, 0.03);
tone(140, 0.05, "square", 0.12, 0.09); tone(120, 0.04, "square", 0.10, 0.035);
sweep(0.28, 0.10, 900, 400, 12, 0.07);
} }
// Copy — two-chirp "printer feed", modeled on Firefox Easy Screenshot's // Printer chika-chika-chika — 3 descending percussive bursts.
// copy feedback: fast staccato ascending pair, no residual hum.
function playCopy() { function playCopy() {
noise(0.02, 0.14, 3200, 8, 0); noise(0.028, 0.22, 3800, 9, 0.00);
tone(1400, 0.05, "sine", 0.14, 0); tone(1200, 0.03, "sine", 0.10, 0.00);
noise(0.02, 0.14, 4200, 8, 0.06); noise(0.028, 0.22, 3200, 9, 0.06);
tone(2000, 0.06, "sine", 0.14, 0.06); tone(1000, 0.03, "sine", 0.10, 0.06);
noise(0.028, 0.22, 2600, 9, 0.12);
tone(800, 0.03, "sine", 0.10, 0.12);
}
// Paper crumple — long band-limited noise with crackle texture and a
// descending centre frequency.
function playDiscard() {
noise(0.16, 0.28, 2600, 3, 0.00, true);
noise(0.12, 0.22, 1800, 3, 0.06, true);
noise(0.08, 0.16, 1200, 3, 0.12, true);
} }
function playDiscard() { tone(500, 0.06, "sine", 0.18, 0); tone(280, 0.10, "sine", 0.14, 0.05); }
function paintSound() { function paintSound() {
btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable"; btnSound.title = soundOn ? "Sounds on — click to mute" : "Sounds off — click to enable";
btnSound.innerHTML = soundOn btnSound.innerHTML = soundOn