Compare commits

...

8 Commits

Author SHA1 Message Date
Vixalie
0aba9bb15e adjust PulseCard operating style. 2025-03-21 17:23:31 +08:00
Vixalie
cc3b762880 adjust Pulse List layout. 2025-03-21 17:00:34 +08:00
Vixalie
346566b147 add PulseCard component. 2025-03-21 16:59:08 +08:00
Vixalie
4cdc73ca90 add extra adjust sequence logic. 2025-03-21 16:58:35 +08:00
Vixalie
9222e60b58 remove unused import. 2025-03-21 16:35:56 +08:00
Vixalie
770efe269a fix pulse frequency shifting serializing. 2025-03-21 16:35:27 +08:00
Vixalie
65d2e739ef fix atom chain. 2025-03-21 16:27:49 +08:00
Vixalie
3253b8b98e refactor Pattern and Pulse methods. 2025-03-21 14:25:03 +08:00
10 changed files with 365 additions and 233 deletions

View File

@ -1,9 +1,11 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_repr::{Deserialize_repr, Serialize_repr};
use crate::fraction::Fraction;
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr)]
#[repr(u32)]
pub enum FrequencyShifting {
Linear,
Quadratic,

View File

@ -1,180 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
import dayjs from 'dayjs';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithRefresh } from 'jotai/utils';
import { get, reduce } from 'lodash-es';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
export enum FrequencyShifting {
/**
* Change frequency undergoes a linear transformation from previous pulse to current one.
*/
Linear,
/**
* Change frequency undergoes a quadratic transformation from previous pulse to current one.
*/
Quadratic,
/**
* Change frequency undergoes a cubic transformation from previous pulse to current one.
*/
Cubic,
/**
* Change frequency with quick fade in and fade out.
*/
Ease,
/**
* Change frequency with spiking within range from previous pulse and current one.
*/
Pulsating,
/**
* Based on frequency of previous pulse, take the twice of frequency of current pulse as the peak frequency. Follow the maximum frequency limitation.
*/
Spiking,
/**
* Randomize frequency within range from previous frequency and current one.
*/
Randomize,
/**
* Randomize frequency within minium and maximum frequency.
*/
Maniac,
/**
* Synchronize changes of frequency with pulse width changes.
*/
Synchronized,
}
export interface ControlPoint {
x: number;
y: number;
}
export class Pulse {
order: number;
id: number;
offset: number;
width: number;
maniac: boolean;
frequency: number;
frequencyShifting: FrequencyShifting;
controlPoint1: ControlPoint;
controlPoint2: ControlPoint;
constructor(order: number, width: number, frequency: number) {
this.id = v4();
this.order = order;
this.offset = 0;
this.width = width;
this.maniac = false;
this.frequency = frequency;
this.frequencyShifting = FrequencyShifting.Linear;
this.controlPoint1 = { x: 0, y: 0 };
this.controlPoint2 = { x: 0, y: 0 };
}
equals(other: Pulse): boolean {
return this.id === other.id;
}
}
export class Pattern {
id: string;
name: string;
createdAt: number;
lastModifiedAt: number | null;
smoothRepeat: boolean;
pulses: Pulse[];
constructor() {
this.id = v4();
this.name = '';
this.createdAt = dayjs().valueOf();
this.lastModifiedAt = null;
this.smoothRepeat = true;
this.pulses = [];
}
movePulseUp(pulseId: string, step: number) {
const index = this.pulses.findIndex((pulse) => pulse.id === pulseId);
if (index === -1 || index - step < 0) return;
const targetIndex = index - step;
const targetPulse = this.pulses[targetIndex];
const currentPulse = this.pulses[index];
// Swap the pulses
this.pulses[targetIndex] = currentPulse;
this.pulses[index] = targetPulse;
// Swap their order
const tempOrder = currentPulse.order;
currentPulse.order = targetPulse.order;
targetPulse.order = tempOrder;
// Sort pulses by order
this.pulses.sort((a, b) => a.order - b.order);
}
movePulseDown(pulseId: string, step: number) {
const index = this.pulses.findIndex((pulse) => pulse.id === pulseId);
if (index === -1 || index + step >= this.pulses.length) return;
const targetIndex = index + step;
const targetPulse = this.pulses[targetIndex];
const currentPulse = this.pulses[index];
// Swap the pulses
this.pulses[targetIndex] = currentPulse;
this.pulses[index] = targetPulse;
// Swap their order
const tempOrder = currentPulse.order;
currentPulse.order = targetPulse.order;
targetPulse.order = tempOrder;
// Sort pulses by order
this.pulses.sort((a, b) => a.order - b.order);
}
addPulse(): Pulse {
const maxOrder = reduce(this.pulses, (former, pulse) => Math.max(former, pulse.order), 0);
const newPulse = new Pulse(
maxOrder + 1,
this.smoothRepeat ? get(this.pulses, '[0].width', 0) : get(this.pulses, '[-1].width', 0),
this.smoothRepeat
? get(this.pulses, '[0].frequency', 1)
: get(this.pulses, '[-1].frequency', 1),
);
this.pulses.push(newPulse);
return newPulse;
}
updatePulse(pulseId: string, pulse: Pulse) {
const index = this.pulses.findIndex((p) => p.id === pulseId);
if (index !== -1) {
const { id, order, ...rest } = pulse;
this.pulses[index] = { ...this.pulses[index], ...rest };
}
}
deletePulse(pulseId: string) {
this.pulses = this.pulses.filter((pulse) => pulse.id !== pulseId);
this.pulses.sort((a, b) => a.order - b.order);
this.pulses.forEach((pulse, index) => {
pulse.order = index + 1;
});
}
}
export function totalDuration(pattern: Pattern): number {
return reduce(
pattern.pulses,
(former, pulse) => former + pulse.offset,
pattern.smoothRepeat && pattern.pulses.length > 1 ? 100 : 0,
);
}
import { Pattern, Pulse, totalDuration } from './pattern-model';
export const SearchKeywordAtom = atom<string | null>(null);
export const PatternsAtom = atomWithRefresh(async (get) => {
@ -201,25 +30,22 @@ export const CurrentPatternAtom = atomWithRefresh<Pattern | null>(async (get) =>
}
return null;
});
export const PulsesInCurrentPatternAtom = atomWithRefresh(
(get) => get(CurrentPatternAtom)?.pulses ?? [],
);
export const CurrentPatternDuration = atom((get) => {
const currentPattern = get(CurrentPatternAtom);
if (!currentPattern) return 0;
return totalDuration(currentPattern);
});
export const SelectedPulseIdAtom = atom<string | null>(null);
export const SelectedPulseAtom = atom<Pulse | null>((get) => {
const pulses = get(PulsesInCurrentPatternAtom);
export const SelectedPulseAtom = atom<Pulse | null>(async (get) => {
const pattern = await get(CurrentPatternAtom);
const selectedPulseId = get(SelectedPulseIdAtom);
return pulses.find((pulse) => pulse.id === selectedPulseId) ?? null;
console.debug('[refresh selected pulse]', selectedPulseId, pattern);
return pattern?.pulses?.find((pulse) => pulse.id === selectedPulseId) ?? null;
});
export function useSavePattern() {
const refreshPatterns = useSetAtom(PatternsAtom);
const selectedPatternId = useAtomValue(SelectedPatternIdAtom);
const refreshSelectedPattern = useSetAtom(CurrentPatternAtom);
const { showToast } = useNotification();
const savePattern = useCallback(
@ -227,9 +53,6 @@ export function useSavePattern() {
try {
await invoke('save_pattern', { pattern });
refreshPatterns();
if (pattern.id === selectedPatternId) {
refreshSelectedPattern();
}
return true;
} catch (error) {
console.error('[save pattern]', error);

View File

@ -0,0 +1,190 @@
import dayjs from 'dayjs';
import { get, reduce } from 'lodash-es';
import { v4 } from 'uuid';
export enum FrequencyShifting {
/**
* Change frequency undergoes a linear transformation from previous pulse to current one.
*/
Linear = 0,
/**
* Change frequency undergoes a quadratic transformation from previous pulse to current one.
*/
Quadratic = 1,
/**
* Change frequency undergoes a cubic transformation from previous pulse to current one.
*/
Cubic = 2,
/**
* Change frequency with quick fade in and fade out.
*/
Ease = 3,
/**
* Change frequency with spiking within range from previous pulse and current one.
*/
Pulsating = 4,
/**
* Based on frequency of previous pulse, take the twice of frequency of current pulse as the peak frequency. Follow the maximum frequency limitation.
*/
Spiking = 5,
/**
* Randomize frequency within range from previous frequency and current one.
*/
Randomize = 6,
/**
* Randomize frequency within minium and maximum frequency.
*/
Maniac = 7,
/**
* Synchronize changes of frequency with pulse width changes.
*/
Synchronized = 8,
}
export interface ControlPoint {
x: number;
y: number;
}
export interface Pulse {
order: number;
id: number;
offset: number;
width: number;
maniac: boolean;
frequency: number;
frequencyShifting: FrequencyShifting;
controlPoint1: ControlPoint;
controlPoint2: ControlPoint;
}
export function createPulse(order: number, width: number, frequency: number) {
return {
id: v4(),
order,
offset: 0,
width,
maniac: false,
frequency,
frequencyShifting: FrequencyShifting.Linear,
controlPoint1: { x: 0, y: 0 },
controlPoint2: { x: 0, y: 0 },
} as Pulse;
}
export interface Pattern {
id: string;
name: string;
createdAt: number;
lastModifiedAt: number | null;
smoothRepeat: boolean;
pulses: Pulse[];
}
export function createPattern() {
return {
id: v4(),
name: '',
createdAt: dayjs().valueOf(),
lastModifiedAt: null,
smoothRepeat: true,
pulses: [],
} as Pattern;
}
export function movePulseUp(pattern: Pattern, pulseId: string, step: number) {
const index = pattern.pulses.findIndex((pulse) => pulse.id === pulseId);
if (index === -1 || index - step < 0) return;
const targetIndex = index - step;
const targetPulse = pattern.pulses[targetIndex];
const currentPulse = pattern.pulses[index];
// Swap the pulses
pattern.pulses[targetIndex] = currentPulse;
pattern.pulses[index] = targetPulse;
// If the target pulse's order is 1, swap their offsets
if (targetPulse.order === 1) {
const tempOffset = currentPulse.offset;
currentPulse.offset = targetPulse.offset;
targetPulse.offset = tempOffset;
}
// Swap their order
const tempOrder = currentPulse.order;
currentPulse.order = targetPulse.order;
targetPulse.order = tempOrder;
// Sort pulses by order
pattern.pulses.sort((a, b) => a.order - b.order);
}
export function movePulseDown(pattern: Pattern, pulseId: string, step: number) {
const index = pattern.pulses.findIndex((pulse) => pulse.id === pulseId);
if (index === -1 || index + step >= pattern.pulses.length) return;
const targetIndex = index + step;
const targetPulse = pattern.pulses[targetIndex];
const currentPulse = pattern.pulses[index];
// Swap the pulses
pattern.pulses[targetIndex] = currentPulse;
pattern.pulses[index] = targetPulse;
// If the current pulse's order is 1, swap their offsets
if (currentPulse.order === 1) {
const tempOffset = currentPulse.offset;
currentPulse.offset = targetPulse.offset;
targetPulse.offset = tempOffset;
}
// Swap their order
const tempOrder = currentPulse.order;
currentPulse.order = targetPulse.order;
targetPulse.order = tempOrder;
// Sort pulses by order
pattern.pulses.sort((a, b) => a.order - b.order);
}
export function addPulse(pattern: Pattern): Pulse {
const maxOrder = reduce(pattern.pulses, (former, pulse) => Math.max(former, pulse.order), 0);
const newPulse = createPulse(
maxOrder + 1,
pattern.smoothRepeat
? get(pattern.pulses, '[0].width', 0)
: get(pattern.pulses, '[-1].width', 0),
pattern.smoothRepeat
? get(pattern.pulses, '[0].frequency', 1)
: get(pattern.pulses, '[-1].frequency', 1),
);
pattern.pulses.push(newPulse);
return newPulse;
}
export function updatePulse(pattern: Pattern, pulseId: string, pulse: Pulse) {
const index = pattern.pulses.findIndex((p) => p.id === pulseId);
if (index !== -1) {
const { id, order, ...rest } = pulse;
pattern.pulses[index] = { ...pattern.pulses[index], ...rest };
}
}
export function deletePulse(pattern: Pattern, pulseId: string) {
pattern.pulses = pattern.pulses.filter((pulse) => pulse.id !== pulseId);
pattern.pulses.sort((a, b) => a.order - b.order);
pattern.pulses.forEach((pulse, index) => {
pulse.order = index + 1;
});
}
export function durationRemains(pattern: Pattern): number {
return 20 * 1000 - totalDuration(pattern) - (pattern.smoothRepeat ? 100 : 0);
}
export function totalDuration(pattern: Pattern): number {
return reduce(
pattern.pulses,
(former, pulse) => former + pulse.offset,
pattern.smoothRepeat && pattern.pulses.length > 1 ? 100 : 0,
);
}

View File

@ -0,0 +1,53 @@
@layer pages {
.pulse_card {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing) * 2);
color: var(--color-on-surface);
background-color: var(--color-surface);
border-radius: calc(var(--border-radius) * 2);
padding: 0;
.order {
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
border-top-left-radius: calc(var(--border-radius) * 2);
border-bottom-left-radius: calc(var(--border-radius) * 2);
}
.offset {
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
}
&.selected {
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
.order {
background-color: color-mix(
in oklch,
var(--color-secondary-container) 88%,
var(--color-white)
);
}
}
&:hover {
background-color: color-mix(in oklch, var(--color-on-surface) 8%, transparent);
box-shadow: var(--elevation-2-ambient), var(--elevation-2-umbra);
.order {
background-color: color-mix(
in oklch,
var(--color-secondary-container) 60%,
var(--color-white)
);
}
}
&:active {
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
.order {
background-color: color-mix(
in oklch,
var(--color-secondary-container) 68%,
var(--color-white)
);
}
}
}
}

View File

@ -0,0 +1,34 @@
import cx from 'clsx';
import { useAtom } from 'jotai';
import { FC, useCallback } from 'react';
import { Pulse } from '../../context/pattern-model';
import { SelectedPulseIdAtom } from '../../context/Patterns';
import styles from './PulseCard.module.css';
type PulseCardProps = {
pulse: Pulse;
};
const PulseCard: FC<PulseCardProps> = ({ pulse }) => {
const [selected, setSelected] = useAtom(SelectedPulseIdAtom);
const handleSelect = useCallback(() => {
if (selected === pulse.id) {
setSelected(null);
} else {
setSelected(pulse.id);
}
}, [pulse, selected]);
return (
<div
className={cx(styles.pulse_card, selected === pulse.id && styles.selected)}
onClick={handleSelect}>
<div className={styles.order}>{pulse.order.toString().padStart(3, '0')}</div>
<div className="spacer" />
<div className={styles.offset}>+ {pulse.offset} ms</div>
</div>
);
};
export default PulseCard;

View File

@ -5,14 +5,17 @@
justify-content: flex-end;
align-items: center;
gap: calc(var(--spacing) * 2);
}
.attribute_unit {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing));
label {
font-weight: bold;
.attribute_unit {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing));
font-size: var(--body-small-font-size);
line-height: var(--body-small-line-height);
font-weight: var(--body-small-font-weight);
label {
font-weight: 500;
}
}
}
.pulses {

View File

@ -1,39 +1,46 @@
import { DndContext } from '@dnd-kit/core';
import { Icon } from '@iconify/react/dist/iconify.js';
import { useAtomValue } from 'jotai';
import { FC } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { max } from 'lodash-es';
import { FC, useCallback, useMemo } from 'react';
import { ScrollArea } from '../../components/ScrollArea';
import { addPulse, deletePulse } from '../../context/pattern-model';
import {
CurrentPatternAtom,
CurrentPatternDuration,
PulsesInCurrentPatternAtom,
SelectedPulseAtom,
useSavePattern,
} from '../../context/Patterns';
import PulseCard from './PulseCard';
import styles from './PulseList.module.css';
const PulseList: FC = () => {
const pattern = useAtomValue(CurrentPatternAtom);
const pulses = useAtomValue(PulsesInCurrentPatternAtom);
const [pattern, refreshPattern] = useAtom(CurrentPatternAtom);
const duration = useAtomValue(CurrentPatternDuration);
const selectedPulse = useAtomValue(SelectedPulseAtom);
const maxPulseOrder = useMemo(
() => max(pattern?.pulses.map((pulse) => pulse.order) ?? []),
[pattern],
);
const savePattern = useSavePattern();
const handleAddPulseAction = useCallback(async () => {
if (!pattern) return;
addPulse(pattern);
await savePattern(pattern);
refreshPattern();
}, [pattern]);
const handleDeletePulseAction = useCallback(async () => {
if (!pattern || !selectedPulse) return;
deletePulse(pattern, selectedPulse.id);
await savePattern(pattern);
refreshPattern();
}, [pattern, selectedPulse]);
return (
<>
<div className={styles.pulse_tools}>
<button className="text">
<Icon icon="material-symbols-light:play-arrow-outline" />
<span>Test Run</span>
</button>
<button className="text">
<Icon icon="material-symbols-light:add" />
<span>Add Pulse</span>
</button>
<button className="text">
<Icon icon="material-symbols-light:delete-forever-outline" />
<span>Delete Selected</span>
</button>
</div>
<div className={styles.pulse_tools}>
<div className={styles.attribute_unit}>
<span>{pulses.length ?? 0}</span>
<span>{pattern?.pulses.length ?? 0}</span>
<label>Key Pulses</label>
</div>
<div className={styles.attribute_unit}>
@ -41,13 +48,43 @@ const PulseList: FC = () => {
<span>{duration.toFixed(2)} s</span>
</div>
</div>
<div className={styles.pulse_tools}>
<button className="text" disabled>
<Icon icon="material-symbols-light:play-arrow-outline" />
<span>Test Run</span>
</button>
<button className="text" onClick={handleAddPulseAction}>
<Icon icon="material-symbols-light:add" />
<span>Add Pulse</span>
</button>
<button className="text" onClick={handleDeletePulseAction} disabled={!selectedPulse}>
<Icon icon="material-symbols-light:delete-forever-outline" />
<span>Delete Selected</span>
</button>
</div>
<div className={styles.pulse_tools}>
<button className="text" disabled={!selectedPulse || selectedPulse.order === 1}>
<Icon icon="material-symbols-light:arrow-upward" />
<span>Move Up</span>
</button>
<button className="text" disabled={!selectedPulse || selectedPulse.order === maxPulseOrder}>
<Icon icon="material-symbols-light:arrow-downward" />
<span>Move Down</span>
</button>
</div>
<div className={styles.pulses}>
<ScrollArea enableY>
<DndContext>
<div className={styles.pulse_cards}>
{pulses.length === 0 && <div className="empty_prompt">No key pulses.</div>}
</div>
</DndContext>
<div className={styles.pulse_cards}>
{pattern?.pulses.length === 0 ? (
<div className="empty_prompt">No key pulses.</div>
) : (
<>
{pattern?.pulses.map((pulse) => (
<PulseCard key={pulse.id} pulse={pulse} />
))}
</>
)}
</div>
</ScrollArea>
</div>
</>

View File

@ -6,13 +6,8 @@ import { FC, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { NotificationType, useNotification } from '../../components/Notifications';
import PatternPreview from '../../components/PatternPreview';
import {
CurrentPatternAtom,
Pattern,
PatternsAtom,
SelectedPatternIdAtom,
totalDuration,
} from '../../context/Patterns';
import { CurrentPatternAtom, PatternsAtom, SelectedPatternIdAtom } from '../../context/Patterns';
import { Pattern, totalDuration } from '../../context/pattern-model';
import styles from './PatternDetail.module.css';
const EmptyPromption: FC = () => {

View File

@ -5,13 +5,8 @@ import { FC, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDebounce } from 'react-use';
import { ScrollArea } from '../../components/ScrollArea';
import {
Pattern,
PatternsAtom,
SearchKeywordAtom,
SelectedPatternIdAtom,
totalDuration,
} from '../../context/Patterns';
import { PatternsAtom, SearchKeywordAtom, SelectedPatternIdAtom } from '../../context/Patterns';
import { Pattern, totalDuration } from '../../context/pattern-model';
import styles from './Patterns.module.css';
const PatternCard: FC<{ pattern: Pattern }> = ({ pattern }) => {

View File

@ -3,7 +3,7 @@ import { useSetAtom } from 'jotai';
import { FC, useActionState } from 'react';
import { useNavigate } from 'react-router-dom';
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
import { Pattern, SelectedPatternIdAtom, useSavePattern } from '../context/Patterns';
import { SelectedPatternIdAtom, useSavePattern } from '../context/Patterns';
import styles from './CreatePattern.module.css';
const CreatePattern: FC = () => {