Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions src/components/MatrixGrid.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computePlaybackNote } from '@/music/matrix'
import type { MatrixCell } from '@/music/matrix'
import type { AnswerResult } from '@/music/scoring'
import { useAudio } from '@/composables/useAudio'
import { Note } from '@/music/tonal'

const { playNote } = useAudio()

Expand Down Expand Up @@ -56,19 +56,7 @@ function isActive(cell: MatrixCell): boolean {
}

function noteForColumnPlayback(cell: MatrixCell): string {
if (!cell.note || !props.intervalSemitones) return cell.note
const givenRow = cell.col // diagonal: given is always at row === col
const givenCell = props.cells[givenRow]?.[cell.col]
if (!givenCell?.note) return cell.note
const givenMidi = Note.midi(`${givenCell.note}4`) ?? 60
const semDiff = (props.intervalSemitones[cell.row] ?? 0) - (props.intervalSemitones[givenRow] ?? 0)
const targetMidi = givenMidi + semDiff
const { letter, alt } = Note.get(cell.note)
// Note.get().chroma wraps mod 12, which is wrong for Cb/Cbb/B#/B## whose alt pushes the
// pitch class outside 0-11; rebuild the unbounded value from the natural letter + alt instead.
const unboundedChroma = Note.get(letter).chroma + alt
const octave = Math.round((targetMidi - unboundedChroma) / 12) - 1
return `${cell.note}${octave}`
return computePlaybackNote(props.cells, props.intervalSemitones, cell)
}

function handleClick(cell: MatrixCell) {
Expand Down
13 changes: 7 additions & 6 deletions src/components/NotePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
import { computed } from 'vue'
import { parseNote } from '@/music/note'
import type { NoteLetter, Accidental } from '@/music/note'
import { useAudio } from '@/composables/useAudio'

const { playNote } = useAudio()

const model = defineModel<string | null>()

defineProps<{
disabled: boolean
}>()

const emit = defineEmits<{
'note-click': [note: string]
}>()

const LETTERS: NoteLetter[] = ['C', 'D', 'E', 'F', 'G', 'A', 'B']
const ACCIDENTALS: { value: Accidental; label: string }[] = [
{ value: 'bb', label: 'bb' },
Expand All @@ -31,20 +32,20 @@ function selectLetter(letter: NoteLetter) {
const candidate = letter + activeAccidental.value
if (parseNote(candidate)) {
model.value = candidate
playNote(candidate)
emit('note-click', candidate)
return
}
}
model.value = letter
playNote(letter)
emit('note-click', letter)
}

function selectAccidental(acc: Accidental) {
if (activeLetter.value === null) return
const candidate = activeLetter.value + acc
if (parseNote(candidate)) {
model.value = candidate
playNote(candidate)
emit('note-click', candidate)
}
}
</script>
Expand Down
9 changes: 5 additions & 4 deletions src/components/PianoKeyboard.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseNote } from '@/music/note'
import { useAudio } from '@/composables/useAudio'

const { playNote } = useAudio()

const props = defineProps<{
activeNote?: string | null
activeNotes?: string[]
activeOnly?: boolean
}>()

const emit = defineEmits<{
'note-click': [note: string]
}>()

const activeChroma = computed<number | null>(() => {
if (!props.activeNote) return null
return parseNote(props.activeNote)?.chroma ?? null
Expand All @@ -32,7 +33,7 @@ function isKeyActive(noteName: string): boolean {

function handleKeyClick(noteName: string): void {
if (props.activeOnly && !isKeyActive(noteName)) return
playNote(noteName)
emit('note-click', noteName)
}

const WHITE_KEY_WIDTH = 36
Expand Down
22 changes: 19 additions & 3 deletions src/components/PlayingView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import NotePicker from '@/components/NotePicker.vue'
import PianoKeyboard from '@/components/PianoKeyboard.vue'
import TheoryModal from '@/components/TheoryModal.vue'
import { useAudio } from '@/composables/useAudio'
import { computePlaybackNote } from '@/music/matrix'
import type { MatrixCell } from '@/music/matrix'

const { playJingle } = useAudio()
const { playNote, playJingle } = useAudio()

const gameStore = useGameStore()
const progressStore = useProgressStore()
Expand Down Expand Up @@ -103,6 +104,17 @@ const puzzleIntervalSemitones = computed<number[]>(() => {
return entry?.intervals.map((iv) => Interval.semitones(iv) ?? 0) ?? []
})

function playNoteForActiveCell(note: string): void {
if (!activeCell.value || session.value.phase !== 'playing') return
const { row, col } = activeCell.value
const playbackNote = computePlaybackNote(session.value.puzzle.cells, puzzleIntervalSemitones.value, {
row,
col,
note,
})
playNote(playbackNote)
}

const puzzleTitle = computed(() => {
if (session.value.phase !== 'playing') return ''
return formatPuzzleTitle(session.value.puzzle.diagonalNote, session.value.puzzle.quality)
Expand Down Expand Up @@ -185,9 +197,13 @@ function submit() {
>Hint</button>
</div>

<PianoKeyboard v-if="showPiano" :active-note="activeCellNote" />
<PianoKeyboard v-if="showPiano" :active-note="activeCellNote" @note-click="playNoteForActiveCell" />

<NotePicker v-model="activeCellNote" :disabled="activeCell === null" />
<NotePicker
v-model="activeCellNote"
:disabled="activeCell === null"
@note-click="playNoteForActiveCell"
/>

<button class="submit-btn" @click="submit">Submit</button>
</main>
Expand Down
32 changes: 32 additions & 0 deletions src/components/__tests__/NotePicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,36 @@ describe('NotePicker', () => {
}
})
})

describe('note-click emit', () => {
it('emits note-click alongside update:modelValue when selecting a letter', async () => {
const wrapper = mount(NotePicker, {
props: { modelValue: null, disabled: false, 'onUpdate:modelValue': () => {} },
})
await wrapper.findAll('.letters-row .picker-btn')[2]!.trigger('click') // E
expect(wrapper.emitted('note-click')).toEqual([['E']])
})

it('emits note-click with the combined note when selecting an accidental', async () => {
const wrapper = mount(NotePicker, {
props: { modelValue: 'F', disabled: false, 'onUpdate:modelValue': () => {} },
})
await wrapper.findAll('.accidentals-row .picker-btn')[3]!.trigger('click') // #
expect(wrapper.emitted('note-click')).toEqual([['F#']])
})

it('emits both update:modelValue and note-click from the same click', async () => {
const updates: (string | null)[] = []
const wrapper = mount(NotePicker, {
props: {
modelValue: null,
disabled: false,
'onUpdate:modelValue': (v: string | null | undefined) => updates.push(v ?? null),
},
})
await wrapper.findAll('.letters-row .picker-btn')[4]!.trigger('click') // G
expect(updates).toEqual(['G'])
expect(wrapper.emitted('note-click')).toEqual([['G']])
})
})
})
26 changes: 26 additions & 0 deletions src/components/__tests__/PianoKeyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,30 @@ describe('PianoKeyboard', () => {
expect((key.element as HTMLElement).style.left).toBeTruthy()
}
})

