diff --git a/checkbox-checked.png b/checkbox-checked.png new file mode 100644 index 0000000..9888bdd Binary files /dev/null and b/checkbox-checked.png differ diff --git a/checkbox-unchecked.png b/checkbox-unchecked.png new file mode 100644 index 0000000..0a6d8fe Binary files /dev/null and b/checkbox-unchecked.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..1c0c734 Binary files /dev/null and b/icon.png differ diff --git a/index.html b/index.html index 810636a..71f8104 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ AutoTracker - + @@ -147,6 +147,54 @@ margin: 0; } + @media (min-width: 600px) { + .muted:after { + content: " MUTE"; + font-weight: normal; + } + } + + @media (max-width: 599px) { + .muted:after { + content: " M"; + font-weight: normal; + } + } + + h3 { + cursor: default; + } + + input#regenerateEnabled[type=checkbox] { + display:none; + } + + input#regenerateEnabled[type=checkbox] + label { + margin-left: 8px; + display: inline-block; + padding: 0 0 0 0px; + background: url("checkbox-unchecked.png") no-repeat; + height: 32px; + width: 32px; + background-size: 80%; + vertical-align: middle; + } + + input#regenerateEnabled[type=checkbox]:checked + label { + background: url("checkbox-checked.png") no-repeat; + background-size: 80%; + } + + button#forceGenerate { + cursor: default; + padding: 0; + border: none; + background: url("regenerate.png") no-repeat; + width: 32px; + height: 32px; + background-size: 80%; + vertical-align: middle; + } diff --git a/regenerate.png b/regenerate.png new file mode 100644 index 0000000..df21b61 Binary files /dev/null and b/regenerate.png differ diff --git a/src/audio.ts b/src/audio.ts index c2680be..b167165 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -5,7 +5,7 @@ */ import {fill, rnd} from './utils.js' -type Synth = { play: (note: T) => void} +export type Synth = { play: (note: T) => void, mute: (muted: boolean) => void} const A3Frequency = 440; const A0Frequency = A3Frequency / 8; @@ -63,6 +63,8 @@ function Audio(ctx: AudioContext) { pulseOutputGain.connect(outputPanner); outputPanner.connect(ctx.destination); + let muted = false; + const freq = wavetableTrigger.frequency, width = wavetableOffsetGain.gain, gain = pulseOutputGain.gain; @@ -79,7 +81,7 @@ function Audio(ctx: AudioContext) { slide(gain, 0, release); } function play(note: Note) { - if (note.note === "---") { + if (muted || note.note === "---") { noteOff(); } else if (note.note === 'cont') { // do nothing @@ -89,7 +91,11 @@ function Audio(ctx: AudioContext) { set(width, note.fx?.pulseWidth ?? 0.0); } - return {play} + function mute(_muted: boolean) { + muted = _muted; + } + + return {play, mute} } function DrumSynth(): Synth { @@ -111,10 +117,21 @@ function Audio(ctx: AudioContext) { noiseGain.connect(noisePan); noisePan.connect(ctx.destination); + let muted = false; + + function drumOff() { + toneGain.gain.cancelScheduledValues(ctx.currentTime); + toneGain.gain.setValueAtTime(0, ctx.currentTime); + noiseGain.gain.cancelScheduledValues(ctx.currentTime); + noiseGain.gain.setValueAtTime(0, ctx.currentTime); + } - function play(slot: Drum) { + function play(this: Synth, slot: Drum) { const vel = slot.vel ? slot.vel : 1; - if (slot.drum === 'KCK') { + + if (muted) { + drumOff() + } else if (slot.drum === 'KCK') { toneOscillator.detune.cancelScheduledValues(ctx.currentTime); toneOscillator.detune.setValueAtTime(3000, ctx.currentTime); toneOscillator.detune.setTargetAtTime(0, ctx.currentTime, 0.07); @@ -143,9 +160,12 @@ function Audio(ctx: AudioContext) { noiseGain.gain.setValueCurveAtTime(new Float32Array([0.2 * vel,0.15 * vel,0.0]), ctx.currentTime, 0.15); } } - return { - play, + + function mute(_muted: boolean) { + muted = _muted; } + + return {play, mute} } return { SquareSynth, diff --git a/src/display.ts b/src/display.ts index 62a6aaf..436dfef 100644 --- a/src/display.ts +++ b/src/display.ts @@ -3,6 +3,8 @@ This work is licensed under a Creative Commons Attribution 4.0 International License https://creativecommons.org/licenses/by/4.0/ */ +import { settings, synths } from "./tracker.js"; + const A0 = -12; function textRepr(slot: Slot) { @@ -31,19 +33,39 @@ function textRepr(slot: Slot) { } function PatternDisplay(display: HTMLElement) { + const regenerateCheckbox = document.createElement("input"); function setPatterns(newPats: Pattern[], saveString: string) { - display.innerHTML = "
Pattern ID: " + saveString + "
"; + display.innerHTML = + `
+ Pattern ID: ${saveString} + + +
`; + document.getElementById("regenerateEnabled")?.addEventListener("input", e => + settings.regenerateEnabled = (e.target as HTMLInputElement).checked + ); + document.getElementById("forceGenerate")?.addEventListener("click", e => + settings.forceGenerate = true + ); const container = document.createElement("div"); container.classList.add("columns"); display.append(container); function add(pattern: Pattern, index: number) { const pDisplay = document.createElement("code"); pDisplay.innerHTML = - "

" + (index === 4 ? "*" : "⎍") + (index + 1) + "

" + + `

