diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 7722b67..244ac72 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -96,8 +96,8 @@ const TrackInfoItem: React.FC = ({ const volumeInputRef = useRef(null); // Local flag to track slider interaction; not used for rendering const isAdjustingVolumeRef = useRef(false); - const [muted, setMuted] = useState(false); - const [solo, setSolo] = useState(false); + const [muted, setMuted] = useState(track.getMuted()); + const [solo, setSolo] = useState(track.getSolo()); // Close dropdown when clicking outside useEffect(() => { @@ -135,11 +135,10 @@ const TrackInfoItem: React.FC = ({ setVolume(track.getVolume()); }, [allTracks, track]); - // Sync mute/solo UI with audio interface state on track/project changes + // Sync mute/solo UI with the track model on track/project changes useEffect(() => { - const audioInterface = KGAudioInterface.instance(); - setMuted(audioInterface.getTrackMuted(track.getId().toString())); - setSolo(audioInterface.getTrackSolo(track.getId().toString())); + setMuted(track.getMuted()); + setSolo(track.getSolo()); }, [allTracks, track]); // Handle track name edit within the component @@ -252,22 +251,20 @@ const TrackInfoItem: React.FC = ({ e.stopPropagation(); const next = !muted; setMuted(next); - try { - KGAudioInterface.instance().setTrackMute(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { muted: next }).catch(err => { + setMuted(track.getMuted()); console.error('Failed to toggle mute:', err); - } + }); }; const handleToggleSolo = (e: React.MouseEvent) => { e.stopPropagation(); const next = !solo; setSolo(next); - try { - KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { solo: next }).catch(err => { + setSolo(track.getSolo()); console.error('Failed to toggle solo:', err); - } + }); }; // Handle track click diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index f73f921..c3d28aa 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -53,7 +53,7 @@ export class KGProject { @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 11; @Expose() @Type(() => KGTrack, { diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index bf785ea..180f88c 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -242,11 +242,13 @@ export class KGAudioInterface { console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`); // Create new audio bus - // Initialize with track's stored volume if available + // Initialize with track's stored mix state if available const project = KGCore.instance().getCurrentProject(); const track = project.getTracks().find(t => t.getId().toString() === trackId); const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; - const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0, initialMuted, initialSolo); // Connect to master gain if available, otherwise to destination if (this.masterGain) { @@ -299,7 +301,11 @@ export class KGAudioInterface { try { console.log(`Creating audio player bus for track ${trackId}`); - const playerBus = await KGAudioPlayerBus.create(volume, 0); + const project = KGCore.instance().getCurrentProject(); + const track = project.getTracks().find(t => t.getId().toString() === trackId); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const playerBus = await KGAudioPlayerBus.create(volume, 0, initialMuted, initialSolo); if (this.masterGain) { playerBus.connect(this.masterGain); diff --git a/src/core/commands/track/UpdateTrackCommand.test.ts b/src/core/commands/track/UpdateTrackCommand.test.ts new file mode 100644 index 0000000..5a97edc --- /dev/null +++ b/src/core/commands/track/UpdateTrackCommand.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../../KGCore'; +import { KGProject } from '../../KGProject'; +import { KGAudioInterface } from '../../audio-interface/KGAudioInterface'; +import { KGTrack } from '../../track/KGTrack'; +import { UpdateTrackCommand } from './UpdateTrackCommand'; + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: vi.fn(), + }, +})); + +vi.mock('../../audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: vi.fn(), + }, +})); + +describe('UpdateTrackCommand', () => { + let track: KGTrack; + let project: KGProject; + const mockCore = { + getCurrentProject: vi.fn(), + }; + const mockAudioInterface = { + setTrackVolume: vi.fn(), + setTrackInstrument: vi.fn(), + setTrackMute: vi.fn(), + setTrackSolo: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + track = new KGTrack('Track 1', 1); + project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + mockCore.getCurrentProject.mockReturnValue(project); + vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore); + vi.mocked(KGAudioInterface.instance).mockReturnValue(mockAudioInterface as unknown as KGAudioInterface); + }); + + it('updates muted state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { muted: true }); + + command.execute(); + + expect(track.getMuted()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['muted'])); + }); + + it('updates solo state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { solo: true }); + + command.execute(); + + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['solo'])); + }); + + it('restores muted and solo state on undo', () => { + track.setMuted(true); + track.setSolo(true); + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + command.undo(); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenLastCalledWith('1', true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenLastCalledWith('1', true); + }); + + it('treats unchanged mute and solo values as no-ops', () => { + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(mockAudioInterface.setTrackMute).not.toHaveBeenCalled(); + expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled(); + expect(command.getChangedProperties()).toEqual(new Set()); + }); +}); diff --git a/src/core/commands/track/UpdateTrackCommand.ts b/src/core/commands/track/UpdateTrackCommand.ts index 0a3cc84..df61e67 100644 --- a/src/core/commands/track/UpdateTrackCommand.ts +++ b/src/core/commands/track/UpdateTrackCommand.ts @@ -12,6 +12,8 @@ export interface TrackUpdateProperties { instrument?: InstrumentType; // Only applies to MIDI tracks type?: TrackType; volume?: number; + muted?: boolean; + solo?: boolean; } /** @@ -47,6 +49,8 @@ export class UpdateTrackCommand extends KGCommand { name: this.targetTrack.getName(), type: this.targetTrack.getType(), volume: this.targetTrack.getVolume(), + muted: this.targetTrack.getMuted(), + solo: this.targetTrack.getSolo(), }; // Store original instrument if it's a MIDI track @@ -103,6 +107,32 @@ export class UpdateTrackCommand extends KGCommand { updatedProperties.push(`volume: ${originalVolume} → ${newVolume}`); } + if (this.newProperties.muted !== undefined && this.newProperties.muted !== this.originalProperties.muted) { + const newMuted = this.newProperties.muted; + const originalMuted = this.originalProperties.muted; + + this.targetTrack.setMuted(newMuted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), newMuted); + + this.changedProperties.add('muted'); + updatedProperties.push(`muted: ${originalMuted} → ${newMuted}`); + } + + if (this.newProperties.solo !== undefined && this.newProperties.solo !== this.originalProperties.solo) { + const newSolo = this.newProperties.solo; + const originalSolo = this.originalProperties.solo; + + this.targetTrack.setSolo(newSolo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), newSolo); + + this.changedProperties.add('solo'); + updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`); + } + if (updatedProperties.length > 0) { console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`); } else { @@ -156,6 +186,24 @@ export class UpdateTrackCommand extends KGCommand { restoredProperties.push(`volume: ${this.originalProperties.volume}`); } + if (this.changedProperties.has('muted') && this.originalProperties.muted !== undefined) { + this.targetTrack.setMuted(this.originalProperties.muted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), this.originalProperties.muted); + + restoredProperties.push(`muted: ${this.originalProperties.muted}`); + } + + if (this.changedProperties.has('solo') && this.originalProperties.solo !== undefined) { + this.targetTrack.setSolo(this.originalProperties.solo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), this.originalProperties.solo); + + restoredProperties.push(`solo: ${this.originalProperties.solo}`); + } + console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`); } @@ -175,6 +223,12 @@ export class UpdateTrackCommand extends KGCommand { if (this.newProperties.volume !== undefined) { updatedProps.push('volume'); } + if (this.newProperties.muted !== undefined) { + updatedProps.push('muted'); + } + if (this.newProperties.solo !== undefined) { + updatedProps.push('solo'); + } if (updatedProps.length === 1) { return `Update track "${trackName}" ${updatedProps[0]}`; @@ -219,4 +273,4 @@ export class UpdateTrackCommand extends KGCommand { public getChangedProperties(): Set { return new Set(this.changedProperties); } -} \ No newline at end of file +} diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index 85b382f..a20a707 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; // --- OPFS mock infrastructure --- @@ -121,6 +122,22 @@ describe('KGProjectStorage', () => { expect(loaded!.getBpm()).toBe(120); }); + it('preserves track mute and solo state when saving and loading', async () => { + const track = new KGTrack('Track 1', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('My Song', 16, 0, 120, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + + await storage.save('My Song', project); + + const loaded = await storage.load('My Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getTracks()).toHaveLength(1); + expect(loaded!.getTracks()[0].getMuted()).toBe(true); + expect(loaded!.getTracks()[0].getSolo()).toBe(true); + }); + it('creates meta.json and media/ directory on save', async () => { const project = createTestProject('My Song'); await storage.save('My Song', project); diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index bafc84c..b70b365 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -9,6 +9,7 @@ import { upgradeToV7 } from './upgradeToV7'; import { upgradeToV8 } from './upgradeToV8'; import { upgradeToV9 } from './upgradeToV9'; import { upgradeToV10 } from './upgradeToV10'; +import { upgradeToV11 } from './upgradeToV11'; /** * Upgrade the given project to the latest structure version, one version at a time. @@ -68,6 +69,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject { workingProject = upgradeToV10(workingProject); break; } + case 11: { + workingProject = upgradeToV11(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV10.test.ts b/src/core/project-upgrader/upgradeToV10.test.ts index 3e7b28c..643c18d 100644 --- a/src/core/project-upgrader/upgradeToV10.test.ts +++ b/src/core/project-upgrader/upgradeToV10.test.ts @@ -26,7 +26,7 @@ describe('upgradeToV10', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(11); expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1); }); }); diff --git a/src/core/project-upgrader/upgradeToV11.test.ts b/src/core/project-upgrader/upgradeToV11.test.ts new file mode 100644 index 0000000..abb1832 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; +import { upgradeProjectToLatest } from './KGProjectUpgrader'; +import { upgradeToV11 } from './upgradeToV11'; + +describe('upgradeToV11', () => { + it('initializes missing mute and solo flags on legacy tracks', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(project.getProjectStructureVersion()).toBe(11); + }); + + it('preserves existing mute and solo flags', () => { + const track = new KGTrack('Legacy Track', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + }); + + it('upgrades legacy projects through the main upgrader path', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + const upgraded = upgradeProjectToLatest(project); + + expect(upgraded.getProjectStructureVersion()).toBe(11); + expect(upgraded.getTracks()[0].getMuted()).toBe(false); + expect(upgraded.getTracks()[0].getSolo()).toBe(false); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV11.ts b/src/core/project-upgrader/upgradeToV11.ts new file mode 100644 index 0000000..1ede27d --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.ts @@ -0,0 +1,21 @@ +import { KGProject } from '../KGProject'; + +export function upgradeToV11(project: KGProject): KGProject { + try { + for (const track of project.getTracks()) { + const muted = (track as unknown as { muted?: unknown }).muted; + if (typeof muted !== 'boolean') { + track.setMuted(false); + } + + const solo = (track as unknown as { solo?: unknown }).solo; + if (typeof solo !== 'boolean') { + track.setSolo(false); + } + } + } finally { + project.setProjectStructureVersion(11); + } + + return project; +} diff --git a/src/core/project-upgrader/upgradeToV8.test.ts b/src/core/project-upgrader/upgradeToV8.test.ts index ba6a341..ee13acf 100644 --- a/src/core/project-upgrader/upgradeToV8.test.ts +++ b/src/core/project-upgrader/upgradeToV8.test.ts @@ -57,7 +57,7 @@ describe('upgradeToV8', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(11); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128); }); diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index c3692a1..4ae20c2 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -37,6 +37,14 @@ export class KGTrack { @Expose() @WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; + + @Expose() + @WithDefault(false) + protected muted: boolean = false; + + @Expose() + @WithDefault(false) + protected solo: boolean = false; @Expose() @Type(() => KGRegion, { @@ -91,6 +99,14 @@ export class KGTrack { return this.volume; } + public getMuted(): boolean { + return this.muted; + } + + public getSolo(): boolean { + return this.solo; + } + // Setters public setName(name: string): void { this.name = name; @@ -120,6 +136,14 @@ export class KGTrack { ); } + public setMuted(muted: boolean): void { + this.muted = muted; + } + + public setSolo(solo: boolean): void { + this.solo = solo; + } + public setRegions(regions: KGRegion[]): void { this.regions = regions; } diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index c8bf4dd..eabd77a 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -873,6 +873,14 @@ export const useProjectStore = create((set, get) => { } } + // Reapply restored mute/solo state after all buses exist so solo logic can be + // computed against the full track set. + for (const track of tracks) { + const trackId = track.getId().toString(); + audioInterface.setTrackMute(trackId, track.getMuted()); + audioInterface.setTrackSolo(trackId, track.getSolo()); + } + // Update CSS variables updateTimeSignatureCSS(timeSignature); updateMaxBarsCSS(maxBars);