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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ The desired architecture is data-first:
- runtime outputs become observable streams at the JS boundary
- LoomLarge bridges those streams into Effect-managed state

## Agency Ownership Boundaries

When a change concerns character behavior, first identify the Polyester agency
that owns that behavior. Do not move agency behavior into LoomLarge call sites
because a LoomLarge module exposes the symptom.

In particular, speech-time prosody is owned by Polyester:

- speech/vocal timing code triggers the prosodic expression agency
- `src/prosodic/` owns prosodic state, pulse handling, scheduling, and fade-out
- bundled head-nod snippets live under `src/animation/snippets/speaking/`
- LoomLarge should configure or pass agencies, not author prosodic keyframes or
schedule head-nod snippets directly

## Documentation Discipline

When implementation work exposes a recurring misunderstanding, missing
Expand Down
9 changes: 9 additions & 0 deletions src/animation/__tests__/snippetPreloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe('snippetPreloader', () => {
expect(storage.removeItem).not.toHaveBeenCalled();
});

it('bundles head nod snippets with inherited head-pitch anchors', async () => {
const resolved = await resolveSnippetEntry('speakingAnimationsList', 'headNodSmall');

expect(resolved?.source).toBe('bundled');
expect(resolved?.data.curves['54'][0]).toEqual({ time: 0, intensity: 0, inherit: true });
expect(resolved?.data.curves['53'][0]).toEqual({ time: 0, intensity: 0, inherit: true });
expect(resolved?.data.curves['53'].at(-1)?.intensity).toBe(0);
});

