diff --git a/src/App.jsx b/src/App.jsx index 8bdb1e0..c773e0d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useAudioScheduler } from "./audio/useAudioScheduler"; import { BrowserPanel } from "./components/BrowserPanel"; +import { AiAgentWindow } from "./components/AiAgentWindow"; import { AppTitleBar } from "./components/AppTitleBar"; import { ChannelRackWindow } from "./components/ChannelRackWindow"; import { FxPluginWindow } from "./components/FxPluginWindow"; @@ -29,6 +30,7 @@ import "./styles/plugins.css"; import "./styles/pattern-list.css"; import "./styles/render-window.css"; import "./styles/auth.css"; +import "./styles/ai-agent.css"; import "./styles/load-project.css"; import "./styles/theme-main.css"; import "./styles/theme-plugins.css"; @@ -426,6 +428,16 @@ function App() { + + + + 0 && + !samplePaths.has(samplePath) + ) { + issues.push("Sample path was not found in the current Browser index."); + } + + if ( + (operation.type === "assign_sample_to_channel" || + operation.type === "add_sample_as_channel" || + operation.type === "add_playlist_audio_clip") && + !samplePath + ) { + issues.push("Missing samplePath."); + } + + if (operation.type === "set_fx_slot_effect") { + const effectType = asString(payload.effectType || "none"); + if (effectType !== "none" && !PLUGIN_EFFECT_TYPES.has(effectType)) { + issues.push("Unsupported effectType: " + effectType); + } + } + + if (operation.type === "assign_plugin_to_channel") { + const pluginRef = asString(payload.pluginRef); + if (!pluginRef) { + issues.push("Missing pluginRef."); + } else if (!PLUGIN_INSTRUMENT_BY_REF[pluginRef]) { + issues.push("Unknown instrument pluginRef: " + pluginRef); + } + } + + if (operation.type === "clear_channel_pattern") { + const mode = asString(payload.mode || "all").toLowerCase(); + if (!["all", "notes", "steps"].includes(mode)) { + issues.push("Unsupported clear mode: " + mode); + } + } + + // Warn when notes are added to a channel that has no instrument or sample + // assigned — the notes will be silent. + if ( + operation.type === "add_piano_notes" || + operation.type === "add_chord_progression" + ) { + const targetChannelId = resolveChannelId(dawState, payload); + const targetChannel = (channels || []).find(function (item) { + return item.id === targetChannelId; + }); + if ( + targetChannel && + !asString(targetChannel.pluginRef) && + !asString(targetChannel.sampleRef) + ) { + issues.push( + "Channel " + targetChannelId + " has no instrument assigned. Use assign_plugin_to_channel first.", + ); + } + } + + if (operation.type === "add_chord_progression") { + const chords = Array.isArray(payload.chords) ? payload.chords : []; + if (chords.length === 0) { + issues.push("Missing chords."); + } + + chords.forEach(function (chord, index) { + const pitches = Array.isArray(chord?.pitches) ? chord.pitches : []; + if (pitches.length < 2) { + issues.push("Chord " + (index + 1) + " must contain at least 2 pitches."); + } + }); + } + + return issues; +} + +export function validatePreparedAiOperations( + operations, + { dawState, availableSamples } = {}, +) { + const stepKeys = new Set(); + + return (Array.isArray(operations) ? operations : []).map(function (operation) { + const issues = validateAiOperation(operation, dawState || {}, availableSamples); + + if (operation.type === "set_step") { + const payload = operation.payload || {}; + const key = [ + resolvePatternId(dawState || {}, payload), + resolveChannelId(dawState || {}, payload), + Math.round(clampNumber(payload.stepIndex, 0, 127, 0)), + ].join(":"); + if (stepKeys.has(key)) { + issues.push("Duplicate set_step for the same channel and stepIndex."); + } + stepKeys.add(key); + } + + return { + ...operation, + issues, + status: issues.length > 0 ? "warning" : "ready", + }; + }); +} + +function applyClearChannelPattern(operation, dispatch, getState) { + const dawState = getDawState(getState()); + const payload = operation.payload || {}; + const patternId = resolvePatternId(dawState, payload); + const channelId = resolveChannelId(dawState, payload); + const pattern = getPatternById(dawState, patternId); + const mode = asString(payload.mode || "all").toLowerCase(); + const shouldClearNotes = mode === "all" || mode === "notes"; + const shouldClearSteps = mode === "all" || mode === "steps"; + const removals = []; + + if (shouldClearNotes) { + (Array.isArray(pattern?.pianoPreview?.[channelId]) + ? pattern.pianoPreview[channelId] + : [] + ).forEach(function (note) { + removals.push({ + id: note.id, + start: note.start, + pitch: note.pitch, + source: "piano", + }); + }); + } + + if (shouldClearSteps) { + (Array.isArray(pattern?.stepGrid?.[channelId]) + ? pattern.stepGrid[channelId] + : [] + ).forEach(function (value, stepIndex) { + if (value) { + removals.push({ start: stepIndex, source: "step" }); + } + }); + } + + if (removals.length > 0) { + dispatch(removePianoNotesBatch({ patternId, channelId, notes: removals })); + } +} + +function applyCreatePattern(operation, dispatch, getState) { + const payload = operation.payload || {}; + dispatch(createPattern({ lengthSteps: payload.lengthSteps || payload.length })); + + const nextState = getDawState(getState()); + const newPatternId = getActivePatternId(nextState); + const name = asString(payload.name); + if (newPatternId && name) { + dispatch(renamePattern({ patternId: newPatternId, name })); + } +} + +function applyAddChannel(operation, dispatch, getState) { + const payload = operation.payload || {}; + dispatch(addChannel()); + + const nextState = getDawState(getState()); + const newChannelId = getActiveChannelId(nextState); + const name = asString(payload.name); + if (newChannelId && name) { + dispatch(renameChannel({ channelId: newChannelId, name })); + } + + // Auto-assign instrument if pluginRef is provided and valid, so the + // agent can skip a separate assign_plugin_to_channel step. + const pluginRef = asString(payload.pluginRef); + if (newChannelId && pluginRef && PLUGIN_INSTRUMENT_BY_REF[pluginRef]) { + const plugin = PLUGIN_INSTRUMENT_BY_REF[pluginRef]; + dispatch(assignPluginToChannel({ + channelId: newChannelId, + pluginRef, + pluginName: plugin.name, + })); + } +} + +function applySetStep(operation, dispatch, getState) { + const dawState = getDawState(getState()); + const payload = operation.payload || {}; + const patternId = resolvePatternId(dawState, payload); + const channelId = resolveChannelId(dawState, payload); + const pattern = (dawState.project?.patterns || []).find(function (item) { + return item.id === patternId; + }); + const stepIndex = Math.round(clampNumber(payload.stepIndex, 0, 127, 0)); + const desiredValue = payload.value !== false; + const currentValue = Boolean(pattern?.stepGrid?.[channelId]?.[stepIndex]); + + if (currentValue !== desiredValue) { + dispatch(toggleStep({ patternId, channelId, stepIndex })); + } +} + +function normalizeAiVelocity(rawVelocity) { + const numeric = Number(rawVelocity); + if (!Number.isFinite(numeric) || numeric <= 0) { + return DEFAULT_AI_NOTE_VELOCITY; + } + + if (numeric <= 1) { + return Math.max(1, Math.min(127, Math.round(numeric * 127))); + } + + return Math.max(1, Math.min(127, Math.round(numeric))); +} + +function normalizeAiPitch(rawPitch) { + return Math.max(0, Math.min(127, Math.round(Number(rawPitch || 72)))); +} + +function normalizeAiNoteLength(rawLength, fallbackLength, maxLength) { + const numeric = Number(rawLength); + const fallback = Number.isFinite(Number(fallbackLength)) + ? Number(fallbackLength) + : DEFAULT_AI_NOTE_LENGTH; + const nextLength = Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + return Math.max(0.0625, Math.min(Math.max(0.0625, maxLength), nextLength)); +} + +function getPatternById(dawState, patternId) { + return (dawState.project?.patterns || []).find(function (item) { + return item.id === patternId; + }); +} + +function getPatternLength(dawState, patternId) { + const pattern = getPatternById(dawState, patternId); + return Math.max(1, Number(pattern?.lengthSteps || 16)); +} + +function getMaxPianoNoteEnd(notes) { + return (Array.isArray(notes) ? notes : []).reduce(function (maxEnd, note) { + const start = Math.max(0, Number(note?.start || 0)); + const length = Math.max(0.0625, Number(note?.length || DEFAULT_AI_NOTE_LENGTH)); + return Math.max(maxEnd, start + length); + }, 0); +} + +function getMaxChordEnd(chords) { + return (Array.isArray(chords) ? chords : []).reduce(function (maxEnd, chord) { + const start = Math.max(0, Number(chord?.start || 0)); + const length = Math.max(0.0625, Number(chord?.length || DEFAULT_AI_CHORD_LENGTH)); + return Math.max(maxEnd, start + length); + }, 0); +} + +function ensurePatternLengthForEnd(dispatch, dawState, patternId, maxEnd) { + const currentLength = getPatternLength(dawState, patternId); + if (maxEnd <= currentLength) { + return currentLength; + } + + const nextLength = Math.max(4, Math.min(128, Math.ceil(maxEnd))); + if (nextLength > currentLength) { + dispatch(setPatternLength({ patternId, length: nextLength })); + return nextLength; + } + return currentLength; +} + +function normalizeAiPianoNotes(rawNotes, patternLength, fallbackLength = DEFAULT_AI_NOTE_LENGTH) { + return (Array.isArray(rawNotes) ? rawNotes : []).map(function (note) { + const start = Math.max( + 0, + Math.min(patternLength - 0.0625, Number(note?.start || 0)), + ); + const maxLength = Math.max(0.0625, patternLength - start); + return { + ...note, + start, + length: normalizeAiNoteLength(note?.length, fallbackLength, maxLength), + pitch: normalizeAiPitch(note?.pitch), + velocity: normalizeAiVelocity(note?.velocity), + }; + }); +} + +function normalizeAiChordProgression(chords, patternLength) { + const notes = []; + + (Array.isArray(chords) ? chords : []).forEach(function (chord, chordIndex) { + const pitches = Array.isArray(chord?.pitches) ? chord.pitches : []; + if (pitches.length < 2) { + return; + } + + const start = Math.max( + 0, + Math.min(patternLength - 0.0625, Number(chord?.start || 0)), + ); + const maxLength = Math.max(0.0625, patternLength - start); + const length = Math.max( + MIN_AI_CHORD_LENGTH, + normalizeAiNoteLength(chord?.length, DEFAULT_AI_CHORD_LENGTH, maxLength), + ); + const safeLength = Math.min(length, maxLength); + const velocity = normalizeAiVelocity(chord?.velocity); + + pitches.forEach(function (pitch, pitchIndex) { + notes.push({ + id: "ai-chord-" + chordIndex + "-" + pitchIndex, + start, + length: safeLength, + pitch: normalizeAiPitch(pitch), + velocity, + }); + }); + }); + + return notes; +} + +function applyAiOperation(operation, dispatch, getState) { + const dawState = getDawState(getState()); + const payload = operation.payload || {}; + + if (operation.type === "create_pattern") { + applyCreatePattern(operation, dispatch, getState); + return true; + } + + if (operation.type === "set_bpm") { + dispatch(setBpm(payload.bpm || payload.value)); + return true; + } + + if (operation.type === "set_active_pattern") { + dispatch(setActivePattern(resolvePatternId(dawState, payload))); + return true; + } + + if (operation.type === "rename_pattern") { + dispatch(renamePattern({ + patternId: resolvePatternId(dawState, payload), + name: payload.name, + })); + return true; + } + + if (operation.type === "set_pattern_length") { + dispatch(setPatternLength({ + patternId: resolvePatternId(dawState, payload), + length: payload.lengthSteps || payload.length, + })); + return true; + } + + if (operation.type === "clear_channel_pattern") { + applyClearChannelPattern(operation, dispatch, getState); + return true; + } + + if (operation.type === "add_channel") { + applyAddChannel(operation, dispatch, getState); + return true; + } + + if (operation.type === "assign_sample_to_channel") { + dispatch(assignSampleToChannel({ + channelId: resolveChannelId(dawState, payload), + sampleRef: payload.samplePath || payload.sampleRef, + sampleName: payload.sampleName || payload.clipName, + })); + return true; + } + + if (operation.type === "assign_plugin_to_channel") { + const pluginRef = asString(payload.pluginRef); + const plugin = PLUGIN_INSTRUMENT_BY_REF[pluginRef]; + dispatch(assignPluginToChannel({ + channelId: resolveChannelId(dawState, payload), + pluginRef, + pluginName: payload.pluginName || plugin?.name, + })); + return true; + } + + if (operation.type === "add_sample_as_channel") { + dispatch(addPlaylistSampleAsChannel({ + samplePath: payload.samplePath || payload.sampleRef, + clipName: payload.clipName || payload.sampleName, + trackId: resolveTrackId(dawState, payload), + barStart: payload.barStart, + barLength: payload.barLength, + })); + return true; + } + + if (operation.type === "set_step") { + applySetStep(operation, dispatch, getState); + return true; + } + + if (operation.type === "add_piano_notes") { + const patternId = resolvePatternId(dawState, payload); + const patternLength = ensurePatternLengthForEnd( + dispatch, + dawState, + patternId, + getMaxPianoNoteEnd(payload.notes), + ); + dispatch(addPianoNotesBatch({ + patternId, + channelId: resolveChannelId(dawState, payload), + notes: normalizeAiPianoNotes(payload.notes, patternLength), + allowOverlaps: Boolean(payload.allowOverlaps), + })); + return true; + } + + if (operation.type === "add_chord_progression") { + const patternId = resolvePatternId(dawState, payload); + const patternLength = ensurePatternLengthForEnd( + dispatch, + dawState, + patternId, + getMaxChordEnd(payload.chords), + ); + dispatch(addPianoNotesBatch({ + patternId, + channelId: resolveChannelId(dawState, payload), + notes: normalizeAiChordProgression(payload.chords, patternLength), + allowOverlaps: true, + })); + return true; + } + + if (operation.type === "set_channel_volume") { + dispatch(setChannelVolume({ + channelId: resolveChannelId(dawState, payload), + value: clampNumber(payload.value, 0, 1, 1), + })); + return true; + } + + if (operation.type === "set_channel_pan") { + dispatch(setChannelPan({ + channelId: resolveChannelId(dawState, payload), + value: clampNumber(payload.value, -1, 1, 0), + })); + return true; + } + + if (operation.type === "set_channel_mute") { + dispatch(setChannelMute({ + channelId: resolveChannelId(dawState, payload), + value: Boolean(payload.value), + })); + return true; + } + + if (operation.type === "set_channel_solo") { + dispatch(setChannelSolo({ + channelId: resolveChannelId(dawState, payload), + value: Boolean(payload.value), + })); + return true; + } + + if (operation.type === "set_channel_input_mode") { + dispatch(setChannelInputMode({ + channelId: resolveChannelId(dawState, payload), + mode: payload.mode === "piano" ? "piano" : "steps", + })); + return true; + } + + if (operation.type === "set_channel_mixer_insert") { + dispatch(setChannelMixerInsert({ + channelId: resolveChannelId(dawState, payload), + insertId: payload.insertId, + })); + return true; + } + + if (operation.type === "add_playlist_pattern_clip") { + dispatch(addPlaylistPatternClip({ + patternId: resolvePatternId(dawState, payload), + trackId: resolveTrackId(dawState, payload), + barStart: payload.barStart, + barLength: payload.barLength, + })); + return true; + } + + if (operation.type === "add_playlist_audio_clip") { + dispatch(addPlaylistAudioClip({ + samplePath: payload.samplePath || payload.sampleRef, + clipName: payload.clipName || payload.sampleName, + channelId: payload.channelId ? resolveChannelId(dawState, payload) : undefined, + trackId: resolveTrackId(dawState, payload), + barStart: payload.barStart, + barLength: payload.barLength, + sourceOffsetSteps: payload.sourceOffsetSteps, + })); + return true; + } + + if (operation.type === "add_mixer_track") { + dispatch(addMixerTrack()); + return true; + } + + if (operation.type === "set_insert_fader") { + dispatch(setInsertFader({ insertId: payload.insertId, value: payload.value })); + return true; + } + + if (operation.type === "set_insert_pan") { + dispatch(setInsertPan({ insertId: payload.insertId, value: payload.value })); + return true; + } + + if (operation.type === "set_insert_stereo") { + dispatch(setInsertStereo({ insertId: payload.insertId, value: payload.value })); + return true; + } + + if (operation.type === "set_fx_slot_effect") { + const effectType = asString(payload.effectType || "none"); + const slotId = payload.slotId || "slot-1"; + dispatch(setFxSlotEffectType({ + insertId: payload.insertId, + slotId, + effectType, + })); + + const updatedState = getDawState(getState()); + const insert = (updatedState.mixer?.inserts || []).find(function (item) { + return item.id === payload.insertId; + }); + const slot = (insert?.fxSlots || []).find(function (item) { + return item.id === slotId; + }); + const desiredEnabled = effectType !== "none" && payload.enabled !== false; + + if (Boolean(slot?.enabled) !== desiredEnabled) { + dispatch(toggleFxSlot({ + insertId: payload.insertId, + slotId, + })); + } + return true; + } + + if (operation.type === "set_fx_reverb_param") { + dispatch(setFxSlotReverbParam({ + insertId: payload.insertId, + slotId: payload.slotId || "slot-1", + param: payload.param, + value: payload.value, + })); + return true; + } + + if (operation.type === "set_fx_maximizer_param") { + dispatch(setFxSlotMaximizerParam({ + insertId: payload.insertId, + slotId: payload.slotId || "slot-1", + param: payload.param, + value: payload.value, + })); + return true; + } + + if (operation.type === "set_fx_graphic_eq_band_gain") { + dispatch(setFxSlotGraphicEqBandGain({ + insertId: payload.insertId, + slotId: payload.slotId || "slot-1", + bandIndex: payload.bandIndex, + gainDb: payload.gainDb, + })); + return true; + } + + return false; +} + +export function applyAiOperations(operations, { dispatch, getState }) { + const results = []; + + dispatch(beginAiOperationBatch()); + + try { + (Array.isArray(operations) ? operations : []).forEach(function (operation) { + try { + const wasApplied = applyAiOperation(operation, dispatch, getState); + results.push({ + id: operation.id, + description: operation.description || operation.type, + status: wasApplied ? "applied" : "skipped", + }); + } catch (error) { + results.push({ + id: operation.id, + description: operation.description || operation.type, + status: "failed", + reason: String(error?.message || error), + }); + } + }); + } finally { + dispatch(endAiOperationBatch()); + } + + const applied = results.filter(function (result) { + return result.status === "applied"; + }); + const skipped = results.filter(function (result) { + return result.status !== "applied"; + }); + + return { applied, skipped, results }; +} diff --git a/src/ai/aiAgentOperations.test.js b/src/ai/aiAgentOperations.test.js new file mode 100644 index 0000000..894c3d6 --- /dev/null +++ b/src/ai/aiAgentOperations.test.js @@ -0,0 +1,604 @@ +import { describe, it, expect } from "vitest"; +import { + applyAiOperations, + prepareAiOperations, + validatePreparedAiOperations, +} from "./aiAgentOperations"; + +function createDawState() { + return { + project: { + activePatternId: "pat-1", + activeChannelId: "ch-kick", + patterns: [ + { + id: "pat-1", + name: "Pattern 1", + lengthSteps: 16, + stepGrid: { + "ch-kick": Array(16).fill(false), + }, + pianoPreview: {}, + }, + ], + channels: [ + { + id: "ch-kick", + name: "Kick", + }, + ], + playlistTracks: [{ id: "trk-1", name: "Track 1" }], + }, + mixer: { + inserts: [ + { + id: "insert-1", + fxSlots: [ + { + id: "slot-1", + enabled: false, + effectType: "none", + }, + ], + }, + ], + }, + }; +} + +describe("prepareAiOperations", function () { + it("keeps allowed operations and rejects unsupported ones", function () { + const result = prepareAiOperations([ + { type: "set_step", payload: { stepIndex: 0, value: true } }, + { type: "delete_everything", payload: {} }, + ]); + + expect(result.operations).toHaveLength(1); + expect(result.operations[0].type).toBe("set_step"); + expect(result.rejected).toHaveLength(1); + }); +}); + +describe("validatePreparedAiOperations", function () { + it("marks operations with missing referenced ids as warnings", function () { + const prepared = prepareAiOperations([ + { + type: "set_channel_volume", + payload: { channelId: "missing-channel", value: 0.5 }, + }, + ]).operations; + + const validated = validatePreparedAiOperations(prepared, { + dawState: createDawState(), + availableSamples: [], + }); + + expect(validated[0].status).toBe("warning"); + expect(validated[0].issues[0]).toContain("Channel id does not exist"); + }); + + it("warns when an instrument pluginRef is unknown", function () { + const prepared = prepareAiOperations([ + { + type: "assign_plugin_to_channel", + payload: { channelId: "ch-kick", pluginRef: "missing-plugin" }, + }, + ]).operations; + + const validated = validatePreparedAiOperations(prepared, { + dawState: createDawState(), + availableSamples: [], + }); + + expect(validated[0].status).toBe("warning"); + expect(validated[0].issues[0]).toContain("Unknown instrument pluginRef"); + }); + + it("warns when a chord progression contains single-note chords", function () { + const prepared = prepareAiOperations([ + { + type: "add_chord_progression", + payload: { + chords: [{ start: 0, length: 16, pitches: [60], velocity: 95 }], + }, + }, + ]).operations; + + const validated = validatePreparedAiOperations(prepared, { + dawState: createDawState(), + availableSamples: [], + }); + + expect(validated[0].status).toBe("warning"); + expect(validated[0].issues).toEqual( + expect.arrayContaining([ + expect.stringContaining("at least 2 pitches"), + ]), + ); + }); + + it("warns on duplicate set_step for the same channel and step", function () { + const prepared = prepareAiOperations([ + { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } }, + { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } }, + ]).operations; + + const validated = validatePreparedAiOperations(prepared, { + dawState: createDawState(), + availableSamples: [], + }); + + expect(validated[0].status).toBe("ready"); + expect(validated[1].status).toBe("warning"); + expect(validated[1].issues).toEqual( + expect.arrayContaining([ + expect.stringContaining("Duplicate set_step"), + ]), + ); + }); + + it("does not warn when set_step uses different stepIndex values", function () { + const prepared = prepareAiOperations([ + { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } }, + { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 1 } }, + ]).operations; + + const validated = validatePreparedAiOperations(prepared, { + dawState: createDawState(), + availableSamples: [], + }); + + expect(validated[0].status).toBe("ready"); + expect(validated[1].status).toBe("ready"); + }); +}); + +describe("applyAiOperations", function () { + it("dispatches a step toggle only when the desired value differs", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { type: "set_step", payload: { stepIndex: 0, value: true } }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched).toHaveLength(3); + expect(dispatched[0].type).toBe("daw/beginAiOperationBatch"); + expect(dispatched[1]).toMatchObject({ + type: "daw/toggleStep", + payload: { + patternId: "pat-1", + channelId: "ch-kick", + stepIndex: 0, + }, + }); + expect(dispatched[2].type).toBe("daw/endAiOperationBatch"); + + dawState.project.patterns[0].stepGrid["ch-kick"][0] = true; + dispatched.length = 0; + + applyAiOperations( + prepareAiOperations([ + { type: "set_step", payload: { stepIndex: 0, value: true } }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched).toHaveLength(2); + expect(dispatched[0].type).toBe("daw/beginAiOperationBatch"); + expect(dispatched[1].type).toBe("daw/endAiOperationBatch"); + }); + + it("dispatches instrument assignment operations", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "assign_plugin_to_channel", + payload: { + channelId: "ch-kick", + pluginRef: "openstudio-piano", + pluginName: "Piano", + }, + }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched[1]).toMatchObject({ + type: "daw/assignPluginToChannel", + payload: { + channelId: "ch-kick", + pluginRef: "openstudio-piano", + pluginName: "Piano", + }, + }); + }); + + it("normalizes AI note velocity before dispatching piano notes", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_piano_notes", + payload: { + channelId: "ch-kick", + notes: [ + { start: 0, length: 1, pitch: 60, velocity: 0.8 }, + { start: 1, length: 1, pitch: 62, velocity: 0 }, + ], + }, + }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched[1].type).toBe("daw/addPianoNotesBatch"); + expect(dispatched[1].payload.notes[0].velocity).toBe(102); + expect(dispatched[1].payload.notes[1].velocity).toBe(95); + }); + + it("expands chord progressions into long stacked piano notes", function () { + const dawState = createDawState(); + dawState.project.patterns[0].lengthSteps = 64; + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_chord_progression", + payload: { + channelId: "ch-kick", + chords: [ + { start: 0, length: 16, pitches: [57, 60, 64], velocity: 0.75 }, + { start: 16, length: 16, pitches: [53, 57, 60], velocity: 95 }, + ], + }, + }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched[1].type).toBe("daw/addPianoNotesBatch"); + expect(dispatched[1].payload.allowOverlaps).toBe(true); + expect(dispatched[1].payload.notes).toHaveLength(6); + expect(dispatched[1].payload.notes.slice(0, 3)).toEqual([ + expect.objectContaining({ start: 0, length: 16, pitch: 57, velocity: 95 }), + expect.objectContaining({ start: 0, length: 16, pitch: 60, velocity: 95 }), + expect.objectContaining({ start: 0, length: 16, pitch: 64, velocity: 95 }), + ]); + }); + + it("dispatches BPM changes", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { type: "set_bpm", payload: { bpm: 150 } }, + ]).operations, + { dispatch, getState }, + ); + + expect(dispatched[1]).toMatchObject({ + type: "daw/setBpm", + payload: 150, + }); + }); + + it("clears existing piano notes and active sequencer steps", function () { + const dawState = createDawState(); + dawState.project.patterns[0].pianoPreview = { + "ch-kick": [ + { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 }, + ], + }; + dawState.project.patterns[0].stepGrid["ch-kick"][0] = true; + dawState.project.patterns[0].stepGrid["ch-kick"][4] = true; + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "clear_channel_pattern", + payload: { patternId: "pat-1", channelId: "ch-kick", mode: "all" }, + }, + ]).operations, + { dispatch, getState }, + ); + + const clearAction = dispatched.find(function (action) { + return action.type === "daw/removePianoNotesBatch"; + }); + expect(clearAction).toBeDefined(); + expect(clearAction.payload.notes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "n-1", source: "piano" }), + expect.objectContaining({ start: 0, source: "step" }), + expect.objectContaining({ start: 4, source: "step" }), + ]), + ); + }); + + it("auto-expands pattern length before adding notes beyond the current pattern", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_piano_notes", + payload: { + channelId: "ch-kick", + notes: [{ start: 30, length: 4, pitch: 60, velocity: 95 }], + }, + }, + ]).operations, + { dispatch, getState }, + ); + + const lengthAction = dispatched.find(function (action) { + return action.type === "daw/setPatternLength"; + }); + const notesAction = dispatched.find(function (action) { + return action.type === "daw/addPianoNotesBatch"; + }); + expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 34 }); + expect(notesAction.payload.notes[0]).toEqual( + expect.objectContaining({ start: 30, length: 4 }), + ); + }); + + it("auto-expands pattern length before adding chord progressions", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_chord_progression", + payload: { + channelId: "ch-kick", + chords: [{ start: 24, length: 8, pitches: [60, 64, 67], velocity: 95 }], + }, + }, + ]).operations, + { dispatch, getState }, + ); + + const lengthAction = dispatched.find(function (action) { + return action.type === "daw/setPatternLength"; + }); + const notesAction = dispatched.find(function (action) { + return action.type === "daw/addPianoNotesBatch"; + }); + expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 32 }); + expect(notesAction.payload.notes).toHaveLength(3); + expect(notesAction.payload.notes[0]).toEqual( + expect.objectContaining({ start: 24, length: 8 }), + ); + }); + + it("auto-assigns pluginRef when add_channel includes one", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_channel", + payload: { + name: "Piano Track", + pluginRef: "openstudio-piano", + }, + }, + ]).operations, + { dispatch, getState }, + ); + + const assignAction = dispatched.find(function (action) { + return action.type === "daw/assignPluginToChannel"; + }); + expect(assignAction).toBeDefined(); + expect(assignAction.payload.pluginRef).toBe("openstudio-piano"); + expect(assignAction.payload.pluginName).toBe("Piano"); + }); + + it("does not auto-assign when pluginRef is invalid", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_channel", + payload: { + name: "Mystery", + pluginRef: "nonexistent-instrument", + }, + }, + ]).operations, + { dispatch, getState }, + ); + + const assignAction = dispatched.find(function (action) { + return action.type === "daw/assignPluginToChannel"; + }); + expect(assignAction).toBeUndefined(); + }); + + it("warns when add_piano_notes targets a channel without instrument", function () { + const dawState = createDawState(); + + const validated = validatePreparedAiOperations( + prepareAiOperations([ + { + type: "add_piano_notes", + payload: { + channelId: "ch-kick", + notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }], + }, + }, + ]).operations, + { dawState }, + ); + + // ch-kick has no pluginRef or sampleRef in createDawState, so the + // validation should flag it. + expect(validated[0].issues).toEqual( + expect.arrayContaining([ + expect.stringContaining("has no instrument assigned"), + ]), + ); + expect(validated[0].status).toBe("warning"); + }); + + it("does not warn when channel has a pluginRef", function () { + const dawState = createDawState(); + dawState.project.channels[0].pluginRef = "openstudio-piano"; + + const validated = validatePreparedAiOperations( + prepareAiOperations([ + { + type: "add_piano_notes", + payload: { + channelId: "ch-kick", + notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }], + }, + }, + ]).operations, + { dawState }, + ); + + expect(validated[0].issues).not.toContain( + expect.stringContaining("has no instrument assigned"), + ); + expect(validated[0].status).toBe("ready"); + }); + + it("treats $new like $active in validation (no missing-channel warning)", function () { + const validated = validatePreparedAiOperations( + prepareAiOperations([ + { + type: "add_piano_notes", + payload: { + channelId: "$new", + notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }], + }, + }, + ]).operations, + { dawState: createDawState() }, + ); + + expect(validated[0].issues).not.toContain( + expect.stringContaining("Channel id does not exist"), + ); + }); +}); + +describe("applyAiOperations with $new channel reference", function () { + it("resolves $new to the channel just created by add_channel", function () { + const dawState = createDawState(); + const dispatched = []; + const dispatch = function (action) { + dispatched.push(action); + }; + const getState = function () { + return { daw: dawState }; + }; + + applyAiOperations( + prepareAiOperations([ + { + type: "add_channel", + payload: { name: "Melody", pluginRef: "openstudio-piano" }, + }, + { + type: "add_piano_notes", + payload: { + channelId: "$new", + notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }], + }, + }, + ]).operations, + { dispatch, getState }, + ); + + // addPianoNotesBatch should have been dispatched with a real channel id, + // not the literal string "$new". + const notesAction = dispatched.find(function (action) { + return action.type === "daw/addPianoNotesBatch"; + }); + expect(notesAction).toBeDefined(); + expect(notesAction.payload.channelId).not.toBe("$new"); + expect(notesAction.payload.channelId).not.toBe("$active"); + expect(notesAction.payload.notes).toHaveLength(1); + }); +}); diff --git a/src/ai/aiAgentPrompt.js b/src/ai/aiAgentPrompt.js new file mode 100644 index 0000000..474815f --- /dev/null +++ b/src/ai/aiAgentPrompt.js @@ -0,0 +1,68 @@ +export const AI_AGENT_DEFAULT_MODEL = "gpt-5.5"; + +export const AI_AGENT_OPERATION_TYPES = [ + "set_bpm", + "create_pattern", + "set_active_pattern", + "rename_pattern", + "set_pattern_length", + "add_channel", + "assign_plugin_to_channel", + "assign_sample_to_channel", + "add_sample_as_channel", + "clear_channel_pattern", + "set_step", + "add_piano_notes", + "add_chord_progression", + "set_channel_volume", + "set_channel_pan", + "set_channel_mute", + "set_channel_solo", + "set_channel_input_mode", + "set_channel_mixer_insert", + "add_playlist_pattern_clip", + "add_playlist_audio_clip", + "add_mixer_track", + "set_insert_fader", + "set_insert_pan", + "set_insert_stereo", + "set_fx_slot_effect", + "set_fx_reverb_param", + "set_fx_maximizer_param", + "set_fx_graphic_eq_band_gain", +]; + +export function getAiAgentSystemPrompt() { + return [ + "You are OpenStudio AI Agent, a DAW assistant inside a React/Web Audio music app.", + "You must return JSON only. Do not include Markdown.", + "Never claim that changes are applied. You only prepare a preview plan.", + "Only use operation types from the allowed list.", + "Prefer exact ids from the project summary. If a user refers to the active pattern or active channel, use \"$active\". Use \"$new\" to reference a channel just created by add_channel in the same plan.", + "Use sample paths exactly as provided in availableSamples. Do not invent file paths.", + "Use pluginRef values exactly as provided in availableInstruments. Do not invent instrument plugin refs.", + "If the user asks for an instrument not in availableInstruments, pick the closest match from the list. Channel names are labels only — the actual sound comes from the pluginRef.", + "When creating a melody or instrument track, always follow these steps in order:", + "1. add_channel with a descriptive name. You may include pluginRef in the payload to auto-assign the instrument.", + "2. assign_plugin_to_channel with a pluginRef from availableInstruments. This is mandatory — a channel without an instrument produces no sound. Never skip this step.", + "3. set_channel_input_mode to piano.", + "4. add_piano_notes with the melody.", + "The active pattern includes existing notes in `notes` and steps in `steps`. Read them to understand the current melody and harmonize.", + "When replacing, fixing, rewriting, or making a new melody/drum part on an existing channel, use clear_channel_pattern first. Use mode \"notes\" for melodies, \"steps\" for drum sequencer rows, and \"all\" when replacing both.", + "Only skip clear_channel_pattern when the user explicitly asks to add, layer, double, or keep existing material.", + "When adding chords to an existing melody, read the melody pitches from the active pattern and choose a harmonically matching chord progression.", + "Prefer adding chords on a separate channel or in a lower octave to avoid overlapping the melody.", + "If the user asks for chords or a chord progression, use add_chord_progression with chords containing start, length, pitches and velocity.", + "Use MIDI velocity values from 1 to 127. Prefer 80-105 for musical notes.", + "For chord progressions, use longer note lengths such as 8 or 16 steps, not tiny one-step notes.", + "Use set_bpm when the user asks to change project tempo or BPM.", + "OpenStudio timing: 1 Channel Rack block = 1 step. 1 sequencer step = 1/16 bar. 16 steps = 1 bar, 32 steps = 2 bars, 64 steps = 4 bars, 128 steps = 8 bars.", + "Use set_pattern_length before creating patterns longer than the current pattern. For 4 bars use 64 steps, for 8 bars use 128 steps.", + "For drum patterns, use set_step with different stepIndex values for each hit. For a 1-bar pattern use indices 0-15: kick often 0, 8; clap/snare often 8. For longer patterns repeat clap/snare every 16 steps: 8, 24, 40, 56, etc. Hats often use 0,2,4,6,8,10,12,14 or every step 0-15. For 2 bars continue 16-31.", + "Do not repeat set_step for the same stepIndex on the same channel — each step is a toggle, calling it twice cancels the first call.", + "Each drum sound needs its own channel with a sample assigned via assign_sample_to_channel or add_sample_as_channel. Use sample paths from availableSamples.", + "Keep edits small and musically useful. If unsure, ask for clarification in the message and return an empty operations array.", + "Return this shape: {\"message\": string, \"operations\": [{\"type\": string, \"payload\": object}] }.", + "Allowed operation types: " + AI_AGENT_OPERATION_TYPES.join(", "), + ].join("\n"); +} diff --git a/src/ai/aiClient.js b/src/ai/aiClient.js new file mode 100644 index 0000000..d0552aa --- /dev/null +++ b/src/ai/aiClient.js @@ -0,0 +1,29 @@ +import { + requestAiAgentPlan as requestOpenAiAgentPlan, + testOpenAiConnection, +} from "./openAiClient"; +import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient"; +import { + AI_AGENT_PROVIDER_GEMINI, + AI_AGENT_PROVIDER_OPENAI, + getAiProviderConfig, +} from "./aiProviders"; + +export async function requestAiAgentPlan({ provider, ...params }) { + const config = getAiProviderConfig(provider); + if (config.id === AI_AGENT_PROVIDER_GEMINI) { + return requestGeminiAgentPlan(params); + } + return requestOpenAiAgentPlan(params); +} + +export async function testAiConnection({ provider, ...params }) { + const config = getAiProviderConfig(provider); + if (config.id === AI_AGENT_PROVIDER_GEMINI) { + return testGeminiConnection(params); + } + if (config.id === AI_AGENT_PROVIDER_OPENAI) { + return testOpenAiConnection(params); + } + return testOpenAiConnection(params); +} diff --git a/src/ai/aiClient.test.js b/src/ai/aiClient.test.js new file mode 100644 index 0000000..e1926d5 --- /dev/null +++ b/src/ai/aiClient.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./openAiClient", function () { + return { + requestAiAgentPlan: vi.fn(async function () { + return { provider: "openai" }; + }), + testOpenAiConnection: vi.fn(async function () { + return { model: "gpt-5.5" }; + }), + }; +}); + +vi.mock("./geminiClient", function () { + return { + requestGeminiAgentPlan: vi.fn(async function () { + return { provider: "gemini" }; + }), + testGeminiConnection: vi.fn(async function () { + return { model: "gemini-3.5-flash" }; + }), + }; +}); + +import { requestAiAgentPlan, testAiConnection } from "./aiClient"; +import { + requestAiAgentPlan as requestOpenAiAgentPlan, + testOpenAiConnection, +} from "./openAiClient"; +import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient"; + +describe("aiClient", function () { + beforeEach(function () { + vi.clearAllMocks(); + }); + + it("routes plan requests to OpenAI by default", async function () { + const result = await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.5", + }); + + expect(result.provider).toBe("openai"); + expect(requestOpenAiAgentPlan).toHaveBeenCalledTimes(1); + expect(requestGeminiAgentPlan).not.toHaveBeenCalled(); + }); + + it("routes plan requests to Gemini", async function () { + const result = await requestAiAgentPlan({ + provider: "gemini", + apiKey: "gemini-key", + model: "gemini-3.5-flash", + }); + + expect(result.provider).toBe("gemini"); + expect(requestGeminiAgentPlan).toHaveBeenCalledTimes(1); + expect(requestOpenAiAgentPlan).not.toHaveBeenCalled(); + }); + + it("routes connection tests to the selected provider", async function () { + await testAiConnection({ provider: "openai", apiKey: "sk-test" }); + await testAiConnection({ provider: "gemini", apiKey: "gemini-key" }); + + expect(testOpenAiConnection).toHaveBeenCalledTimes(1); + expect(testGeminiConnection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ai/aiProjectSummary.js b/src/ai/aiProjectSummary.js new file mode 100644 index 0000000..5442929 --- /dev/null +++ b/src/ai/aiProjectSummary.js @@ -0,0 +1,171 @@ +import { PLUGIN_EFFECTS } from "../data/pluginEffects"; +import { PLUGIN_INSTRUMENTS } from "../data/pluginInstruments"; + +// Returns { channelId: [{ start, length, pitch, velocity }] } without note ids. +// Only used for the active pattern so the payload stays compact. +function summarizePianoPreview(pianoPreview) { + if (!pianoPreview || typeof pianoPreview !== "object") { + return {}; + } + + const result = {}; + Object.keys(pianoPreview).forEach(function (channelId) { + const notes = Array.isArray(pianoPreview[channelId]) + ? pianoPreview[channelId] + : []; + if (notes.length === 0) { + return; + } + result[channelId] = notes.map(function (note) { + return { + start: note.start, + length: note.length, + pitch: note.pitch, + velocity: note.velocity, + }; + }); + }); + return result; +} + +// Returns { channelId: [bool, bool, ...] } for the active pattern only. +function summarizeStepGrid(stepGrid) { + if (!stepGrid || typeof stepGrid !== "object") { + return {}; + } + + const result = {}; + Object.keys(stepGrid).forEach(function (channelId) { + const row = Array.isArray(stepGrid[channelId]) ? stepGrid[channelId] : []; + if (row.length === 0) { + return; + } + result[channelId] = row.map(Boolean); + }); + return result; +} + +// Lightweight count so the agent knows a non-active pattern is not empty +// without receiving every note (keeps the token budget reasonable). +function countPianoNotes(pianoPreview) { + if (!pianoPreview || typeof pianoPreview !== "object") { + return 0; + } + return Object.keys(pianoPreview).reduce(function (total, channelId) { + return total + (Array.isArray(pianoPreview[channelId]) + ? pianoPreview[channelId].length + : 0); + }, 0); +} + +function summarizeFxSlots(insert) { + return (Array.isArray(insert?.fxSlots) ? insert.fxSlots : []) + .filter(function (slot) { + return slot?.effectType && slot.effectType !== "none"; + }) + .map(function (slot) { + return { + id: slot.id, + name: slot.name, + enabled: Boolean(slot.enabled), + effectType: slot.effectType, + }; + }); +} + +export function buildAiProjectSummary(dawState, availableSamples = []) { + const project = dawState?.project || {}; + const mixer = dawState?.mixer || {}; + const transport = dawState?.transport || {}; + + return { + transport: { + bpm: transport.bpm, + mode: transport.mode, + }, + activePatternId: project.activePatternId, + activeChannelId: project.activeChannelId, + patterns: (Array.isArray(project.patterns) ? project.patterns : []).map( + function (pattern) { + const isActive = pattern.id === project.activePatternId; + return { + id: pattern.id, + name: pattern.name, + lengthSteps: pattern.lengthSteps, + notes: isActive ? summarizePianoPreview(pattern.pianoPreview) : null, + steps: isActive ? summarizeStepGrid(pattern.stepGrid) : null, + noteCount: isActive ? null : countPianoNotes(pattern.pianoPreview), + }; + }, + ), + channels: (Array.isArray(project.channels) ? project.channels : []).map( + function (channel) { + return { + id: channel.id, + name: channel.name, + sampleRef: channel.sampleRef, + pluginRef: channel.pluginRef, + volume: channel.volume, + pan: channel.pan, + muted: Boolean(channel.muted), + solo: Boolean(channel.solo), + inputMode: channel.inputMode, + mixerInsertId: channel.mixerInsertId, + }; + }, + ), + mixerInserts: (Array.isArray(mixer.inserts) ? mixer.inserts : []).map( + function (insert) { + return { + id: insert.id, + name: insert.name, + isMaster: Boolean(insert.isMaster), + active: Boolean(insert.active), + fader: insert.fader, + pan: insert.pan, + stereoSeparation: insert.stereoSeparation, + fxSlots: summarizeFxSlots(insert), + }; + }, + ), + availableInstruments: PLUGIN_INSTRUMENTS.map(function (instrument) { + return { + pluginRef: instrument.pluginRef, + name: instrument.name, + description: instrument.description, + }; + }), + availableEffects: PLUGIN_EFFECTS.map(function (effect) { + return { + effectType: effect.effectType, + name: effect.name, + description: effect.description, + }; + }), + playlistTracks: (Array.isArray(project.playlistTracks) + ? project.playlistTracks + : [] + ).map(function (track) { + return { + id: track.id, + name: track.name, + }; + }), + playlistClips: (Array.isArray(project.playlistClips) + ? project.playlistClips + : [] + ).slice(0, 80).map(function (clip) { + return { + id: clip.id, + clipType: clip.clipType, + patternId: clip.patternId, + samplePath: clip.samplePath, + channelId: clip.channelId, + trackId: clip.trackId, + barStart: clip.barStart, + barLength: clip.barLength, + }; + }), + availableSamples, + }; +} diff --git a/src/ai/aiProjectSummary.test.js b/src/ai/aiProjectSummary.test.js new file mode 100644 index 0000000..948e37e --- /dev/null +++ b/src/ai/aiProjectSummary.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { buildAiProjectSummary } from "./aiProjectSummary"; + +describe("buildAiProjectSummary", function () { + it("includes available plugin instruments and effects", function () { + const summary = buildAiProjectSummary({ + transport: { bpm: 140, mode: "pattern" }, + project: { + activePatternId: "pat-1", + activeChannelId: "ch-1", + patterns: [], + channels: [], + playlistTracks: [], + playlistClips: [], + }, + mixer: { inserts: [] }, + }); + + expect(summary.availableInstruments).toContainEqual( + expect.objectContaining({ + pluginRef: "openstudio-piano", + name: "Piano", + }), + ); + expect(summary.availableEffects).toContainEqual( + expect.objectContaining({ + effectType: "reverb", + name: "Reverb", + }), + ); + }); + + it("includes notes and steps for the active pattern", function () { + const summary = buildAiProjectSummary({ + transport: { bpm: 140, mode: "pattern" }, + project: { + activePatternId: "pat-1", + activeChannelId: "ch-1", + patterns: [ + { + id: "pat-1", + name: "Pattern 1", + lengthSteps: 16, + pianoPreview: { + "ch-1": [ + { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 }, + { id: "n-2", start: 4, length: 4, pitch: 64, velocity: 100 }, + ], + }, + stepGrid: { + "ch-1": [true, false, false, false], + }, + }, + ], + channels: [{ id: "ch-1", name: "Piano" }], + playlistTracks: [], + playlistClips: [], + }, + mixer: { inserts: [] }, + }); + + expect(summary.patterns[0].notes).toEqual({ + "ch-1": [ + { start: 0, length: 4, pitch: 60, velocity: 95 }, + { start: 4, length: 4, pitch: 64, velocity: 100 }, + ], + }); + expect(summary.patterns[0].steps).toEqual({ + "ch-1": [true, false, false, false], + }); + expect(summary.patterns[0].noteCount).toBeNull(); + }); + + it("sends only noteCount for inactive patterns", function () { + const summary = buildAiProjectSummary({ + transport: { bpm: 140, mode: "pattern" }, + project: { + activePatternId: "pat-1", + activeChannelId: "ch-1", + patterns: [ + { + id: "pat-1", + name: "Active", + lengthSteps: 16, + pianoPreview: {}, + stepGrid: {}, + }, + { + id: "pat-2", + name: "Inactive", + lengthSteps: 32, + pianoPreview: { + "ch-1": [ + { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 }, + ], + }, + stepGrid: { "ch-1": [true] }, + }, + ], + channels: [{ id: "ch-1", name: "Piano" }], + playlistTracks: [], + playlistClips: [], + }, + mixer: { inserts: [] }, + }); + + const activePattern = summary.patterns.find(function (p) { + return p.id === "pat-1"; + }); + const inactivePattern = summary.patterns.find(function (p) { + return p.id === "pat-2"; + }); + + expect(activePattern.notes).toEqual({}); + expect(activePattern.steps).toEqual({}); + expect(activePattern.noteCount).toBeNull(); + + expect(inactivePattern.notes).toBeNull(); + expect(inactivePattern.steps).toBeNull(); + expect(inactivePattern.noteCount).toBe(1); + }); +}); diff --git a/src/ai/aiProviders.js b/src/ai/aiProviders.js new file mode 100644 index 0000000..64e1693 --- /dev/null +++ b/src/ai/aiProviders.js @@ -0,0 +1,49 @@ +export const AI_AGENT_PROVIDER_OPENAI = "openai"; +export const AI_AGENT_PROVIDER_GEMINI = "gemini"; +export const AI_AGENT_DEFAULT_PROVIDER = AI_AGENT_PROVIDER_OPENAI; + +export const AI_AGENT_PROVIDERS = [ + { + id: AI_AGENT_PROVIDER_OPENAI, + label: "OpenAI", + defaultModel: "gpt-5.5", + keyStorage: "openstudio.ai.keys.openai", + modelStorage: "openstudio.ai.models.openai", + keyPlaceholder: "sk-...", + models: [ + { value: "gpt-5.5", label: "GPT-5.5" }, + { value: "gpt-5.4", label: "GPT-5.4" }, + { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }, + ], + }, + { + id: AI_AGENT_PROVIDER_GEMINI, + label: "Gemini", + defaultModel: "gemini-3.5-flash", + keyStorage: "openstudio.ai.keys.gemini", + modelStorage: "openstudio.ai.models.gemini", + keyPlaceholder: "AIza...", + models: [ + { value: "gemini-3.5-flash", label: "Gemini 3.5 Flash" }, + { value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite" }, + { value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" }, + { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" }, + ], + }, +]; + +export function getAiProviderConfig(providerId) { + const safeProviderId = String(providerId || "").trim(); + return AI_AGENT_PROVIDERS.find(function (provider) { + return provider.id === safeProviderId; + }) || AI_AGENT_PROVIDERS[0]; +} + +export function getAiProviderModel(providerId, model) { + const provider = getAiProviderConfig(providerId); + const safeModel = String(model || "").trim(); + const hasModel = provider.models.some(function (item) { + return item.value === safeModel; + }); + return hasModel ? safeModel : provider.defaultModel; +} diff --git a/src/ai/aiSampleIndex.js b/src/ai/aiSampleIndex.js new file mode 100644 index 0000000..74483e7 --- /dev/null +++ b/src/ai/aiSampleIndex.js @@ -0,0 +1,142 @@ +function makePacksPath(relativePath) { + const cleanRelative = String(relativePath || "") + .replace(/^\/+/, "") + .trim(); + const isFileProtocol = + typeof window !== "undefined" && window.location.protocol === "file:"; + + if (isFileProtocol) { + const normalized = cleanRelative.replace(/^packs\/?/i, ""); + return ( + "openstudio://packs/" + + normalized + .split("/") + .filter(Boolean) + .map(function (segment) { + return encodeURIComponent(segment); + }) + .join("/") + ); + } + + return "/" + cleanRelative; +} + +function normalizePackItemPath(rawPath) { + const input = String(rawPath || "").trim(); + if (!input) { + return ""; + } + + if (/^(https?:|file:|openstudio:)/i.test(input)) { + return input; + } + + const noLeadingSlash = input.replace(/^\/+/, ""); + if (noLeadingSlash.startsWith("packs/")) { + return makePacksPath(noLeadingSlash); + } + + return makePacksPath("packs/" + noLeadingSlash); +} + +export async function loadAiSampleIndex(limit = 240) { + try { + const response = await fetch( + makePacksPath("packs/manifest.json") + "?ts=" + Date.now(), + { cache: "no-store" }, + ); + + if (!response.ok) { + return []; + } + + const manifest = await response.json(); + const folders = Array.isArray(manifest?.folders) ? manifest.folders : []; + const samples = []; + + folders.forEach(function (folder) { + const folderName = String(folder?.folder || "Packs"); + const items = Array.isArray(folder?.items) ? folder.items : []; + items.forEach(function (item) { + const name = typeof item === "string" + ? item.split("/").pop() || item + : String(item?.name || item?.path || "Sample").split("/").pop(); + const path = typeof item === "string" + ? normalizePackItemPath(item) + : normalizePackItemPath(item?.path); + + if (!path) { + return; + } + + samples.push({ + name, + path, + folder: folderName, + }); + }); + }); + + return samples.slice(0, limit); + } catch { + return []; + } +} + +function tokenizeSampleQuery(query) { + return String(query || "") + .toLowerCase() + .split(/[^a-z0-9]+/i) + .map(function (token) { + return token.trim(); + }) + .filter(function (token) { + return token.length >= 2; + }); +} + +function scoreSample(sample, tokens) { + const haystack = [sample?.name, sample?.folder, sample?.path] + .join(" ") + .toLowerCase(); + + return tokens.reduce(function (score, token) { + if (!haystack.includes(token)) { + return score; + } + + const name = String(sample?.name || "").toLowerCase(); + return score + (name.includes(token) ? 3 : 1); + }, 0); +} + +export function searchAiSamples(samples, query, limit = 80) { + const source = Array.isArray(samples) ? samples : []; + const tokens = tokenizeSampleQuery(query); + + if (tokens.length === 0) { + return source.slice(0, limit); + } + + return source + .map(function (sample) { + return { + sample, + score: scoreSample(sample, tokens), + }; + }) + .filter(function (item) { + return item.score > 0; + }) + .sort(function (a, b) { + if (a.score !== b.score) { + return b.score - a.score; + } + return String(a.sample?.name || "").localeCompare(String(b.sample?.name || "")); + }) + .slice(0, limit) + .map(function (item) { + return item.sample; + }); +} diff --git a/src/ai/aiSampleIndex.test.js b/src/ai/aiSampleIndex.test.js new file mode 100644 index 0000000..04cf789 --- /dev/null +++ b/src/ai/aiSampleIndex.test.js @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { searchAiSamples } from "./aiSampleIndex"; + +describe("searchAiSamples", function () { + it("prioritizes samples matching the user prompt", function () { + const samples = [ + { name: "Soft Piano.wav", folder: "Keys", path: "/packs/keys/piano.wav" }, + { name: "Trap Kick.wav", folder: "Drums", path: "/packs/drums/kick.wav" }, + { name: "Open Hat.wav", folder: "Drums", path: "/packs/drums/hat.wav" }, + ]; + + const result = searchAiSamples(samples, "make a trap kick pattern", 2); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Trap Kick.wav"); + }); +}); diff --git a/src/ai/geminiClient.js b/src/ai/geminiClient.js new file mode 100644 index 0000000..7026c27 --- /dev/null +++ b/src/ai/geminiClient.js @@ -0,0 +1,160 @@ +import { getAiAgentSystemPrompt } from "./aiAgentPrompt"; +import { getAiProviderConfig, AI_AGENT_PROVIDER_GEMINI } from "./aiProviders"; + +const GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"; + +function parseAiJsonContent(content) { + const raw = String(content || "").trim(); + if (!raw) { + throw new Error("AI returned an empty response."); + } + + try { + return JSON.parse(raw); + } catch { + const jsonMatch = raw.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("AI response was not valid JSON."); + } + return JSON.parse(jsonMatch[0]); + } +} + +function buildGeminiContents({ userMessage, projectSummary, conversationHistory }) { + const historyContents = (Array.isArray(conversationHistory) + ? conversationHistory + : [] + ) + .filter(function (msg) { + return msg && (msg.role === "user" || msg.role === "assistant") && msg.content; + }) + .map(function (msg) { + return { + role: msg.role === "assistant" ? "model" : "user", + parts: [{ text: String(msg.content) }], + }; + }); + + return historyContents.concat({ + role: "user", + parts: [ + { + text: JSON.stringify({ + request: userMessage, + project: projectSummary, + }), + }, + ], + }); +} + +function makeGeminiGenerateUrl(model, apiKey) { + return ( + GEMINI_API_BASE_URL + + "/" + + encodeURIComponent(model) + + ":generateContent?key=" + + encodeURIComponent(apiKey) + ); +} + +export async function requestGeminiAgentPlan({ + apiKey, + model, + userMessage, + projectSummary, + conversationHistory = [], +}) { + const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI); + const safeApiKey = String(apiKey || "").trim(); + const safeMessage = String(userMessage || "").trim(); + const safeModel = String(model || provider.defaultModel).trim() || provider.defaultModel; + + if (!safeApiKey) { + throw new Error("Paste your Gemini API key before sending a message."); + } + + if (!safeMessage) { + throw new Error("Write a request for the AI Agent first."); + } + + const response = await fetch(makeGeminiGenerateUrl(safeModel, safeApiKey), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + systemInstruction: { + parts: [{ text: getAiAgentSystemPrompt() }], + }, + contents: buildGeminiContents({ + userMessage: safeMessage, + projectSummary, + conversationHistory, + }), + generationConfig: { + responseMimeType: "application/json", + }, + }), + }); + + const result = await response.json().catch(function () { + return null; + }); + + if (!response.ok) { + throw new Error( + result?.error?.message || + "Gemini request failed with status " + response.status, + ); + } + + const content = result?.candidates?.[0]?.content?.parts?.[0]?.text; + const parsed = parseAiJsonContent(content); + + return { + message: String(parsed?.message || "I prepared a project edit plan."), + operations: Array.isArray(parsed?.operations) ? parsed.operations : [], + }; +} + +export async function testGeminiConnection({ apiKey, model }) { + const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI); + const safeApiKey = String(apiKey || "").trim(); + const safeModel = String(model || provider.defaultModel).trim() || provider.defaultModel; + + if (!safeApiKey) { + throw new Error("Paste your Gemini API key before testing the connection."); + } + + const response = await fetch(makeGeminiGenerateUrl(safeModel, safeApiKey), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [{ text: "Return {\"ok\":true} as JSON." }], + }, + ], + generationConfig: { + responseMimeType: "application/json", + }, + }), + }); + + const result = await response.json().catch(function () { + return null; + }); + + if (!response.ok) { + throw new Error( + result?.error?.message || + "Gemini connection test failed with status " + response.status, + ); + } + + return { model: safeModel }; +} diff --git a/src/ai/geminiClient.test.js b/src/ai/geminiClient.test.js new file mode 100644 index 0000000..b32df12 --- /dev/null +++ b/src/ai/geminiClient.test.js @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient"; + +describe("geminiClient", function () { + afterEach(function () { + vi.restoreAllMocks(); + }); + + it("parses JSON generateContent responses", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + message: "Plan ready", + operations: [{ type: "create_pattern", payload: {} }], + }), + }, + ], + }, + }, + ], + }; + }, + }); + + const result = await requestGeminiAgentPlan({ + apiKey: "gemini-key", + model: "gemini-3.5-flash", + userMessage: "create pattern", + projectSummary: { patterns: [] }, + }); + + expect(result.message).toBe("Plan ready"); + expect(result.operations).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain( + "/gemini-3.5-flash:generateContent?key=gemini-key", + ); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.systemInstruction.parts[0].text).toContain("OpenStudio"); + expect(sentBody.generationConfig.responseMimeType).toBe("application/json"); + }); + + it("maps conversation history to Gemini roles", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + candidates: [ + { + content: { + parts: [ + { text: JSON.stringify({ message: "ok", operations: [] }) }, + ], + }, + }, + ], + }; + }, + }); + + await requestGeminiAgentPlan({ + apiKey: "gemini-key", + model: "gemini-3.1-pro-preview", + userMessage: "add a snare", + projectSummary: { tempo: 140 }, + conversationHistory: [ + { role: "user", content: "create a beat" }, + { role: "assistant", content: "I created kick and hats." }, + { role: "system", content: "Applied 2 operations." }, + ], + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.contents).toHaveLength(3); + expect(sentBody.contents[0]).toEqual({ + role: "user", + parts: [{ text: "create a beat" }], + }); + expect(sentBody.contents[1]).toEqual({ + role: "model", + parts: [{ text: "I created kick and hats." }], + }); + expect(sentBody.contents[2].role).toBe("user"); + expect(JSON.parse(sentBody.contents[2].parts[0].text)).toEqual({ + request: "add a snare", + project: { tempo: 140 }, + }); + }); + + it("tests access with a lightweight JSON request", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { candidates: [] }; + }, + }); + + const result = await testGeminiConnection({ + apiKey: "gemini-key", + model: "gemini-3.1-flash-lite", + }); + + expect(result.model).toBe("gemini-3.1-flash-lite"); + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.contents[0].parts[0].text).toBe( + "Return {\"ok\":true} as JSON.", + ); + }); + + it("requires a Gemini API key", async function () { + await expect( + requestGeminiAgentPlan({ + apiKey: "", + model: "gemini-3.5-flash", + userMessage: "hi", + projectSummary: {}, + }), + ).rejects.toThrow("Paste your Gemini API key"); + }); +}); diff --git a/src/ai/openAiClient.js b/src/ai/openAiClient.js new file mode 100644 index 0000000..8747535 --- /dev/null +++ b/src/ai/openAiClient.js @@ -0,0 +1,148 @@ +import { AI_AGENT_DEFAULT_MODEL, getAiAgentSystemPrompt } from "./aiAgentPrompt"; + +const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions"; +const OPENAI_MODELS_URL = "https://api.openai.com/v1/models"; + +function parseAiJsonContent(content) { + const raw = String(content || "").trim(); + if (!raw) { + throw new Error("AI returned an empty response."); + } + + try { + return JSON.parse(raw); + } catch { + const jsonMatch = raw.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("AI response was not valid JSON."); + } + return JSON.parse(jsonMatch[0]); + } +} + +export async function requestAiAgentPlan({ + apiKey, + model = AI_AGENT_DEFAULT_MODEL, + userMessage, + projectSummary, + conversationHistory = [], +}) { + const safeApiKey = String(apiKey || "").trim(); + const safeMessage = String(userMessage || "").trim(); + const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL; + + if (!safeApiKey) { + throw new Error("Paste your OpenAI API key before sending a message."); + } + + if (!safeMessage) { + throw new Error("Write a request for the AI Agent first."); + } + + // GPT-5.5 (and other reasoning models) reject custom temperature values, + // only the default (1) is supported. Older GPT-5.4 variants still accept it. + const supportsTemperature = !safeModel.startsWith("gpt-5.5"); + + // Build the full message list: system prompt, prior conversation turns, + // then the current user request with the live project summary. This gives + // the model context from earlier in the chat (loaded from Supabase or + // built up during the current session). + const historyMessages = (Array.isArray(conversationHistory) + ? conversationHistory + : [] + ) + .filter(function (msg) { + return msg && (msg.role === "user" || msg.role === "assistant") && msg.content; + }) + .map(function (msg) { + return { role: msg.role, content: msg.content }; + }); + + const requestBody = { + model: safeModel, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: getAiAgentSystemPrompt(), + }, + ...historyMessages, + { + role: "user", + content: JSON.stringify({ + request: safeMessage, + project: projectSummary, + }), + }, + ], + }; + + if (supportsTemperature) { + requestBody.temperature = 0.35; + } + + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer " + safeApiKey, + }, + body: JSON.stringify(requestBody), + }); + + const result = await response.json().catch(function () { + return null; + }); + + if (!response.ok) { + const message = + result?.error?.message || + "OpenAI request failed with status " + response.status; + throw new Error(message); + } + + const content = result?.choices?.[0]?.message?.content; + const parsed = parseAiJsonContent(content); + + return { + message: String(parsed?.message || "I prepared a project edit plan."), + operations: Array.isArray(parsed?.operations) ? parsed.operations : [], + }; +} + +export async function testOpenAiConnection({ + apiKey, + model = AI_AGENT_DEFAULT_MODEL, +}) { + const safeApiKey = String(apiKey || "").trim(); + const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL; + + if (!safeApiKey) { + throw new Error("Paste your OpenAI API key before testing the connection."); + } + + const response = await fetch( + OPENAI_MODELS_URL + "/" + encodeURIComponent(safeModel), + { + method: "GET", + headers: { + authorization: "Bearer " + safeApiKey, + }, + }, + ); + + const result = await response.json().catch(function () { + return null; + }); + + if (!response.ok) { + throw new Error( + result?.error?.message || + "OpenAI connection test failed with status " + response.status, + ); + } + + return { + model: result?.id || safeModel, + }; +} diff --git a/src/ai/openAiClient.test.js b/src/ai/openAiClient.test.js new file mode 100644 index 0000000..fe4fbce --- /dev/null +++ b/src/ai/openAiClient.test.js @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { requestAiAgentPlan, testOpenAiConnection } from "./openAiClient"; + +describe("openAiClient", function () { + afterEach(function () { + vi.restoreAllMocks(); + }); + + it("parses JSON chat completion responses", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + choices: [ + { + message: { + content: JSON.stringify({ + message: "Plan ready", + operations: [{ type: "create_pattern", payload: {} }], + }), + }, + }, + ], + }; + }, + }); + + const result = await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.5", + userMessage: "create pattern", + projectSummary: { patterns: [] }, + }); + + expect(result.message).toBe("Plan ready"); + expect(result.operations).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // GPT-5.5 must not send temperature (only default 1 is supported) + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody).not.toHaveProperty("temperature"); + }); + + it("sends temperature for models that support it", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), + }, + }, + ], + }; + }, + }); + + await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.4", + userMessage: "hi", + projectSummary: {}, + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.temperature).toBe(0.35); + }); + + it("tests model access through the models endpoint", async function () { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { id: "gpt-5.5" }; + }, + }); + + const result = await testOpenAiConnection({ + apiKey: "sk-test", + model: "gpt-5.5", + }); + + expect(result.model).toBe("gpt-5.5"); + }); + + it("includes conversation history in the messages array", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), + }, + }, + ], + }; + }, + }); + + await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.4", + userMessage: "add a snare", + projectSummary: {}, + conversationHistory: [ + { role: "user", content: "create a trap beat" }, + { role: "assistant", content: "I created a pattern with kick and hats." }, + ], + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.messages).toHaveLength(4); + expect(sentBody.messages[0].role).toBe("system"); + expect(sentBody.messages[1]).toEqual({ + role: "user", + content: "create a trap beat", + }); + expect(sentBody.messages[2].role).toBe("assistant"); + expect(sentBody.messages[3].role).toBe("user"); + }); + + it("works without conversation history (empty array default)", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), + }, + }, + ], + }; + }, + }); + + await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.4", + userMessage: "hi", + projectSummary: {}, + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody.messages).toHaveLength(2); + expect(sentBody.messages[0].role).toBe("system"); + expect(sentBody.messages[1].role).toBe("user"); + }); + + it("filters out system-role messages from conversation history", async function () { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async function () { + return { + choices: [ + { + message: { + content: JSON.stringify({ message: "ok", operations: [] }), + }, + }, + ], + }; + }, + }); + + await requestAiAgentPlan({ + apiKey: "sk-test", + model: "gpt-5.4", + userMessage: "hi", + projectSummary: {}, + conversationHistory: [ + { role: "user", content: "create a beat" }, + { role: "system", content: "Applied 2 operations." }, + ], + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + // system prompt + 1 history (user only) + current user message = 3 + expect(sentBody.messages).toHaveLength(3); + expect(sentBody.messages[1]).toEqual({ + role: "user", + content: "create a beat", + }); + }); +}); diff --git a/src/audio/core/useSampleSettingsPreview.js b/src/audio/core/useSampleSettingsPreview.js index b1e229d..4bf2373 100644 --- a/src/audio/core/useSampleSettingsPreview.js +++ b/src/audio/core/useSampleSettingsPreview.js @@ -15,6 +15,8 @@ import { getPluginInstrumentCacheKey, } from "./usePluginInstruments"; import { BASE_CHANNEL_TRIGGER_GAIN } from "../domain/constants"; +import { DEFAULT_SAMPLE_MIDI_PITCH } from "../domain/pitch"; +import { getSafeSampleSettings } from "../domain/sampleSettings"; const SAMPLE_SETTINGS_PREVIEW_PLAY_EVENT = "openstudio:sample-settings-preview-play"; @@ -128,6 +130,9 @@ export function useSampleSettingsPreview({ hasPluginInstrument && typeof schedulePluginInstrumentRef.current === "function" ) { + const channelSettings = getSafeSampleSettings( + channel.sampleSettings, + ); schedulePluginInstrumentRef.current( pluginRef, startAt, @@ -135,6 +140,9 @@ export function useSampleSettingsPreview({ channel.pan, channel, outputNode, + DEFAULT_SAMPLE_MIDI_PITCH, + 1, + channelSettings, ); return; } diff --git a/src/audio/core/useTransportScheduler.js b/src/audio/core/useTransportScheduler.js index 01ea8e0..fbac626 100644 --- a/src/audio/core/useTransportScheduler.js +++ b/src/audio/core/useTransportScheduler.js @@ -102,6 +102,7 @@ export function useTransportScheduler({ mixerGraphRef, mixerSettingsRef, ensureMixerGraph, + startMixerInsertModulators, applyMixerSettingsToGraph, getInsertInputNodeForChannel, updateMixerMeters, diff --git a/src/components/AiAgentWindow.jsx b/src/components/AiAgentWindow.jsx new file mode 100644 index 0000000..7167822 --- /dev/null +++ b/src/components/AiAgentWindow.jsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { Bot } from "lucide-react"; +import { AiAgentConversationList } from "./ai-agent/AiAgentConversationList"; +import { AiAgentMessages } from "./ai-agent/AiAgentMessages"; +import { AiAgentPlanPanel } from "./ai-agent/AiAgentPlanPanel"; +import { useAiAgentController } from "./ai-agent/useAiAgentController"; + +function AiAgentSelect({ value, options, onChange }) { + const [isOpen, setIsOpen] = useState(false); + const activeOption = options.find(function (option) { + return option.value === value; + }) || options[0]; + + return ( +
+ + {isOpen ? ( +
+ {options.map(function (option) { + const isActive = option.value === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +export function AiAgentWindow() { + const agent = useAiAgentController(); + + return ( +
+
+ +
+

Project assistant

+

AI Agent

+
+
+ +
+ + + + +
+ +
+ {agent.connectionStatus ? ( +
+ {agent.connectionStatus} +
+ ) : null} +
+ +
+ + +
+ + + {agent.error ?
{agent.error}
: null} + +
+