-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ref #367
- Loading branch information
Showing
13 changed files
with
652 additions
and
891 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import { computed, ref } from 'vue' | ||
import { defineStore } from 'pinia' | ||
import type { Input, Output } from 'webmidi' | ||
import { MidiOut } from 'xen-midi' | ||
import { useAudioStore } from './audio' | ||
|
||
export const useMidiStore = defineStore('midi', () => { | ||
const audio = useAudioStore() | ||
|
||
const input = ref<Input | null>(null) | ||
const output = ref<Output | null>(null) | ||
const inputChannels = ref(new Set([1])) | ||
// Channel 10 is reserved for percussion | ||
const outputChannels = ref(new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16])) | ||
const velocityOn = ref(true) | ||
|
||
const whiteMode = ref<'off' | 'simple' | 'blackAverage' | 'keyColors'>('off') | ||
|
||
const out = computed(() => new MidiOut(output.value as Output, outputChannels.value)) | ||
|
||
// === MIDI input / output === | ||
|
||
function sendNoteOn(frequency: number, rawAttack: number) { | ||
const midiOff = out.value.sendNoteOn(frequency, rawAttack) | ||
|
||
if (audio.synth === null || audio.virtualSynth === null) { | ||
return midiOff | ||
} | ||
|
||
// Trigger web audio API synth. | ||
const synthOff = audio.synth.noteOn(frequency, rawAttack / 127) | ||
|
||
// Trigger virtual synth for per-voice visualization. | ||
const virtualOff = audio.virtualSynth.voiceOn(frequency) | ||
|
||
const off = (rawRelease: number) => { | ||
midiOff(rawRelease) | ||
synthOff() | ||
virtualOff() | ||
} | ||
|
||
return off | ||
} | ||
|
||
return { | ||
// State | ||
input, | ||
output, | ||
inputChannels, | ||
outputChannels, | ||
velocityOn, | ||
whiteMode, | ||
// Computed state | ||
out, | ||
// Methods | ||
sendNoteOn | ||
} | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,235 @@ | ||
import { computed, reactive, ref, watch } from 'vue' | ||
import { defineStore } from 'pinia' | ||
import { | ||
Interval, | ||
Scale, | ||
parseLine, | ||
type IntervalOptions, | ||
reverseParseScale | ||
} from 'scale-workshop-core' | ||
import { DEFAULT_NUMBER_OF_COMPONENTS, NUMBER_OF_NOTES, UNIX_NEWLINE } from '@/constants' | ||
import { computeWhiteIndices } from '@/midi' | ||
import { mapWhiteAsdfBlackQwerty, mapWhiteQweZxcBlack123Asd } from '@/keyboard-mapping' | ||
import { arraysEqual } from 'xen-dev-utils' | ||
import type { AccidentalStyle } from '@/utils' | ||
|
||
export const useStateStore = defineStore('state', () => { | ||
// Nonpersistent state of the application | ||
const scaleName = ref('') | ||
const scaleLines = ref<string[]>([]) | ||
const scale = reactive(Scale.fromIntervalArray([parseLine('1/1', DEFAULT_NUMBER_OF_COMPONENTS)])) | ||
const baseMidiNote = ref(69) | ||
const keyColors = ref([ | ||
'white', | ||
'black', | ||
'white', | ||
'white', | ||
'black', | ||
'white', | ||
'black', | ||
'white', | ||
'white', | ||
'black', | ||
'white', | ||
'black' | ||
]) | ||
const isomorphicVertical = ref(5) | ||
const isomorphicHorizontal = ref(1) | ||
// Keyboard mode affects both physical qwerty and virtual keyboards | ||
const keyboardMode = ref<'isomorphic' | 'piano'>('isomorphic') | ||
// Physical layout mimics a piano layout in one or two layers | ||
const pianoMode = ref<'Asdf' | 'QweZxc0' | 'QweZxc1'>('Asdf') | ||
const equaveShift = ref(0) | ||
const degreeShift = ref(0) | ||
const heldNotes = reactive(new Map<number, number>()) | ||
const typingActive = ref(true) | ||
|
||
// These user preferences are fetched from local storage. | ||
const storage = window.localStorage | ||
const newline = ref(storage.getItem('newline') ?? UNIX_NEWLINE) | ||
|
||
const storedScheme = storage.getItem('colorScheme') | ||
const mediaScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' | ||
const colorScheme = ref<'light' | 'dark'>( | ||
(storedScheme ?? mediaScheme) === 'dark' ? 'dark' : 'light' | ||
) | ||
const centsFractionDigits = ref(parseInt(storage.getItem('centsFractionDigits') ?? '3', 10)) | ||
const decimalFractionDigits = ref(parseInt(storage.getItem('decimalFractionDigits') ?? '5', 10)) | ||
const showVirtualQwerty = ref(storage.getItem('showVirtualQwerty') === 'true') | ||
const midiOctaveOffset = ref(parseInt(storage.getItem('midiOctaveOffset') ?? '-1', 10)) | ||
const intervalMatrixIndexing = ref(parseInt(storage.getItem('intervalMatrixIndexing') ?? '0', 10)) | ||
const accidentalPreference = ref<AccidentalStyle>( | ||
(localStorage.getItem('accidentalPreference') as AccidentalStyle) ?? 'double' | ||
) | ||
|
||
// Special keyboard codes also from local storage. | ||
const deactivationCode = ref(storage.getItem('deactivationCode') ?? 'Backquote') | ||
const equaveUpCode = ref(storage.getItem('equaveUpCode') ?? 'NumpadMultiply') | ||
const equaveDownCode = ref(storage.getItem('equaveDownCode') ?? 'NumpadDivide') | ||
const degreeUpCode = ref(storage.getItem('degreeUpCode') ?? 'NumpadAdd') | ||
const degreeDownCode = ref(storage.getItem('degreeDownCode') ?? 'NumpadSubtract') | ||
|
||
// === Computed state === | ||
const frequencies = computed(() => | ||
scale.getFrequencyRange(-baseMidiNote.value, NUMBER_OF_NOTES - baseMidiNote.value) | ||
) | ||
|
||
const baseIndex = computed( | ||
() => baseMidiNote.value + equaveShift.value * scale.size + degreeShift.value | ||
) | ||
|
||
// For midi mapping | ||
const whiteIndices = computed(() => computeWhiteIndices(baseMidiNote.value, keyColors.value)) | ||
|
||
const keyboardMapping = computed<Map<string, number>>(() => { | ||
const size = scale.size | ||
const baseIndex = baseMidiNote.value + equaveShift.value * size + degreeShift.value | ||
if (pianoMode.value === 'Asdf') { | ||
return mapWhiteAsdfBlackQwerty(keyColors.value, baseMidiNote.value, baseIndex) | ||
} else if (pianoMode.value === 'QweZxc0') { | ||
return mapWhiteQweZxcBlack123Asd(keyColors.value, size, baseMidiNote.value, baseIndex, 0) | ||
} else { | ||
return mapWhiteQweZxcBlack123Asd(keyColors.value, size, baseMidiNote.value, baseIndex, 1) | ||
} | ||
}) | ||
|
||
// === State updates === | ||
function updateFromScaleLines(lines: string[]) { | ||
if (arraysEqual(lines, scaleLines.value)) { | ||
return | ||
} | ||
scaleLines.value = lines | ||
const intervals: Interval[] = [] | ||
const options: IntervalOptions = { | ||
centsFractionDigits: centsFractionDigits.value, | ||
decimalFractionDigits: decimalFractionDigits.value | ||
} | ||
lines.forEach((line) => { | ||
try { | ||
const interval = parseLine(line, DEFAULT_NUMBER_OF_COMPONENTS, options) | ||
intervals.push(interval) | ||
} catch { | ||
/* empty */ | ||
} | ||
}) | ||
if (!intervals.length) { | ||
intervals.push(parseLine('1/1', DEFAULT_NUMBER_OF_COMPONENTS, options)) | ||
} | ||
|
||
const surrogate = Scale.fromIntervalArray(intervals) | ||
scale.intervals = surrogate.intervals | ||
} | ||
|
||
function updateFromScale(surrogate: Scale) { | ||
scale.intervals = surrogate.intervals | ||
scale.baseFrequency = surrogate.baseFrequency | ||
scaleLines.value = reverseParseScale(surrogate) | ||
} | ||
|
||
// Computed wrappers to avoid triggering a watcher loop. | ||
const scaleWrapper = computed({ | ||
get() { | ||
return scale | ||
}, | ||
set: updateFromScale | ||
}) | ||
|
||
const scaleLinesWrapper = computed({ | ||
get() { | ||
return scaleLines.value | ||
}, | ||
set: updateFromScaleLines | ||
}) | ||
|
||
// Local storage watchers | ||
watch(newline, (newValue) => window.localStorage.setItem('newline', newValue)) | ||
watch( | ||
colorScheme, | ||
(newValue) => { | ||
window.localStorage.setItem('colorScheme', newValue) | ||
document.documentElement.setAttribute('data-theme', newValue) | ||
}, | ||
{ immediate: true } | ||
) | ||
watch(centsFractionDigits, (newValue) => | ||
window.localStorage.setItem('centsFractionDigits', newValue.toString()) | ||
) | ||
watch(decimalFractionDigits, (newValue) => | ||
window.localStorage.setItem('decimalFractionDigits', newValue.toString()) | ||
) | ||
watch(showVirtualQwerty, (newValue) => | ||
window.localStorage.setItem('showVirtualQwerty', newValue.toString()) | ||
) | ||
watch(midiOctaveOffset, (newValue) => | ||
window.localStorage.setItem('midiOctaveOffset', newValue.toString()) | ||
) | ||
watch(intervalMatrixIndexing, (newValue) => | ||
window.localStorage.setItem('intervalMatrixIndexing', newValue.toString()) | ||
) | ||
watch(accidentalPreference, (newValue) => localStorage.setItem('accidentalPreference', newValue)) | ||
// Store keymaps | ||
watch(deactivationCode, (newValue) => window.localStorage.setItem('deactivationCode', newValue)) | ||
watch(equaveUpCode, (newValue) => window.localStorage.setItem('equaveUpCode', newValue)) | ||
watch(equaveDownCode, (newValue) => window.localStorage.setItem('equaveDownCode', newValue)) | ||
watch(degreeUpCode, (newValue) => window.localStorage.setItem('degreeUpCode', newValue)) | ||
watch(degreeDownCode, (newValue) => window.localStorage.setItem('degreeDownCode', newValue)) | ||
|
||
// Sanity watchers | ||
watch(baseMidiNote, (newValue) => { | ||
if (isNaN(newValue)) { | ||
baseMidiNote.value = 69 | ||
} else if (Math.round(newValue) != newValue) { | ||
baseMidiNote.value = Math.round(newValue) | ||
} | ||
}) | ||
|
||
// Methods | ||
function getFrequency(index: number) { | ||
if (index >= 0 && index < frequencies.value.length) { | ||
return frequencies.value[index] | ||
} else { | ||
// Support more than 128 notes with some additional computational cost | ||
return scale.getFrequency(index - baseMidiNote.value) | ||
} | ||
} | ||
|
||
return { | ||
// Live state | ||
scaleName, | ||
scaleLinesRaw: scaleLines, | ||
scaleLines: scaleLinesWrapper, | ||
scaleRaw: scale, | ||
scale: scaleWrapper, | ||
baseMidiNote, | ||
keyColors, | ||
isomorphicVertical, | ||
isomorphicHorizontal, | ||
keyboardMode, | ||
pianoMode, | ||
equaveShift, | ||
degreeShift, | ||
heldNotes, | ||
typingActive, | ||
// Persistent state | ||
newline, | ||
colorScheme, | ||
centsFractionDigits, | ||
decimalFractionDigits, | ||
showVirtualQwerty, | ||
midiOctaveOffset, | ||
intervalMatrixIndexing, | ||
deactivationCode, | ||
equaveUpCode, | ||
equaveDownCode, | ||
degreeUpCode, | ||
degreeDownCode, | ||
accidentalPreference, | ||
// Computed state | ||
frequencies, | ||
baseIndex, | ||
whiteIndices, | ||
keyboardMapping, | ||
// Methods | ||
getFrequency | ||
} | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.