describe('note-click emit', () => {
it('emits note-click with the bare pitch class when a white key is clicked', async () => {
const wrapper = mount(PianoKeyboard)
await wrapper.findAll('.white-key')[2]!.trigger('click') // E
expect(wrapper.emitted('note-click')).toEqual([['E']])
})

it('emits note-click with the bare pitch class when a black key is clicked', async () => {
const wrapper = mount(PianoKeyboard)
await wrapper.findAll('.black-key')[0]!.trigger('click') // C#
expect(wrapper.emitted('note-click')).toEqual([['C#']])
})

it('does not emit note-click for an inactive key when activeOnly is set', async () => {
const wrapper = mount(PianoKeyboard, { props: { activeOnly: true, activeNote: 'C' } })
await wrapper.findAll('.white-key')[2]!.trigger('click') // E, not active
expect(wrapper.emitted('note-click')).toBeUndefined()
})

it('emits note-click for the active key when activeOnly is set', async () => {
const wrapper = mount(PianoKeyboard, { props: { activeOnly: true, activeNote: 'C' } })
await wrapper.findAll('.white-key')[0]!.trigger('click') // C, active
expect(wrapper.emitted('note-click')).toEqual([['C']])
})
})
})
52 changes: 52 additions & 0 deletions src/components/__tests__/PlayingView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { setActivePinia, createPinia } from 'pinia'
import PlayingView from '@/components/PlayingView.vue'
import { useGameStore } from '@/stores/game'

const mockPlayNote = vi.fn<(note: string) => void>()
vi.mock('@/composables/useAudio', () => ({
useAudio: () => ({ playNote: mockPlayNote, playJingle: vi.fn<() => void>() }),
}))

const DEFAULT_OPTIONS = { noDegreeLabels: false, noPianoKeyboard: false }

function startGame(opts = DEFAULT_OPTIONS) {
Expand Down Expand Up @@ -111,6 +116,53 @@ describe('PlayingView', () => {
})
})

describe('piano keyboard / note picker playback (issue #122)', () => {
beforeEach(() => {
mockPlayNote.mockClear()
})

it('does not play a note when no cell is active', async () => {
startGame()
const wrapper = mountView()
await wrapper.findComponent({ name: 'PianoKeyboard' }).vm.$emit('note-click', 'C')
expect(mockPlayNote).not.toHaveBeenCalled()
})

it("plays a piano key note in the given cell's own octave when a given cell is active", async () => {
useGameStore().startPuzzle('C', 'dom9', DEFAULT_OPTIONS)
const wrapper = mountView()

await wrapper.find('.matrix-cell.given').trigger('click')
await wrapper.findComponent({ name: 'PianoKeyboard' }).vm.$emit('note-click', 'G')

// Given cells always yield a zero semitone offset from themselves, so any
// clicked note is placed in the same octave register as the given C4.
expect(mockPlayNote).toHaveBeenCalledWith('G3')
})

it('plays the octave-correct note when clicking a piano key with a non-given cell active', async () => {
useGameStore().startPuzzle('C', 'dom9', DEFAULT_OPTIONS)
const wrapper = mountView()

// Reversed row order puts row 4 (the 9th-degree row) first in the DOM;
// its non-given col-0 cell is the "D" (9th) of the C dom9 chord, 14 semitones above C4.
await wrapper.findAll('.matrix-cell')[0]!.trigger('click')
await wrapper.findComponent({ name: 'PianoKeyboard' }).vm.$emit('note-click', 'D')

expect(mockPlayNote).toHaveBeenCalledWith('D5')
})

it('plays the octave-correct note when NotePicker emits note-click', async () => {
useGameStore().startPuzzle('C', 'dom9', DEFAULT_OPTIONS)
const wrapper = mountView()

await wrapper.findAll('.matrix-cell')[0]!.trigger('click') // row4/col0, the 9th
await wrapper.findComponent({ name: 'NotePicker' }).vm.$emit('note-click', 'D')

expect(mockPlayNote).toHaveBeenCalledWith('D5')
})
})

describe('answer submission flow', () => {
it('stores the answer when NotePicker emits a note', async () => {
const game = startGame()
Expand Down
Loading