it('ignores legacy preloaded bundle entries when reading custom snippet names', () => {
const storage = createStorageMock({
bundledAnimationSnippetsManifest: JSON.stringify({
Expand Down
8 changes: 7 additions & 1 deletion src/animation/snippets/speaking/headNodBig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"54": [
{
"time": 0.0,
"intensity": 0.0
"intensity": 0.0,
"inherit": true
},
{
"time": 0.4,
Expand All @@ -15,6 +16,11 @@
}
],
"53": [
{
"time": 0.0,
"intensity": 0.0,
"inherit": true
},
{
"time": 0.8,
"intensity": 0.0
Expand Down
8 changes: 7 additions & 1 deletion src/animation/snippets/speaking/headNodSmall.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"54": [
{
"time": 0.0,
"intensity": 0.0
"intensity": 0.0,
"inherit": true
},
{
"time": 0.15,
Expand All @@ -15,6 +16,11 @@
}
],
"53": [
{
"time": 0.0,
"intensity": 0.0,
"inherit": true
},
{
"time": 0.3,
"intensity": 0.0
Expand Down
71 changes: 71 additions & 0 deletions src/prosodic/__tests__/prosodicService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProsodicService, type ProsodicServiceAPI } from '../prosodicService';

function createStorageMock(seed: Record<string, string> = {}) {
const data = { ...seed };

return {
data,
getItem: vi.fn((key: string) => data[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
data[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete data[key];
}),
};
}

describe('createProsodicService', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
let service: ProsodicServiceAPI | null;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
service = null;
});

afterEach(() => {
service?.dispose();
vi.unstubAllGlobals();
warnSpy.mockRestore();
});

it('preserves inherited keyframes when scheduling head nod snippets', () => {
const storage = createStorageMock({
'speakingAnimationsList/headNodSmall': JSON.stringify({
name: 'headNodSmall',
curves: {
'53': [
{ time: 0, intensity: 0, inherit: true },
{ time: 0.15, intensity: 0.4 },
{ time: 0.4, intensity: 0 },
],
},
}),
});
const scheduled: any[] = [];

vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', { localStorage: storage });

service = createProsodicService(
{ headLoopKey: 'speakingAnimationsList/headNodSmall' },
{},
{
scheduleSnippet: (snippet) => {
scheduled.push(snippet);
return snippet.name;
},
removeSnippet: vi.fn(),
},
);

service.startTalking();

expect(scheduled).toHaveLength(1);
expect(scheduled[0].curves['53'][0]).toEqual({ time: 0, intensity: 0, inherit: true });
});
});
3 changes: 2 additions & 1 deletion src/prosodic/prosodicMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createMachine, assign } from 'xstate';

export interface ProsodicSnippet {
name: string;
curves: Record<string, Array<{ time: number; intensity: number }>>;
curves: Record<string, Array<{ time: number; intensity: number; inherit?: boolean }>>;
category: 'brow' | 'head';
priority: number;
intensityScale: number;
Expand Down Expand Up @@ -57,6 +57,7 @@ function normalizeSnippet(data: any, category: 'brow' | 'head', priority: number
curves[key] = arr.map((k: any) => ({
time: k.time ?? k.t ?? 0,
intensity: k.intensity ?? k.v ?? 0,
...(typeof k.inherit === 'boolean' ? { inherit: k.inherit } : {}),
}));
curves[key].sort((a, b) => a.time - b.time);
}
Expand Down
1 change: 1 addition & 0 deletions src/prosodic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface AnimationSnippet {
export interface AnimationCurve {
time: number;
intensity: number;
inherit?: boolean;
}

export type ProsodicChannel = 'brow' | 'head' | 'both';
Expand Down
3 changes: 3 additions & 0 deletions src/tts/ttsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export class TTSService {
lipsyncIntensity: config.lipsyncIntensity ?? 1.0,
jawScale: config.jawScale ?? 1.0,
animationAgency: config.animationAgency ?? undefined,
prosodicService: config.prosodicService ?? undefined,
};

this.callbacks = callbacks;
Expand Down Expand Up @@ -146,6 +147,7 @@ export class TTSService {
speechRate: this.config.rate,
jawScale: this.config.jawScale,
animationAgency: this.config.animationAgency,
prosodicService: this.config.prosodicService,
});
}

Expand Down Expand Up @@ -960,6 +962,7 @@ export class TTSService {
speechRate: this.config.rate,
intensity: this.config.lipsyncIntensity,
jawScale: this.config.jawScale,
prosodicService: this.config.prosodicService,
});
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/tts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Type definitions for Text-to-Speech functionality
*/

import type { ProsodicExpressionAgency } from '../vocal';

export type TTSEngine = 'webSpeech' | 'sapi' | 'azure';
export type WebSpeechReferenceMode = 'none' | 'displayMedia';
export type PlaybackReferenceStatus =
Expand Down Expand Up @@ -44,6 +46,8 @@ export interface TTSConfig {
setSnippetTime?: (name: string, timeSec: number) => void;
seek?: (name: string, timeSec: number) => void;
};
/** Prosodic expression agency triggered by speech lifecycle and word boundaries. */
prosodicService?: ProsodicExpressionAgency;
}

export interface TTSVoice {
Expand Down
28 changes: 28 additions & 0 deletions src/vocal/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,32 @@ describe('VocalService word-boundary sync', () => {
expect(seek).toHaveBeenCalledWith('azure_test_timeline', 0.34);
service.dispose();
});

it('triggers the prosodic expression agency from speech timing', () => {
const prosodicService = {
startTalking: vi.fn(),
stopTalking: vi.fn(),
pulse: vi.fn(),
};
const service = new VocalService({
animationAgency: {
schedule: (snippet) => snippet.name,
remove: vi.fn(),
seek: vi.fn(),
},
prosodicService,
});

vi.spyOn(performance, 'now').mockReturnValue(0);

service.startSentence('hello world');
service.onWordBoundary('hello', 0, 0);
service.stopSentence();

expect(prosodicService.startTalking).toHaveBeenCalledTimes(1);
expect(prosodicService.pulse).toHaveBeenCalledWith(0);
expect(prosodicService.stopTalking).toHaveBeenCalledTimes(1);

service.dispose();
});
});
1 change: 1 addition & 0 deletions src/vocal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type {
VocalSource,
VocalTimeline,
VocalWordTiming,
ProsodicExpressionAgency,
VocalConfig,
VocalState,
} from './types';
Expand Down
5 changes: 5 additions & 0 deletions src/vocal/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export class VocalService {
wordTimings: this.normalizeWordTimings(timeline.wordTimings),
};

this.config.prosodicService?.startTalking();

return name;
}

Expand Down Expand Up @@ -182,6 +184,7 @@ export class VocalService {

ctx.wordIndex = expectedIndex + 1;
this.store.setCurrentWord(word);
this.config.prosodicService?.pulse(expectedIndex);
}

/**
Expand Down Expand Up @@ -212,6 +215,7 @@ export class VocalService {

this.currentSentence = null;
this.store.stopSpeaking();
this.config.prosodicService?.stopTalking();
}

/**
Expand Down Expand Up @@ -450,6 +454,7 @@ export class VocalService {
// Clear sentence context if this was it
if (this.currentSentence?.name === name) {
this.currentSentence = null;
this.config.prosodicService?.stopTalking();
}

// Update state if this was the last snippet
Expand Down
2 changes: 1 addition & 1 deletion src/vocal/snippetBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ export function buildVocalSnippet(
config?: Partial<VocalConfig>,
name?: string
): VocalSnippet {
const cfg: Required<Omit<VocalConfig, 'engine' | 'animationAgency'>> = {
const cfg: Required<Omit<VocalConfig, 'engine' | 'animationAgency' | 'prosodicService'>> = {
...DEFAULT_VOCAL_CONFIG,
...config,
};
Expand Down
13 changes: 12 additions & 1 deletion src/vocal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export interface VocalTimeline {
source?: VocalSource;
}

/** Prosodic expression agency triggered by speech timing. */
export interface ProsodicExpressionAgency {
startTalking: () => void;
stopTalking: () => void;
pulse: (wordIndex: number) => void;
stop?: () => void;
}

// ─────────────────────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -112,6 +120,9 @@ export interface VocalConfig {
resumeSnippet?: (name: string) => void;
seek?: (name: string, offsetSec: number) => void;
};

/** Prosodic expression agency for speech-time brow and head gestures. */
prosodicService?: ProsodicExpressionAgency;
}

export interface VocalState {
Expand All @@ -126,7 +137,7 @@ export interface VocalState {
// Constants
// ─────────────────────────────────────────────────────────────────────────────

export const DEFAULT_VOCAL_CONFIG: Required<Omit<VocalConfig, 'engine' | 'animationAgency'>> = {
export const DEFAULT_VOCAL_CONFIG: Required<Omit<VocalConfig, 'engine' | 'animationAgency' | 'prosodicService'>> = {
intensity: 1.0,
speechRate: 1.0,
jawScale: 1.0,
Expand Down