+ ${index === 4 ? "*" : "⎍"} ${index + 1} +

` + pattern.map((x, i) => "
" + textRepr(x) + "
").join(""); container.append(pDisplay); + + document.getElementById(`patternHeader${index}`)?.addEventListener("click", e => { + settings.muted[index] = !settings.muted[index]; + synths[index].mute(settings.muted[index]); + (e.target as HTMLElement).classList.toggle("muted", settings.muted[index]); + }); } newPats.forEach((p, i) => add(p, i)) } diff --git a/src/generators.ts b/src/generators.ts index 824420f..9657308 100644 --- a/src/generators.ts +++ b/src/generators.ts @@ -46,7 +46,7 @@ function bass(context: MusicContext): Pattern { } function bass2(context: MusicContext): Pattern { return fill(PatternSize, i => { const chord = getChord(context, i); - return {note: i % 8 === 0 ? ((chord[0] + 4) % 12) - 4: 'cont', vel: 2, fx: {pulseWidth: rnd()}} as Note; + return {note: i % 8 === 0 ? ((chord[0] + 4) % 12) - 4: 'cont', fx: {pulseWidth: rnd()}} as Note; })} function melody1(context: MusicContext): Pattern { const slow = flip(); diff --git a/src/tracker.ts b/src/tracker.ts index f0ebb6f..e9d1e4a 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -7,7 +7,7 @@ import PatternDisplay from './display.js' import {choose, fill, rndInt, rnd, seedRNG} from './utils.js' -import Audio from "./audio.js"; +import Audio, { Synth } from "./audio.js"; import * as music from './theory.js' import * as Generators from './generators.js' import {scales} from "./theory.js"; @@ -26,12 +26,11 @@ const progressions = [ [1,1,1,1,1,1,1,1,4,4,4,4,4,4,4,4] ]; -type Synth = { play: (note: T) => void} type FourChannelsPlusDrums = [Note, Note, Note, Note, Drum] type PatternsType = { [K in keyof T]: Pattern }; type SynthsType = { [K in keyof T]: Synth } - +export let synths: SynthsType interface State { key: Key, @@ -42,6 +41,14 @@ interface State { seedCode: string } +class Settings { + regenerateEnabled: boolean = true; + forceGenerate: boolean = false; + muted: boolean[] = [false, false, false, false, false]; +} + +export const settings = new Settings() + type SaveCode = string & {typeTag: "__SaveCode"} function hex(v: number) { return Math.floor(v).toString(16).toUpperCase().padStart(2,'0'); } @@ -75,15 +82,25 @@ function restore(code: SaveCode): State { function bpmClock() { let intervalHandle = { - bpmClock: 0 + bpmClock: 0, + frameFunction: (_: number) => {} }; let fN = 0; function set(bpm: number, frameFunction: (f: number) => void) { window.clearInterval(intervalHandle.bpmClock); intervalHandle.bpmClock = window.setInterval(() => frameFunction(fN++), (60000 / bpm) / 4); + intervalHandle.frameFunction = frameFunction; + } + function setBpm(bpm: number) { + set(bpm, intervalHandle.frameFunction); + } + function resetFrameNumber() { + fN = 0; } return { - set + set, + setBpm, + resetFrameNumber } } @@ -139,7 +156,7 @@ function start() { const ctx: AudioContext = new (window.AudioContext || window.webkitAudioContext)() as AudioContext; const au = Audio(ctx); - const synths: SynthsType = [ + synths = [ au.SquareSynth(), au.SquareSynth(-0.5), au.SquareSynth(), @@ -166,21 +183,30 @@ function start() { function frame(f: number) { const positionInPattern = f % PatternSize; - if (f % 128 === 0 && f!== 0) { + if (settings.forceGenerate || settings.regenerateEnabled && f % 128 === 0 && f!== 0) { + settings.forceGenerate = false; mutateState(state); newPatterns(); - clock.set(state.bpm, frame); + clock.resetFrameNumber(); + clock.setBpm(state.bpm); display.setPatterns(patterns, save(state)); } display.highlightRow(positionInPattern); - // Not a loop because these tuple parts have different types depending on melody vs drum - synths[0].play(patterns[0][positionInPattern]); - synths[1].play(patterns[1][positionInPattern]); - synths[2].play(patterns[2][positionInPattern]); - synths[3].play(patterns[3][positionInPattern]); - synths[4].play(patterns[4][positionInPattern]); + try { + // Not a loop because these tuple parts have different types depending on melody vs drum + synths[0].play(patterns[0][positionInPattern]); + synths[1].play(patterns[1][positionInPattern]); + synths[2].play(patterns[2][positionInPattern]); + synths[3].play(patterns[3][positionInPattern]); + synths[4].play(patterns[4][positionInPattern]); + } catch (e: any) { + // Ignore DOMException: AudioParam.setValueAtTime: Can't add events during a curve event + if (e.name !== "NotSupportedError") { + throw e; + } + } }