diff --git a/.devague/current b/.devague/current new file mode 100644 index 0000000..9d2230f --- /dev/null +++ b/.devague/current @@ -0,0 +1 @@ +harmonics-gives-an-agent-or-robot-its-own-non-spee diff --git a/.devague/current_plan b/.devague/current_plan new file mode 100644 index 0000000..9d2230f --- /dev/null +++ b/.devague/current_plan @@ -0,0 +1 @@ +harmonics-gives-an-agent-or-robot-its-own-non-spee diff --git a/.devague/frames/harmonics-gives-an-agent-or-robot-its-own-non-spee.json b/.devague/frames/harmonics-gives-an-agent-or-robot-its-own-non-spee.json new file mode 100644 index 0000000..256f334 --- /dev/null +++ b/.devague/frames/harmonics-gives-an-agent-or-robot-its-own-non-spee.json @@ -0,0 +1,293 @@ +{ + "slug": "harmonics-gives-an-agent-or-robot-its-own-non-spee", + "title": "harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean \u2014 the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second.", + "schema_version": 1, + "status": "exported", + "created": "2026-07-08T05:35:46Z", + "updated": "2026-07-08T05:52:57Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean \u2014 the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "In a blind ear test the output reads as one agent's discrete first-person utterance \u2014 not an ambient bed narrating events \u2014 and two identities rendering the same axes are recognizably different voices.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c2", + "kind": "audience", + "text": "AI agents and physical robots that must express themselves without a screen or speech, plus the humans and peer agents nearby who listen and need to know \u2014 by ear \u2014 who is present, what they intend, and how it's going.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "The audience is real and reachable: a concrete consumer exists on each side \u2014 a headless/robot agent that emits, and a human or peer agent that listens and acts on what it hears.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c3", + "kind": "after_state", + "text": "An agent can render its current meaning (intent/confidence/urgency/state/identity) into a short, pleasant note sequence \u2014 and thence audio \u2014 that a listener recognizes as *that agent* saying *that thing*, produced offline, deterministically, with a dependency-free core.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "The after-state is demonstrable end to end: one command takes axes (or a sentence) and yields a note sequence, and an audio backend can render that sequence \u2014 with the core path needing no audio device.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c4", + "kind": "before_state", + "text": "Today a headless agent is silent, or borrows a third-person spectator soundtrack (like league-of-agents' replay score) that narrates a match from the outside off an event log; there is no first-person non-speech channel an agent emits as itself, live, in the moment.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "The gap is real: no existing surface gives an agent a first-person non-speech voice; league's soundtrack is third-person and event-log-driven, per its own module docs.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "A recognizable non-speech voice makes a headless agent or robot present and legible by ear \u2014 you can tell who is working, who just succeeded, who is stuck \u2014 with no screen to read and no speech to synthesize or parse.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "The benefit is observable: from sound alone, with no screen, a listener identifies who is acting and the broad state (working/succeeded/blocked) above chance.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c6", + "kind": "boundary", + "text": "Not TTS (no words or phonemes); not a spectator soundtrack, match score, or event-log narration; not an ambient bed you tune out. It is a first-person utterance you attend to, driven by the agent's own axes rather than an external timeline.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "The boundary is enforceable: the surface reproduces no phonemes or words, and its output is driven by the agent's own axes/sentence \u2014 never by an external match or event log.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c7", + "kind": "success_signal", + "text": "Given axes plus an agent identity, harmonics emits a deterministic note sequence (dry-run, no audio device) that unit tests assert on directly; and by ear a listener can tell two different agents apart, and tell a confident success from an uncertain question.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "The signal is measured, not asserted: tests assert on emitted note sequences with no device, and a documented ear-test protocol backs the two human-discrimination claims (agent-vs-agent, success-vs-question).", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c8", + "kind": "requirement", + "text": "A pure text->notes core: 'harmonics play --intent [--confidence ] [--urgency ] [--state ] [--as ]' maps the axes to a documented note-event sequence. Dry-run by default (emits the notes); making sound or a file needs an explicit --play/--out. The core imports no audio stack and runs with no device.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "Same axes + same identity produce an identical note sequence (no wall-clock, no unseeded randomness); the entire text->notes path runs in the test suite with no third-party import and no audio device present.", + "status": "rejected" + }, + { + "id": "h5", + "text": "Same axes + same identity + same nonce (--seq) produce an identical note sequence, and the micro-variation across nonces is itself a pure function of the nonce \u2014 no wall-clock, no unseeded randomness; the whole text->notes path runs in tests with no third-party import and no audio device present.", + "status": "confirmed" + } + ], + "hard_questions": [ + { + "id": "q1", + "text": "Does the first increment ship only 'play' (explicit axes -> notes), with 'say' (sentence -> inferred axes) as a follow-up? Or must sentence inference land in the same increment? [RESOLVED: both 'play' (explicit axes) and 'say' (sentence inference) ship in increment 1 \u2014 see the 'say' requirement.]", + "resolved": true, + "blocking": false + } + ], + "links": [] + }, + { + "id": "c9", + "kind": "requirement", + "text": "Each agent carries a recognizable signature (its 'voice print' \u2014 key / instrument-timbre / motif seed) derived deterministically from its identity string, generalizing league's per-team register into per-agent identity, with an optional hand-authored override in the palette.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "The same identity always renders the same signature; two distinct identities render audibly distinct, stable signatures; a hand-authored palette override deterministically replaces the derived one.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c10", + "kind": "decision", + "text": "A rendered gesture is a sequence of note events, each a small record (start, duration, pitch, velocity, voice/timbre) \u2014 the machine-readable 'notes' of text-to-notes. This sequence is the unit-test surface and the MIDI/robot-consumable representation; audio backends render FROM it, never instead of it.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [] + }, + { + "id": "c11", + "kind": "requirement", + "text": "The palette stays pleasant and non-fatiguing even at the urgent end: urgency is carried by tempo, attack sharpness, and repetition \u2014 never by dissonance or raw loudness \u2014 so an urgent voice is attention-grabbing but never an alarm-clock, and repeated play next to a human does not fatigue.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "'Non-fatiguing' and 'non-alarm' are checkable on the note sequence: every gesture stays within a documented consonant palette and bounded velocity/attack, and the urgent-vs-calm difference shows up only as bounded inter-onset/tempo/repetition \u2014 never as added dissonance or a raised level ceiling.", + "status": "confirmed" + } + ], + "hard_questions": [ + { + "id": "q2", + "text": "risk: Even with bounded palette/velocity/tempo proxies, final 'pleasantness' needs a human ear; the checkable proxies reduce but do not eliminate subjectivity.", + "resolved": false, + "blocking": false + } + ], + "links": [] + }, + { + "id": "c12", + "kind": "requirement", + "text": "'harmonics say \"\"' parses a sentence into the five axes and then renders it \u2014 the text->notes surface. It ships in the same increment as 'play', reusing 'play's mapping once axes are inferred. Dry-run by default like every producing verb.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "Sentence->axes inference is deterministic and offline: a documented cue/keyword->axis rule table (not a network or model call) maps a sentence to the axes; the same sentence always yields the same axes; the test path makes no network call and imports no model.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c13", + "kind": "requirement", + "text": "A caller-supplied nonce ('--seq', with a stable default) selects among deterministic micro-variations of the same gesture, so repeated utterances feel alive rather than robotic while staying fully reproducible (generalizing league's field-hashed variety).", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "Two different nonces yield audibly-but-subtly different utterances of the same intent; the same nonce always yields the same one; the default (no --seq) is itself a fixed, documented value \u2014 variation is never a function of wall-clock or entropy.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c14", + "kind": "requirement", + "text": "The 'say' tune is derived from the sentence's own units (letters / syllables / words -> pitch, timbre, rhythm steps), not only from inferred axes, so a human can APPROXIMATELY connect the sound back to the text \u2014 following 'what is being said' by how it is voiced, the way you follow a hummed phrase. Still non-speech: no phonemes are reproduced.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "The text->contour mapping is documented and deterministic (same text -> same contour); in a small blind ear-test, listeners match a rendered tune back to its source phrase (from a short candidate set) better than chance.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c15", + "kind": "requirement", + "text": "The agent/robot can STRESS parts of the tune via pitch and volume to emote \u2014 raising pitch and/or loudness on marked segments, emphasis carried explicitly (emphasis markers in the text or a stress argument), layered over the intent/confidence/urgency axes. Stress modulation stays WITHIN c11's documented level ceiling, so emphasis never becomes an alarm.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "Stressing a segment measurably raises that segment's note pitch and/or velocity in the emitted sequence relative to the neutral baseline, deterministically and within the level ceiling; with no stress markers the render is the documented neutral baseline.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c16", + "kind": "success_signal", + "text": "By ear a listener can approximately connect a rendered 'say' tune to its source sentence, and can tell which parts were stressed \u2014 the voice reads as 'this text, emoted', not an arbitrary motif.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "The legibility/stress signal is measured: a documented ear-test protocol exists \u2014 tune->phrase matching from a candidate set and stressed-part identification \u2014 with a stated better-than-chance bar.", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + } + ], + "open_vagueness": [ + { + "id": "v2", + "text": "Audio backend for actual sound (pure-Python sine/FM offline default vs sounddevice/simpleaudio/soundfont). The core stays dependency-free and offline-testable, so this does not block the spec.", + "kind": "unknown_nonblocking", + "claim_id": null + }, + { + "id": "v3", + "text": "Granularity of the text->contour unit (per-letter vs per-syllable vs per-word, and how letters color pitch/timbre) \u2014 the legibility requirement is fixed; the exact unit is an implementation choice to settle in the plan.", + "kind": "unknown_nonblocking", + "claim_id": null + } + ] +} diff --git a/.devague/plans/harmonics-gives-an-agent-or-robot-its-own-non-spee.json b/.devague/plans/harmonics-gives-an-agent-or-robot-its-own-non-spee.json new file mode 100644 index 0000000..94288f2 --- /dev/null +++ b/.devague/plans/harmonics-gives-an-agent-or-robot-its-own-non-spee.json @@ -0,0 +1,394 @@ +{ + "slug": "harmonics-gives-an-agent-or-robot-its-own-non-spee", + "title": "harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean \u2014 the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second.", + "frame_slug": "harmonics-gives-an-agent-or-robot-its-own-non-spee", + "schema_version": 1, + "status": "exported", + "created": "2026-07-08T06:31:43Z", + "updated": "2026-07-08T07:24:19Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean \u2014 the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second." + }, + { + "id": "h1", + "kind": "honesty", + "text": "In a blind ear test the output reads as one agent's discrete first-person utterance \u2014 not an ambient bed narrating events \u2014 and two identities rendering the same axes are recognizably different voices." + }, + { + "id": "c2", + "kind": "audience", + "text": "AI agents and physical robots that must express themselves without a screen or speech, plus the humans and peer agents nearby who listen and need to know \u2014 by ear \u2014 who is present, what they intend, and how it's going." + }, + { + "id": "h10", + "kind": "honesty", + "text": "The audience is real and reachable: a concrete consumer exists on each side \u2014 a headless/robot agent that emits, and a human or peer agent that listens and acts on what it hears." + }, + { + "id": "c3", + "kind": "after_state", + "text": "An agent can render its current meaning (intent/confidence/urgency/state/identity) into a short, pleasant note sequence \u2014 and thence audio \u2014 that a listener recognizes as *that agent* saying *that thing*, produced offline, deterministically, with a dependency-free core." + }, + { + "id": "h11", + "kind": "honesty", + "text": "The after-state is demonstrable end to end: one command takes axes (or a sentence) and yields a note sequence, and an audio backend can render that sequence \u2014 with the core path needing no audio device." + }, + { + "id": "c4", + "kind": "before_state", + "text": "Today a headless agent is silent, or borrows a third-person spectator soundtrack (like league-of-agents' replay score) that narrates a match from the outside off an event log; there is no first-person non-speech channel an agent emits as itself, live, in the moment." + }, + { + "id": "h12", + "kind": "honesty", + "text": "The gap is real: no existing surface gives an agent a first-person non-speech voice; league's soundtrack is third-person and event-log-driven, per its own module docs." + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "A recognizable non-speech voice makes a headless agent or robot present and legible by ear \u2014 you can tell who is working, who just succeeded, who is stuck \u2014 with no screen to read and no speech to synthesize or parse." + }, + { + "id": "h13", + "kind": "honesty", + "text": "The benefit is observable: from sound alone, with no screen, a listener identifies who is acting and the broad state (working/succeeded/blocked) above chance." + }, + { + "id": "c6", + "kind": "boundary", + "text": "Not TTS (no words or phonemes); not a spectator soundtrack, match score, or event-log narration; not an ambient bed you tune out. It is a first-person utterance you attend to, driven by the agent's own axes rather than an external timeline." + }, + { + "id": "h14", + "kind": "honesty", + "text": "The boundary is enforceable: the surface reproduces no phonemes or words, and its output is driven by the agent's own axes/sentence \u2014 never by an external match or event log." + }, + { + "id": "c7", + "kind": "success_signal", + "text": "Given axes plus an agent identity, harmonics emits a deterministic note sequence (dry-run, no audio device) that unit tests assert on directly; and by ear a listener can tell two different agents apart, and tell a confident success from an uncertain question." + }, + { + "id": "h15", + "kind": "honesty", + "text": "The signal is measured, not asserted: tests assert on emitted note sequences with no device, and a documented ear-test protocol backs the two human-discrimination claims (agent-vs-agent, success-vs-question)." + }, + { + "id": "c8", + "kind": "requirement", + "text": "A pure text->notes core: 'harmonics play --intent [--confidence ] [--urgency ] [--state ] [--as ]' maps the axes to a documented note-event sequence. Dry-run by default (emits the notes); making sound or a file needs an explicit --play/--out. The core imports no audio stack and runs with no device." + }, + { + "id": "h5", + "kind": "honesty", + "text": "Same axes + same identity + same nonce (--seq) produce an identical note sequence, and the micro-variation across nonces is itself a pure function of the nonce \u2014 no wall-clock, no unseeded randomness; the whole text->notes path runs in tests with no third-party import and no audio device present." + }, + { + "id": "c9", + "kind": "requirement", + "text": "Each agent carries a recognizable signature (its 'voice print' \u2014 key / instrument-timbre / motif seed) derived deterministically from its identity string, generalizing league's per-team register into per-agent identity, with an optional hand-authored override in the palette." + }, + { + "id": "h3", + "kind": "honesty", + "text": "The same identity always renders the same signature; two distinct identities render audibly distinct, stable signatures; a hand-authored palette override deterministically replaces the derived one." + }, + { + "id": "c11", + "kind": "requirement", + "text": "The palette stays pleasant and non-fatiguing even at the urgent end: urgency is carried by tempo, attack sharpness, and repetition \u2014 never by dissonance or raw loudness \u2014 so an urgent voice is attention-grabbing but never an alarm-clock, and repeated play next to a human does not fatigue." + }, + { + "id": "h4", + "kind": "honesty", + "text": "'Non-fatiguing' and 'non-alarm' are checkable on the note sequence: every gesture stays within a documented consonant palette and bounded velocity/attack, and the urgent-vs-calm difference shows up only as bounded inter-onset/tempo/repetition \u2014 never as added dissonance or a raised level ceiling." + }, + { + "id": "c12", + "kind": "requirement", + "text": "'harmonics say \"\"' parses a sentence into the five axes and then renders it \u2014 the text->notes surface. It ships in the same increment as 'play', reusing 'play's mapping once axes are inferred. Dry-run by default like every producing verb." + }, + { + "id": "h6", + "kind": "honesty", + "text": "Sentence->axes inference is deterministic and offline: a documented cue/keyword->axis rule table (not a network or model call) maps a sentence to the axes; the same sentence always yields the same axes; the test path makes no network call and imports no model." + }, + { + "id": "c13", + "kind": "requirement", + "text": "A caller-supplied nonce ('--seq', with a stable default) selects among deterministic micro-variations of the same gesture, so repeated utterances feel alive rather than robotic while staying fully reproducible (generalizing league's field-hashed variety)." + }, + { + "id": "h7", + "kind": "honesty", + "text": "Two different nonces yield audibly-but-subtly different utterances of the same intent; the same nonce always yields the same one; the default (no --seq) is itself a fixed, documented value \u2014 variation is never a function of wall-clock or entropy." + }, + { + "id": "c14", + "kind": "requirement", + "text": "The 'say' tune is derived from the sentence's own units (letters / syllables / words -> pitch, timbre, rhythm steps), not only from inferred axes, so a human can APPROXIMATELY connect the sound back to the text \u2014 following 'what is being said' by how it is voiced, the way you follow a hummed phrase. Still non-speech: no phonemes are reproduced." + }, + { + "id": "h8", + "kind": "honesty", + "text": "The text->contour mapping is documented and deterministic (same text -> same contour); in a small blind ear-test, listeners match a rendered tune back to its source phrase (from a short candidate set) better than chance." + }, + { + "id": "c15", + "kind": "requirement", + "text": "The agent/robot can STRESS parts of the tune via pitch and volume to emote \u2014 raising pitch and/or loudness on marked segments, emphasis carried explicitly (emphasis markers in the text or a stress argument), layered over the intent/confidence/urgency axes. Stress modulation stays WITHIN c11's documented level ceiling, so emphasis never becomes an alarm." + }, + { + "id": "h9", + "kind": "honesty", + "text": "Stressing a segment measurably raises that segment's note pitch and/or velocity in the emitted sequence relative to the neutral baseline, deterministically and within the level ceiling; with no stress markers the render is the documented neutral baseline." + }, + { + "id": "c16", + "kind": "success_signal", + "text": "By ear a listener can approximately connect a rendered 'say' tune to its source sentence, and can tell which parts were stressed \u2014 the voice reads as 'this text, emoted', not an arbitrary motif." + }, + { + "id": "h16", + "kind": "honesty", + "text": "The legibility/stress signal is measured: a documented ear-test protocol exists \u2014 tune->phrase matching from a candidate set and stressed-part identification \u2014 with a stated better-than-chance bar." + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Note-event core (harmonics/notes.py): the NoteEvent record + note-sequence container", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A NoteEvent carries (start, duration, pitch, velocity, voice) and round-trips to/from a plain dict/JSON without loss", + "A note sequence serialises to a stable JSON list and to a MIDI-like note representation; importing harmonics.notes pulls in zero third-party packages" + ], + "deps": [], + "covers": [] + }, + { + "id": "t2", + "summary": "Axes->gesture mapping table (harmonics/mapping.py): the design spine intent/confidence/urgency/state -> sonic parameters", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Each intent maps to a documented motif family and confidence/urgency/state to documented note-parameter changes, all in one table asserted by tests", + "Every rendered gesture stays within a documented consonant scale and a bounded velocity/attack ceiling; the urgent-vs-calm delta appears ONLY as inter-onset/tempo/repetition (a test diffs an urgent vs calm sequence and finds no added dissonance or raised level)" + ], + "deps": [ + "t1" + ], + "covers": [ + "c11", + "h4" + ] + }, + { + "id": "t3", + "summary": "Identity voice-print (harmonics/identity.py): identity string -> (key, timbre, motif-seed) + palette override", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The same identity string always yields the same (key, voice/timbre, motif-seed); two distinct identities yield audibly-distinct signatures, asserted by tests", + "A palette override file deterministically replaces the derived signature for a named agent" + ], + "deps": [ + "t1" + ], + "covers": [ + "c9", + "h3" + ] + }, + { + "id": "t4", + "summary": "Deterministic micro-variation (harmonics/variation.py): --seq nonce -> reproducible gesture variety", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "For fixed axes+identity, two --seq values give different-but-valid sequences and the same --seq reproduces identically; the no-seq default is a fixed documented value", + "Variation is a pure function of the seq value only \u2014 no wall-clock, no entropy \u2014 verified by a determinism test" + ], + "deps": [ + "t1" + ], + "covers": [ + "c13", + "h7" + ] + }, + { + "id": "t5", + "summary": "Sentence->axes inference (harmonics/inference.py): offline cue/keyword rule table", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A documented cue/keyword table maps a sentence to the five axes deterministically; the same sentence always yields the same axes", + "Inference imports no model and makes no network call \u2014 asserted on the import graph / no socket use" + ], + "deps": [], + "covers": [ + "h6" + ] + }, + { + "id": "t6", + "summary": "Text->melodic contour (harmonics/contour.py): sentence units -> pitch/rhythm steps so a human can follow sound<->text", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A documented, deterministic mapping turns a sentence's units into pitch/rhythm steps over the agent's key; same text -> identical contour", + "No phonemes are synthesised (still non-speech); the contour is a structurally-asserted note sequence" + ], + "deps": [ + "t1", + "t3" + ], + "covers": [ + "c14", + "h8" + ] + }, + { + "id": "t7", + "summary": "Expressive stress (harmonics/stress.py): emphasis markers -> pitch+volume emphasis, bounded by the level ceiling", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Marking a segment (emphasis marker or --stress) measurably raises that segment's note pitch and/or velocity vs the neutral baseline, deterministically; no markers -> the documented neutral baseline", + "Stress-boosted velocity never exceeds the palette level ceiling defined in the mapping (t2)" + ], + "deps": [ + "t2", + "t6" + ], + "covers": [ + "c15", + "h9" + ] + }, + { + "id": "t8", + "summary": "'play' verb (harmonics/cli/_commands/play.py): explicit axes -> notes, dry-run by default", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "'harmonics play --intent success --as X' prints a note sequence to stdout by default (dry-run), supports --json, and makes NO sound without --play/--out", + "Same --intent/--confidence/--urgency/--state/--as/--seq -> identical note sequence; the whole path runs in tests with no audio device and no third-party import" + ], + "deps": [ + "t2", + "t3", + "t4" + ], + "covers": [ + "c8", + "h5" + ] + }, + { + "id": "t9", + "summary": "'say' verb (harmonics/cli/_commands/say.py): sentence -> inferred axes + text contour + stress -> notes", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "'harmonics say \"done, tests all green\"' infers axes (t5), builds the text contour (t6), applies stress (t7), and renders via the mapping/play path (t2/t8); dry-run by default with --play/--out/--midi", + "say reuses play's render path once axes are inferred; its output is a note sequence asserted in tests with no device" + ], + "deps": [ + "t5", + "t6", + "t7", + "t8" + ], + "covers": [ + "c12" + ] + }, + { + "id": "t10", + "summary": "Replace placeholder self-descriptions + write the mission/mapping/boundary docs", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The 'clonable template' self-descriptions in _build_parser, learn, the explain catalog, and overview are replaced with the harmonics VOICE mission; grep finds no template self-description", + "CLAUDE.md/README document the five-axis mapping table and the voice-vs-soundtrack boundary (no phonemes; driven by the agent's own axes, not an external event log), naming the league-of-agents contrast" + ], + "deps": [], + "covers": [ + "c1", + "c2", + "h10", + "c4", + "h12", + "c5", + "c6", + "h14" + ] + }, + { + "id": "t11", + "summary": "Ear-test protocol + offline note-sequence test harness", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "docs/ear-test-protocol.md defines blind-listening protocols (agent-vs-agent, success-vs-question, tune->phrase matching, stressed-part id), each with a stated better-than-chance bar", + "A test helper asserts on emitted note sequences with NO audio device; the suite runs offline under pytest" + ], + "deps": [ + "t1" + ], + "covers": [ + "h1", + "c7", + "h15", + "h13", + "c16", + "h16" + ] + }, + { + "id": "t12", + "summary": "Offline audio backend + end-to-end render-to-sound/file (harmonics/audio/)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A pure-Python offline backend renders a note sequence to WAV bytes deterministically; --out writes a file and --play uses an ISOLATED optional import the core never requires", + "One command takes axes (or a sentence) end-to-end to audio, while importing the core still needs no audio device" + ], + "deps": [ + "t8", + "t9" + ], + "covers": [ + "c3", + "h11" + ] + } + ], + "risks": [ + { + "id": "r1", + "text": "Audio backend choice (pure-Python sine/FM vs sounddevice/simpleaudio/soundfont) is unsettled; the dependency-free core is unaffected.", + "kind": "unknown_nonblocking", + "task_id": "t12" + }, + { + "id": "r2", + "text": "Text->contour granularity (per-letter vs per-syllable vs per-word) is unsettled; the legibility requirement is fixed, the exact unit is TBD in build.", + "kind": "unknown_nonblocking", + "task_id": "t6" + }, + { + "id": "r3", + "text": "Final 'pleasantness' needs a human ear; bounded palette/velocity/tempo proxies reduce but do not eliminate subjectivity.", + "kind": "unknown_nonblocking", + "task_id": "t2" + } + ] +} diff --git a/.gitignore b/.gitignore index 95a383a..7ce909a 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,8 @@ __marimo__/ # Per-machine skills config (copy from skills.local.yaml.example) skills.local.yaml + +# devague working state (not committed by default) +.devague/questions/ + +.devague/reviews/ diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 8df5f3d..dde97d1 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -19,5 +19,8 @@ ignores: - ".local/**" - ".afi/**" - ".teken/**" + # devague working state (frames/plans committed as JSON; reviews/questions + # are git-ignored markdown) — tool-generated, not authored prose. + - ".devague/**" # Vendored skills are cited verbatim from guildmaster — do not reformat them. - ".claude/skills/**" diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ca6cd..20f38d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-07-08 + +### Added + +- The **harmonics voice domain** — the non-TTS audio surface from issue #1, built + as an offline, dependency-free text-to-notes core with audio layered on top: + - `harmonics/axes.py` — the shared five-axis vocabulary (intent, confidence, + urgency, state, identity) and a validated `Axes` record. + - `harmonics/notes.py` — the `NoteEvent` core (start, duration, pitch, + velocity, voice) + JSON and MIDI-like serialization; the unit-test surface + and robot/synth representation. + - `harmonics/mapping.py` — the design spine: axes → sonic parameters (intent → + motif, confidence → cadence, urgency → tempo/repetition, state → + sustained-vs-discrete) over a consonant pentatonic scale with a velocity + ceiling, so an urgent voice is attention-grabbing but never an alarm. + - `harmonics/identity.py` — per-agent voice-prints (`root_pitch`, `instrument`, + `seed`) derived deterministically from the identity string, with palette + overrides — so you can tell *who* is speaking by ear. + - `harmonics/variation.py` — deterministic micro-variation keyed by a `--seq` + nonce (never wall-clock/entropy), so repeated utterances feel alive yet + reproducible. + - `harmonics/inference.py` — offline sentence → axes inference (a documented + cue table, no model/network). + - `harmonics/contour.py` — text → melodic contour (one note per word) so a + listener can approximately follow sound ↔ text; still non-speech. + - `harmonics/stress.py` — expressive emphasis (`*word*` / ALL-CAPS) that raises + pitch/velocity within the level ceiling. + - `harmonics/audio/` — a pure-stdlib offline WAV synthesizer; live playback via + an isolated, lazy optional import so the core never needs an audio device. +- The **`play`** verb (`harmonics play --intent … [--confidence/--urgency/--state] + [--as] [--seq]`) — explicit axes → notes, dry-run by default, with + `--json`/`--out`/`--wav`/`--play`. +- The **`say`** verb (`harmonics say ""`) — infers axes, renders the + text-tracking tune in the agent's voice, applies emphasis and micro-variation; + same output/capture contract as `play`. +- **`--articulation {discrete, speechy, smooth, alien}`** on both verbs — how the + synth *moves* between notes. `discrete` is the original per-note synth; the + glide styles run one continuous oscillator that slides (portamento) and stays + legato between word-pitches with a light vibrato, so the voice flows and reads + like speech. Defaults to `smooth` (gliding); affects `--wav`/`--play` only — + the note sequence is unchanged. +- `docs/ear-test-protocol.md` — blind-listening protocols with stated + better-than-chance bars for the human-discrimination claims. +- `docs/specs/` + `docs/plans/` — the converged devague spec and buildable plan + this increment was built from. + +### Changed + +- Replaced the culture-agent-template self-descriptions (parser description, + `explain` root, `learn`, `overview`, README) with the real harmonics **voice** + mission — the five-axis design-spine table and the voice-vs-soundtrack boundary. + ## [0.4.1] - 2026-07-08 ### Changed diff --git a/README.md b/README.md index b7fdf35..cbcdc67 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,60 @@ # harmonics-cli -An agent + CLI giving agents and robots non-TTS (non-speech) audio: express meaning through pleasant sonic signatures — chimes, flutes, pulses, and tonal motifs mapped to intent, confidence, urgency, state, and identity. Turns text/sentences into notes and audio (text-to-notes / text-to-audio). +**harmonics-cli gives an agent or robot its own non-speech VOICE.** It is the +inverse of text-to-speech: instead of turning words into speech, it renders an +agent's live *meaning* into short, pleasant sonic gestures — chimes, flutes, +pulses, tonal motifs — that a listener recognizes by *who* is speaking and +*what* they mean. It reproduces no words or phonemes; it maps meaning, not the +sound of words (text-to-notes / text-to-audio). -## What you get +A recognizable non-speech voice makes a headless agent or robot present and +legible by ear: with no screen to read and no speech to synthesize or parse, you +can tell who is working, who just succeeded, and who is stuck. + +## The design spine — intent → sound + +Everything the domain renders resolves to five expressive axes: + +| Axis | Example values | Sonic mapping | +|------|---|---| +| **intent** | ack, question, success, failure, thinking, handoff | timbre / motif family (chime vs. flute vs. pulse) | +| **confidence** | low → high | consonance, pitch stability, resolved vs. suspended cadence | +| **urgency** | calm → urgent | tempo, attack sharpness, repetition | +| **state** | idle, working, blocked, done | sustained pad vs. discrete events | +| **identity** | which agent | signature motif / key / instrument per agent | + +Urgency is carried by tempo, attack, and repetition — never by dissonance or raw +loudness — so the palette stays pleasant and non-fatiguing even at the urgent +end, playing repeatedly next to a human without becoming an alarm. + +## Voice, not soundtrack + +harmonics is a **first-person utterance** an agent emits *as itself*, live and in +the moment, driven by its own axes. It is **not** a third-person spectator +soundtrack. The contrast is league-of-agents' replay score, which narrates a +match from the outside off an event log; harmonics is the inverse — the agent's +own voice, not an ambient bed you tune out. And it is **not TTS**: no words, no +phonemes, only meaning. + +## Status + +Scaffold today: the agent-first CLI, mesh identity, skill kit, and CI baseline +are in place; the audio domain is being built. The pure text→notes core is +designed to stay dependency-free and offline-testable — tests assert on note +sequences, not on a speaker. Coming text-to-notes verbs: + +- `harmonics play --intent success --confidence high --urgency low` — render + explicit axes to a note sequence (dry-run by default; `--play`/`--out` produce + sound or a file). +- `harmonics say "done, tests all green"` — infer the axes from a sentence, then + render. + +## What you get today - **An agent-first CLI** cited from [teken](https://github.com/agentculture/teken) (`afi-cli`) — the runtime package has no third-party dependencies. - **A mesh identity** — `culture.yaml` (`suffix` + `backend`) and the matching - resident prompt file (`AGENTS.colleague.md`, since this template runs + resident prompt file (`AGENTS.colleague.md`, since this agent runs `backend: colleague`). - **The canonical guildmaster skill kit** (11 skills) under `.claude/skills/`, vendored cite-don't-import. See [`docs/skill-sources.md`](docs/skill-sources.md). @@ -42,20 +89,11 @@ Every command supports `--json`. Results go to stdout, errors/diagnostics to stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment error, `3+` reserved. -## Make it your own - -1. Rename the package `harmonics/` and the `harmonics-cli` - CLI/dist name throughout `pyproject.toml`, the package, `tests/`, - `sonar-project.properties`, and this `README.md`. The name is hard-coded in - ~100 places, so list every occurrence first — see the `git grep` discovery - command in [`CLAUDE.md`](CLAUDE.md), the authoritative rename procedure. -2. Edit `culture.yaml` with your `suffix` and `backend`. -3. Rewrite `CLAUDE.md` for your agent and run `/init`. -4. Re-vendor only the skills you need from guildmaster (see - [`docs/skill-sources.md`](docs/skill-sources.md)). +## Contributing -See [`CLAUDE.md`](CLAUDE.md) for the full conventions (version-bump-every-PR, -the `cicd` PR lane, deploy setup). +See [`CLAUDE.md`](CLAUDE.md) for the full conventions — the design-spine mapping, +the offline-testable text→notes rule, dry-run-by-default for audio verbs, +version-bump-every-PR, the `cicd` PR lane, and deploy setup. ## License diff --git a/docs/ear-test-protocol.md b/docs/ear-test-protocol.md new file mode 100644 index 0000000..c31a647 --- /dev/null +++ b/docs/ear-test-protocol.md @@ -0,0 +1,190 @@ +# Ear-test protocol + +harmonics makes human-discrimination claims that no offline note-sequence +assertion can settle on its own — "a listener can tell two agents apart", "a +success sounds confident and a question sounds unsure". The offline harness in +`tests/ear_harness.py` proves the *notes* are well-formed and deterministic; +it cannot prove a human ear reads them the way the design spine intends. This +document is the other half: a blind-listening protocol, run by a human +facilitator with real listeners, for each human-discrimination claim the +project makes. Per the honesty-conditions rule in `CLAUDE.md`, these claims +are measured, not asserted — this file is the measurement instrument. + +## Scope + +Four discriminations are covered, one per section below: + +1. [Agent-vs-agent](#1-agent-vs-agent-identity-discrimination) — listeners + tell two different agents apart by their signatures. +2. [Success-vs-question](#2-success-vs-question-intentconfidence-discrimination) + — listeners tell a confident success from an uncertain question. +3. [Tune→phrase matching](#3-tunephrase-matching) — listeners match a + rendered `say` tune back to its source sentence from a candidate set. +4. [Stressed-part identification](#4-stressed-part-identification) — + listeners identify which part of an utterance was stressed. + +Each section states: the task, the stimuli, the listener panel size (`N`), +the candidate-set size (`k`) where relevant, the chance level, the trial +count, and an explicit numeric pass bar stated as "well above chance". Every +pass bar in this document was chosen with a wide margin over the level needed +for bare statistical significance (a one-tailed exact binomial test against +the chance level, α = 0.05) — see [Statistical grounding](#statistical-grounding) +for the numbers. This protocol targets the audio verbs in the build brief +(`harmonics play`, `harmonics say`, the per-agent signature palette); it is +written now, ahead of those verbs landing, so the bar is fixed before the +implementation exists to be graded against it. + +## General setup (applies to all four protocols) + +- **Equipment**: closed-back or in-ear headphones in a quiet room (no + open-plan office noise floor). All clips normalized to the same peak + loudness so volume alone is never a cue. +- **Listener panel**: no musical training required — the palette is meant to + read to *any* agent operator, not just trained ears. Screen only for + self-reported normal hearing. +- **Blinding**: listeners never see axis labels, agent names, or file names + during a trial — clips are presented as anonymized "Clip A" / "Clip B" / + "Clip X" or numbered candidates. The facilitator holds the answer key + separately from the listener-facing materials. +- **Session length**: sessions are capped at roughly 15 minutes of active + listening per protocol per listener, with trial order randomized + per-listener to cancel ordering/fatigue effects. +- **Scoring**: every trial is scored as correct/incorrect against the + answer key; no partial credit. Aggregate accuracy is percent correct across + *all* trials from *all* listeners pooled (not averaged per-listener), and + is the number compared against the pass bar. +- **Analysis**: report aggregate accuracy plus the exact one-tailed binomial + p-value against the chance level for that protocol. A pass requires both + the accuracy to clear the numeric bar below *and* p < 0.05 — in practice + every bar below already implies p ≪ 0.05 at the specified trial count (see + [Statistical grounding](#statistical-grounding)), so the accuracy bar is + the binding constraint. +- **Reporting**: each run is logged with date, panel size, per-trial raw + scores (not just the aggregate), and the stimuli set used, so a run can be + reproduced or audited later. + +## 1. Agent-vs-agent identity discrimination + +**Claim under test**: two agents' signature motifs (per the *identity* axis +in the design spine) are distinguishable by ear. + +- **Task**: ABX. The listener hears clip **A** (agent X's signature), clip + **B** (agent Y's signature), then clip **X**, which is a fresh render of + either agent's signature. The listener answers "X matches A" or "X matches + B". +- **Candidate-set size**: `k = 2` (two agents per ABX round; the identity + claim is inherently pairwise). +- **Listeners**: `N = 20`. +- **Trials**: 10 ABX rounds per listener against one agent pair, repeated + across at least 3 distinct agent pairs drawn from the signature palette (so + a pass isn't an artifact of one unusually-distinctive pair). Which clip is + presented as "X" is balanced 50/50 across trials. +- **Chance level**: 50% (binary forced choice). +- **Pass bar**: **≥75% correct** aggregate across all trials (150 of 200 + trials at `N = 20` × 10 trials), well above the 50% chance line. + +## 2. Success-vs-question (intent/confidence) discrimination + +**Claim under test**: a high-confidence `success` render and a low-confidence +`question` render — opposite ends of the *intent* and *confidence* axes — are +told apart reliably, without the listener seeing any label. + +- **Task**: two-alternative forced choice (2AFC). The listener hears one + unlabeled clip, rendered from either `intent=success, confidence=high` or + `intent=question, confidence=low`, and answers "confident success" or + "uncertain question". +- **Candidate-set size**: `k = 2` (the two response categories). +- **Listeners**: `N = 20`. +- **Trials**: 20 trials per listener (10 of each category in randomized + order, no more than 2 of the same category back-to-back). +- **Chance level**: 50% (binary forced choice). +- **Pass bar**: **≥80% correct** aggregate across all trials (320 of 400 + trials at `N = 20` × 20 trials), well above the 50% chance line. The bar is + set higher than the identity test (§1) deliberately: success-vs-question + sits at opposite ends of two axes at once, so if listeners can't clear a + high bar here, the confidence/urgency mapping itself — not just a + borderline pair — has failed. + +## 3. Tune→phrase matching + +**Claim under test**: a tune rendered by `harmonics say ""` still +carries enough of the source sentence's meaning that a listener can match it +back to the sentence that produced it, out of a small set of plausible +candidates. + +- **Task**: match-to-sample. The listener hears one rendered tune, then reads + `k` candidate sentences (1 true source + `k - 1` distractors). Distractors + are drawn to differ from the source on at least one axis (different + intent, confidence, or urgency) rather than being near-synonyms, so the + test measures axis discrimination, not lexical guessing. The listener picks + the candidate they believe produced the tune. +- **Candidate-set size**: `k = 4` (chance = 1/4 = 25%). +- **Listeners**: `N = 20`. +- **Trials**: 15 trials per listener, each with a fresh tune and a freshly + drawn candidate set of size `k = 4`. +- **Chance level**: 1/k = 25%. +- **Pass bar**: **≥70% correct** aggregate across all trials (210 of 300 + trials at `N = 20` × 15 trials), well above the 25% chance line. +- **Extended (optional) variant**: the same task at `k = 8` (chance = + 12.5%), pass bar **≥50% correct**, still well above chance, run only after + the `k = 4` variant passes — a stress test of how far the matching signal + degrades as the candidate set grows. + +## 4. Stressed-part identification + +**Claim under test**: within a single multi-segment utterance, the segment +the renderer marked as emphasized (elevated confidence/urgency contour on +that segment, per the design spine's sonic mapping) is the segment listeners +perceive as stressed. + +- **Task**: forced choice among segments. The listener hears one rendered + utterance built from exactly 4 word/chunk segments, with one segment + rendered as stressed, and picks which of the 4 segments (by position: 1st, + 2nd, 3rd, or 4th) sounded stressed. +- **Candidate-set size**: `k = 4` (fixed at 4 segments per test utterance so + every trial has the same chance level). +- **Listeners**: `N = 20`. +- **Trials**: 15 trials per listener; the stressed segment's position is + rotated evenly across the 4 positions (matching the trial count as closely + as integer division allows) so no position is over- or under-represented + in the answer key. +- **Chance level**: 1/k = 25%. +- **Pass bar**: **≥65% correct** aggregate across all trials (195 of 300 + trials at `N = 20` × 15 trials), well above the 25% chance line. The bar + sits below the tune-matching bar (§3) because within-utterance stress is a + finer-grained discrimination than whole-tune matching. + +## Statistical grounding + +Every pass bar above was chosen to clear bare statistical significance by a +wide margin. For a one-tailed exact binomial test against the stated chance +level at the stated trial count, the number of correct trials needed for +p < 0.05 is far below the pass bar in every case: + +| Protocol | Trials (`n`) | Chance | Bar needed for p < 0.05 | Chosen pass bar | Exact p at pass bar | +|---|---|---|---|---|---| +| Agent-vs-agent (§1) | 200 | 50% | ≥113 (56.5%) | ≥150 (75%) | ≈ 4.2 × 10⁻¹³ | +| Success-vs-question (§2) | 400 | 50% | ≥217 (54.2%) | ≥320 (80%) | ≈ 2.2 × 10⁻³⁵ | +| Tune→phrase matching (§3) | 300 | 25% | ≥88 (29.3%) | ≥210 (70%) | ≈ 4.8 × 10⁻⁶⁰ | +| Stressed-part ID (§4) | 300 | 25% | ≥88 (29.3%) | ≥195 (65%) | ≈ 4.0 × 10⁻⁴⁸ | + +In other words, a pass at any of the bars above is not a marginal +statistical squeak-by — it is a wide, decisive margin over chance, matching +the "well above chance" framing used throughout this document (e.g. "≥70% +correct with k=4, well above the 25% chance line" for §3). + +## Relationship to the offline harness + +This protocol is deliberately **human-in-the-loop and manual** — it is not +run by `pytest` and does not gate CI. It complements, and does not replace, +`tests/ear_harness.py`: + +- the offline harness proves a rendered `list[NoteEvent]` is well-formed and + that rendering is deterministic, with no audio device and no third-party + audio import in the test path; +- this protocol proves that when those well-formed notes actually become + sound, a human ear reads the intended meaning back out of them. + +A claim in the honesty conditions is only considered measured, not merely +asserted, once both halves — the offline assertion and a logged run of the +matching protocol above — exist. diff --git a/docs/plans/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md b/docs/plans/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md new file mode 100644 index 0000000..73a2428 --- /dev/null +++ b/docs/plans/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md @@ -0,0 +1,105 @@ +# Build Plan — harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean — the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second + +slug: `harmonics-gives-an-agent-or-robot-its-own-non-spee` · status: `exported` · from frame: `harmonics-gives-an-agent-or-robot-its-own-non-spee` + +> harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean — the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second. + +## Tasks + +### t1 — Note-event core (harmonics/notes.py): the NoteEvent record + note-sequence container + +- acceptance: + - A NoteEvent carries (start, duration, pitch, velocity, voice) and round-trips to/from a plain dict/JSON without loss + - A note sequence serialises to a stable JSON list and to a MIDI-like note representation; importing harmonics.notes pulls in zero third-party packages + +### t2 — Axes->gesture mapping table (harmonics/mapping.py): the design spine intent/confidence/urgency/state -> sonic parameters + +- depends on: t1 +- covers: c11, h4 +- acceptance: + - Each intent maps to a documented motif family and confidence/urgency/state to documented note-parameter changes, all in one table asserted by tests + - Every rendered gesture stays within a documented consonant scale and a bounded velocity/attack ceiling; the urgent-vs-calm delta appears ONLY as inter-onset/tempo/repetition (a test diffs an urgent vs calm sequence and finds no added dissonance or raised level) + +### t3 — Identity voice-print (harmonics/identity.py): identity string -> (key, timbre, motif-seed) + palette override + +- depends on: t1 +- covers: c9, h3 +- acceptance: + - The same identity string always yields the same (key, voice/timbre, motif-seed); two distinct identities yield audibly-distinct signatures, asserted by tests + - A palette override file deterministically replaces the derived signature for a named agent + +### t4 — Deterministic micro-variation (harmonics/variation.py): --seq nonce -> reproducible gesture variety + +- depends on: t1 +- covers: c13, h7 +- acceptance: + - For fixed axes+identity, two --seq values give different-but-valid sequences and the same --seq reproduces identically; the no-seq default is a fixed documented value + - Variation is a pure function of the seq value only — no wall-clock, no entropy — verified by a determinism test + +### t5 — Sentence->axes inference (harmonics/inference.py): offline cue/keyword rule table + +- covers: h6 +- acceptance: + - A documented cue/keyword table maps a sentence to the five axes deterministically; the same sentence always yields the same axes + - Inference imports no model and makes no network call — asserted on the import graph / no socket use + +### t6 — Text->melodic contour (harmonics/contour.py): sentence units -> pitch/rhythm steps so a human can follow sound<->text + +- depends on: t1, t3 +- covers: c14, h8 +- acceptance: + - A documented, deterministic mapping turns a sentence's units into pitch/rhythm steps over the agent's key; same text -> identical contour + - No phonemes are synthesised (still non-speech); the contour is a structurally-asserted note sequence + +### t7 — Expressive stress (harmonics/stress.py): emphasis markers -> pitch+volume emphasis, bounded by the level ceiling + +- depends on: t2, t6 +- covers: c15, h9 +- acceptance: + - Marking a segment (emphasis marker or --stress) measurably raises that segment's note pitch and/or velocity vs the neutral baseline, deterministically; no markers -> the documented neutral baseline + - Stress-boosted velocity never exceeds the palette level ceiling defined in the mapping (t2) + +### t8 — 'play' verb (harmonics/cli/_commands/play.py): explicit axes -> notes, dry-run by default + +- depends on: t2, t3, t4 +- covers: c8, h5 +- acceptance: + - 'harmonics play --intent success --as X' prints a note sequence to stdout by default (dry-run), supports --json, and makes NO sound without --play/--out + - Same --intent/--confidence/--urgency/--state/--as/--seq -> identical note sequence; the whole path runs in tests with no audio device and no third-party import + +### t9 — 'say' verb (harmonics/cli/_commands/say.py): sentence -> inferred axes + text contour + stress -> notes + +- depends on: t5, t6, t7, t8 +- covers: c12 +- acceptance: + - 'harmonics say "done, tests all green"' infers axes (t5), builds the text contour (t6), applies stress (t7), and renders via the mapping/play path (t2/t8); dry-run by default with --play/--out/--midi + - say reuses play's render path once axes are inferred; its output is a note sequence asserted in tests with no device + +### t10 — Replace placeholder self-descriptions + write the mission/mapping/boundary docs + +- covers: c1, c2, h10, c4, h12, c5, c6, h14 +- acceptance: + - The 'clonable template' self-descriptions in _build_parser, learn, the explain catalog, and overview are replaced with the harmonics VOICE mission; grep finds no template self-description + - CLAUDE.md/README document the five-axis mapping table and the voice-vs-soundtrack boundary (no phonemes; driven by the agent's own axes, not an external event log), naming the league-of-agents contrast + +### t11 — Ear-test protocol + offline note-sequence test harness + +- depends on: t1 +- covers: h1, c7, h15, h13, c16, h16 +- acceptance: + - docs/ear-test-protocol.md defines blind-listening protocols (agent-vs-agent, success-vs-question, tune->phrase matching, stressed-part id), each with a stated better-than-chance bar + - A test helper asserts on emitted note sequences with NO audio device; the suite runs offline under pytest + +### t12 — Offline audio backend + end-to-end render-to-sound/file (harmonics/audio/) + +- depends on: t8, t9 +- covers: c3, h11 +- acceptance: + - A pure-Python offline backend renders a note sequence to WAV bytes deterministically; --out writes a file and --play uses an ISOLATED optional import the core never requires + - One command takes axes (or a sentence) end-to-end to audio, while importing the core still needs no audio device + +## Risks + +- [unknown_nonblocking] Audio backend choice (pure-Python sine/FM vs sounddevice/simpleaudio/soundfont) is unsettled; the dependency-free core is unaffected. (task t12) +- [unknown_nonblocking] Text->contour granularity (per-letter vs per-syllable vs per-word) is unsettled; the legibility requirement is fixed, the exact unit is TBD in build. (task t6) +- [unknown_nonblocking] Final 'pleasantness' needs a human ear; bounded palette/velocity/tempo proxies reduce but do not eliminate subjectivity. (task t2) diff --git a/docs/specs/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md b/docs/specs/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md new file mode 100644 index 0000000..c994a6e --- /dev/null +++ b/docs/specs/2026-07-08-harmonics-gives-an-agent-or-robot-its-own-non-spee.md @@ -0,0 +1,67 @@ +# harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean — the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second + +> harmonics gives an agent or robot its own non-speech VOICE: it turns the agent's live intent, confidence, urgency, state, and identity into short, pleasant sonic gestures a listener recognizes by who is speaking and what they mean — the first-person inverse of a spectator soundtrack, rendered as a note sequence first and sound second. + +## Audience + +- AI agents and physical robots that must express themselves without a screen or speech, plus the humans and peer agents nearby who listen and need to know — by ear — who is present, what they intend, and how it's going. + +## Before → After + +- Before: Today a headless agent is silent, or borrows a third-person spectator soundtrack (like league-of-agents' replay score) that narrates a match from the outside off an event log; there is no first-person non-speech channel an agent emits as itself, live, in the moment. +- After: An agent can render its current meaning (intent/confidence/urgency/state/identity) into a short, pleasant note sequence — and thence audio — that a listener recognizes as *that agent* saying *that thing*, produced offline, deterministically, with a dependency-free core. + +## Why it matters + +- A recognizable non-speech voice makes a headless agent or robot present and legible by ear — you can tell who is working, who just succeeded, who is stuck — with no screen to read and no speech to synthesize or parse. + +## Requirements + +- A pure text->notes core: `harmonics play --intent [--confidence ] [--urgency ] [--state ] [--as ]` maps the axes to a documented note-event sequence. Dry-run by default (emits the notes); making sound or a file needs an explicit --play/--out. The core imports no audio stack and runs with no device. + - honesty: Same axes + same identity + same nonce (--seq) produce an identical note sequence, and the micro-variation across nonces is itself a pure function of the nonce — no wall-clock, no unseeded randomness; the whole text->notes path runs in tests with no third-party import and no audio device present. +- Each agent carries a recognizable signature (its 'voice print' — key / instrument-timbre / motif seed) derived deterministically from its identity string, generalizing league's per-team register into per-agent identity, with an optional hand-authored override in the palette. + - honesty: The same identity always renders the same signature; two distinct identities render audibly distinct, stable signatures; a hand-authored palette override deterministically replaces the derived one. +- The palette stays pleasant and non-fatiguing even at the urgent end: urgency is carried by tempo, attack sharpness, and repetition — never by dissonance or raw loudness — so an urgent voice is attention-grabbing but never an alarm-clock, and repeated play next to a human does not fatigue. + - honesty: 'Non-fatiguing' and 'non-alarm' are checkable on the note sequence: every gesture stays within a documented consonant palette and bounded velocity/attack, and the urgent-vs-calm difference shows up only as bounded inter-onset/tempo/repetition — never as added dissonance or a raised level ceiling. +- `harmonics say ""` parses a sentence into the five axes and then renders it — the text->notes surface. It ships in the same increment as 'play', reusing 'play's mapping once axes are inferred. Dry-run by default like every producing verb. + - honesty: Sentence->axes inference is deterministic and offline: a documented cue/keyword->axis rule table (not a network or model call) maps a sentence to the axes; the same sentence always yields the same axes; the test path makes no network call and imports no model. +- A caller-supplied nonce ('--seq', with a stable default) selects among deterministic micro-variations of the same gesture, so repeated utterances feel alive rather than robotic while staying fully reproducible (generalizing league's field-hashed variety). + - honesty: Two different nonces yield audibly-but-subtly different utterances of the same intent; the same nonce always yields the same one; the default (no --seq) is itself a fixed, documented value — variation is never a function of wall-clock or entropy. +- The 'say' tune is derived from the sentence's own units (letters / syllables / words -> pitch, timbre, rhythm steps), not only from inferred axes, so a human can APPROXIMATELY connect the sound back to the text — following 'what is being said' by how it is voiced, the way you follow a hummed phrase. Still non-speech: no phonemes are reproduced. + - honesty: The text->contour mapping is documented and deterministic (same text -> same contour); in a small blind ear-test, listeners match a rendered tune back to its source phrase (from a short candidate set) better than chance. +- The agent/robot can STRESS parts of the tune via pitch and volume to emote — raising pitch and/or loudness on marked segments, emphasis carried explicitly (emphasis markers in the text or a stress argument), layered over the intent/confidence/urgency axes. Stress modulation stays WITHIN the palette's documented non-fatiguing level ceiling (the pleasantness requirement above), so emphasis never becomes an alarm. + - honesty: Stressing a segment measurably raises that segment's note pitch and/or velocity in the emitted sequence relative to the neutral baseline, deterministically and within the level ceiling; with no stress markers the render is the documented neutral baseline. + +## Honesty conditions + +- In a blind ear test the output reads as one agent's discrete first-person utterance — not an ambient bed narrating events — and two identities rendering the same axes are recognizably different voices. +- The audience is real and reachable: a concrete consumer exists on each side — a headless/robot agent that emits, and a human or peer agent that listens and acts on what it hears. +- The after-state is demonstrable end to end: one command takes axes (or a sentence) and yields a note sequence, and an audio backend can render that sequence — with the core path needing no audio device. +- The gap is real: no existing surface gives an agent a first-person non-speech voice; league's soundtrack is third-person and event-log-driven, per its own module docs. +- The benefit is observable: from sound alone, with no screen, a listener identifies who is acting and the broad state (working/succeeded/blocked) above chance. +- The boundary is enforceable: the surface reproduces no phonemes or words, and its output is driven by the agent's own axes/sentence — never by an external match or event log. +- The signal is measured, not asserted: tests assert on emitted note sequences with no device, and a documented ear-test protocol backs the two human-discrimination claims (agent-vs-agent, success-vs-question). +- The legibility/stress signal is measured: a documented ear-test protocol exists — tune->phrase matching from a candidate set and stressed-part identification — with a stated better-than-chance bar. + +## Success signals + +- Given axes plus an agent identity, harmonics emits a deterministic note sequence (dry-run, no audio device) that unit tests assert on directly; and by ear a listener can tell two different agents apart, and tell a confident success from an uncertain question. +- By ear a listener can approximately connect a rendered 'say' tune to its source sentence, and can tell which parts were stressed — the voice reads as 'this text, emoted', not an arbitrary motif. + +## Scope / boundaries + +- Not TTS (no words or phonemes); not a spectator soundtrack, match score, or event-log narration; not an ambient bed you tune out. It is a first-person utterance you attend to, driven by the agent's own axes rather than an external timeline. + +## Decisions + +- A rendered gesture is a sequence of note events, each a small record (start, duration, pitch, velocity, voice/timbre) — the machine-readable 'notes' of text-to-notes. This sequence is the unit-test surface and the MIDI/robot-consumable representation; audio backends render FROM it, never instead of it. + +## Hard questions + +- Does the first increment ship only 'play' (explicit axes -> notes), with 'say' (sentence -> inferred axes) as a follow-up? Or must sentence inference land in the same increment? [RESOLVED: both 'play' (explicit axes) and 'say' (sentence inference) ship in increment 1 — see the 'say' requirement.] +- risk: Even with bounded palette/velocity/tempo proxies, final 'pleasantness' needs a human ear; the checkable proxies reduce but do not eliminate subjectivity. + +## Open questions (non-blocking — settle in the plan) + +- Audio backend for actual sound (pure-Python sine/FM offline default vs sounddevice / simpleaudio / soundfont). The core stays dependency-free and offline-testable, so this does not block the spec. +- Granularity of the text->contour unit (per-letter vs per-syllable vs per-word, and how letters color pitch/timbre) — the legibility requirement is fixed; the exact unit is an implementation choice. diff --git a/harmonics/audio/__init__.py b/harmonics/audio/__init__.py new file mode 100644 index 0000000..ad2abd6 --- /dev/null +++ b/harmonics/audio/__init__.py @@ -0,0 +1,16 @@ +"""``harmonics.audio`` — the offline audio backend (see :mod:`harmonics.audio.synth`). + +Re-exports :func:`render_wav`/:func:`write_wav`/:func:`play` so callers (the +``play``/``say`` CLI verbs) can write ``from harmonics.audio import play`` / +``harmonics.audio.play(seq)`` without reaching into the ``synth`` submodule +directly. Importing this package pulls in only the standard library — see +``harmonics/audio/synth.py``'s module docstring for the isolation rule that +guarantees that (the optional playback library only loads lazily, inside +:func:`play` itself, at call time). +""" + +from __future__ import annotations + +from harmonics.audio.synth import DEFAULT_SAMPLE_RATE, play, render_wav, write_wav + +__all__ = ["render_wav", "write_wav", "play", "DEFAULT_SAMPLE_RATE"] diff --git a/harmonics/audio/synth.py b/harmonics/audio/synth.py new file mode 100644 index 0000000..2608092 --- /dev/null +++ b/harmonics/audio/synth.py @@ -0,0 +1,508 @@ +"""The offline audio backend — a PURE-STDLIB per-note additive-sine synth. + +This is the one place in ``harmonics`` where a :class:`~harmonics.notes. +NoteEvent` sequence becomes actual sound: :func:`render_wav` mixes a note +sequence into a mono 16-bit PCM WAV byte string, :func:`write_wav` saves that +to a file, and :func:`play` renders and then plays it through a live device. + +Two hard rules from ``CLAUDE.md``'s design spine: + +* the pure text->notes CORE must stay dependency-free and importable with + **no audio device** — so :func:`render_wav`/:func:`write_wav` (and this + module's own import) use only the standard library (``wave``, ``math``, + ``array``, ``io``, ``sys``). No third-party import happens at module + import time. +* audio-PRODUCING actions require an explicit flag; the only function here + that touches a real device, :func:`play`, isolates its optional playback + library behind a **lazy, in-function** import (tried in order: + ``simpleaudio``, then ``sounddevice``) so importing :mod:`harmonics.audio` + itself never requires a sound stack. If neither library is importable, + :func:`play` raises the same structured :class:`~harmonics.cli._errors. + CliError` every other verb failure uses, rather than a bare + ``ImportError`` or a silent no-op. + +Articulation — how the voice MOVES between notes +-------------------------------------------------- +Every renderer here takes an ``articulation`` name (:data:`ARTICULATIONS` +lists the choices) that selects *how* the same note sequence is turned into +sound. The notes never change — only the synthesis does: + +* ``"discrete"`` — the original per-note synth: each :class:`NoteEvent` + becomes its own short additive tone (see :func:`_render_discrete`), with + silence possible between notes. This is the default *of this module's + functions* (unchanged, byte-identical to every prior release) — a "music + box". +* ``"speechy"``, ``"smooth"``, ``"alien"`` — a single continuous, + phase-integrated oscillator that glides between consecutive note pitches + (legato + portamento; see :func:`_render_glide`), never falling silent + mid-phrase. The three styles share one algorithm and differ only in how + much of each inter-note interval is spent gliding and how much vibrato is + applied — see :data:`ARTICULATIONS` for the exact numbers. This reads as + speech, or an alien voice, rather than a sequence of separate notes. + +Discrete synthesis approach +----------------------------- +Each :class:`~harmonics.notes.NoteEvent` becomes a short additive tone: its +MIDI ``pitch`` maps to a frequency (``440 * 2**((pitch-69)/12)``), a small +table of harmonic partials (:data:`_VOICE_PARTIALS`, keyed by ``voice`` so +the six identity timbres — chime/flute/pulse/bell/pluck/glass — sound +distinct) is summed at that frequency and its overtones, shaped by a short +linear attack/release envelope (:func:`_note_envelope`) so the note never +clicks in or out, and scaled by the note's ``velocity``. Notes are placed at +their own ``start``/``duration`` (seconds, relative to the gesture's own +onset — see ``harmonics/notes.py``) into one running mix buffer, which is +then soft-limited (a gentle ``tanh`` knee above 0.9 amplitude, matching the +approach in ``league/replay/audio.py``) and quantized to 16-bit PCM. + +Glide synthesis approach +-------------------------- +:func:`_render_glide` walks the mix sample-by-sample: it holds each note's +pitch, then glides (a smoothstep ramp) to the next note's pitch over the +final ``glide_frac`` share of the inter-onset interval, integrating phase +continuously so the oscillator never resets or clicks. A small vibrato and a +voice-ish falling-harmonic partial set (:data:`_GLIDE_PARTIALS`) give it an +organic, vocal quality; a short global attack and a release tail +(``tail`` seconds after the last note ends) bookend the whole phrase. This +was prototyped and ear-approved before being ported here; the math is +unchanged from the prototype. + +Nothing in this module is seeded/random or depends on wall-clock time, so +the same inputs always render to the same floats and therefore the same +bytes: **same (seq, sample_rate, articulation) -> byte-identical WAV.** +""" + +from __future__ import annotations + +import io +import math +import sys +import wave +from array import array +from pathlib import Path +from typing import Sequence + +from harmonics.cli._errors import EXIT_ENV_ERROR, CliError +from harmonics.notes import NoteEvent + +#: Output format: mono, 16-bit PCM — matches the stdlib ``wave`` module's +#: simplest, most portable write path and every reference WAV in this repo. +CHANNELS = 1 +SAMPLE_WIDTH = 2 # bytes -> 16-bit PCM +DEFAULT_SAMPLE_RATE = 44100 + +_TWO_PI = 2.0 * math.pi + +# --- discrete articulation (the original per-note synth) --------------------- + +#: Attack/release shape shared by every note (seconds). Short enough to be +#: inaudible as its own event, long enough that no note starts or ends with +#: a hard discontinuity (a "click"). +_ATTACK_SECONDS = 0.008 +_RELEASE_SECONDS = 0.04 + +#: Per-voice harmonic partials as ``(ratio, amplitude)`` pairs, the note's +#: fundamental frequency times ``ratio`` at relative ``amplitude``. Mirrors +#: :data:`harmonics.identity.INSTRUMENTS` (chime/flute/pulse/bell/pluck/ +#: glass) — every entry stays gentle/non-fatiguing per the design spine +#: ("pleasant and non-fatiguing... it plays repeatedly next to a human"). +_VOICE_PARTIALS: dict[str, tuple[tuple[float, float], ...]] = { + "chime": ((1.0, 1.0), (2.0, 0.3), (3.0, 0.12)), + "flute": ((1.0, 1.0), (2.0, 0.08)), + "pulse": ((1.0, 1.0), (3.0, 0.25), (5.0, 0.1)), + # Slightly detuned upper partials (2.01/3.02, not 2.0/3.0) for a soft + # bell-like near-harmonic shimmer, echoing the bell voice in + # league/replay/audio.py. + "bell": ((1.0, 1.0), (2.01, 0.3), (3.02, 0.12)), + "pluck": ((1.0, 1.0), (2.0, 0.2)), + "glass": ((1.0, 1.0), (2.0, 0.15), (4.0, 0.08)), +} +#: Fallback partials for a ``voice`` name outside the known instrument +#: palette (``NoteEvent.voice`` is a free-form string) — a plain sine plus a +#: quiet octave, so an unrecognized voice still renders a pleasant tone. +_DEFAULT_PARTIALS: tuple[tuple[float, float], ...] = ((1.0, 1.0), (2.0, 0.2)) + + +def _midi_hz(pitch: float) -> float: + """MIDI note number -> frequency in Hz (A4 = MIDI 69 = 440 Hz).""" + return 440.0 * 2.0 ** ((pitch - 69) / 12) + + +def _note_envelope(num_samples: int, sample_rate: int) -> list[float]: + """A linear attack/sustain/release envelope, ``num_samples`` long. + + Ramps ``0 -> 1`` over :data:`_ATTACK_SECONDS`, holds ``1`` for whatever + remains, then ramps ``1 -> 0`` over :data:`_RELEASE_SECONDS` — clamped so + attack+release never exceeds the note's own length (a very short note + gets a proportionally short, but still click-free, ramp up and down). + """ + if num_samples <= 0: + return [] + attack_n = min(num_samples, max(1, round(_ATTACK_SECONDS * sample_rate))) + remaining = num_samples - attack_n + release_n = min(remaining, max(1, round(_RELEASE_SECONDS * sample_rate))) if remaining else 0 + sustain_n = num_samples - attack_n - release_n + + env: list[float] = [] + for k in range(attack_n): + env.append((k + 1) / attack_n) + env.extend([1.0] * sustain_n) + for k in range(release_n): + env.append(1.0 - (k + 1) / release_n) + return env + + +def _mix_note( + mix: array, ev: NoteEvent, start_sample: int, num_samples: int, sample_rate: int +) -> None: + """Additively synthesize one note's partials into ``mix``, in place.""" + if num_samples <= 0: + return + freq = _midi_hz(ev.pitch) + partials = _VOICE_PARTIALS.get(ev.voice, _DEFAULT_PARTIALS) + total_amp = sum(amp for _, amp in partials) + env = _note_envelope(num_samples, sample_rate) + sin = math.sin + for ratio, amp in partials: + w = _TWO_PI * freq * ratio / sample_rate + gain = ev.velocity * (amp / total_amp) + for k in range(num_samples): + mix[start_sample + k] += gain * env[k] * sin(w * k) + + +def _quantize(mix: array) -> bytes: + """Soft-limit (a gentle ``tanh`` knee above 0.9) and convert to 16-bit + little-endian PCM bytes. Used by the ``discrete`` articulation only.""" + n = len(mix) + out = array("h", bytes(2 * n)) + tanh = math.tanh + knee = 0.9 + for i in range(n): + v = mix[i] + if v > knee: + v = knee + (1.0 - knee) * tanh((v - knee) * 5.0) + elif v < -knee: + v = -knee - (1.0 - knee) * tanh((-knee - v) * 5.0) + out[i] = int(max(-1.0, min(1.0, v)) * 32767) + if sys.byteorder == "big": # pragma: no cover - WAV PCM is little-endian + out.byteswap() + return out.tobytes() + + +def _wav_bytes(pcm: bytes, sample_rate: int) -> bytes: + """Wrap already-quantized 16-bit PCM bytes in a mono WAV container.""" + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(SAMPLE_WIDTH) + wf.setframerate(sample_rate) + wf.writeframes(pcm) + return buf.getvalue() + + +def _render_discrete(seq: Sequence[NoteEvent], sample_rate: int) -> bytes: + """The original per-note synth: unchanged, byte-identical to every prior + release of this module. Deterministic: the same ``seq``/``sample_rate`` + always renders to byte-identical output. An empty sequence renders a + valid, zero-length WAV rather than raising.""" + placements: list[tuple[NoteEvent, int, int]] = [] + total_samples = 0 + for ev in seq: + start_sample = round(ev.start * sample_rate) + num_samples = round(ev.duration * sample_rate) + placements.append((ev, start_sample, num_samples)) + total_samples = max(total_samples, start_sample + num_samples) + + mix = array("d", bytes(8 * total_samples)) + for ev, start_sample, num_samples in placements: + _mix_note(mix, ev, start_sample, num_samples, sample_rate) + + return _wav_bytes(_quantize(mix), sample_rate) + + +# --- glide articulations (speechy / smooth / alien) --------------------------- + +#: A voice-ish timbre: fundamental + falling harmonics (soft saw ~ vocal / +#: reedy). Shared by every glide style — only the glide/vibrato shape below +#: differs between them. Ported verbatim from the approved glide-lab +#: prototype (``_VOICE``). +_GLIDE_PARTIALS: tuple[tuple[float, float], ...] = ( + (1.0, 1.0), + (2.0, 0.5), + (3.0, 0.33), + (4.0, 0.22), + (5.0, 0.13), + (6.0, 0.08), +) + +#: Release tail (seconds) after the last note ends, shared by every glide +#: style — ported verbatim from the prototype. +_GLIDE_TAIL = 0.28 + +#: Legato floor, shared by every glide style — ported verbatim from the +#: prototype's amplitude-envelope math (see :func:`_render_glide`). +_GLIDE_LEGATO_FLOOR = 0.55 + +#: The four selectable articulation styles. ``"discrete"`` (``None``) is the +#: original non-glide, per-note synth (see :func:`_render_discrete`); each +#: other entry is the ``glide_frac``/``vibrato_hz``/``vibrato_cents`` triple +#: fed to :func:`_render_glide` (``tail``/``legato_floor``/``partials`` are +#: shared — see :data:`_GLIDE_TAIL`/:data:`_GLIDE_LEGATO_FLOOR`/ +#: :data:`_GLIDE_PARTIALS` above). Ordered gentlest -> most alien. +ARTICULATIONS: dict[str, dict[str, float] | None] = { + "discrete": None, + "speechy": {"glide_frac": 0.50, "vibrato_cents": 14.0, "vibrato_hz": 5.0}, + "smooth": {"glide_frac": 0.72, "vibrato_cents": 20.0, "vibrato_hz": 5.5}, + "alien": {"glide_frac": 0.92, "vibrato_cents": 28.0, "vibrato_hz": 6.0}, +} + + +def _smoothstep(x: float) -> float: + """Cubic Hermite smoothstep (``3x^2 - 2x^3``), clamped to ``[0, 1]``. + + Clamping ``x`` up front (rather than early-returning the endpoints) keeps + the result identical for every input while giving the analyzer a single, + unconditional expression to reason about. + """ + x = min(1.0, max(0.0, x)) + return x * x * (3 - 2 * x) + + +def _glide_quantize(mix: array) -> bytes: + """Peak-normalize (to 0.82 headroom) then soft-limit (a ``tanh`` knee at + 0.9, steeper than :func:`_quantize`'s) to 16-bit little-endian PCM bytes. + + Differs from :func:`_quantize` (the ``discrete`` path): the glide voice + is one continuous, unbounded oscillator rather than a sum of many + independent, self-limited note envelopes, so it needs its own peak + normalization first. Ported verbatim from the approved glide-lab + prototype's ``_to_wav`` so the glide styles reproduce its sound exactly. + """ + n = len(mix) + out = array("h", bytes(2 * n)) + tanh = math.tanh + peak = 0.0 + for v in mix: + if abs(v) > peak: + peak = abs(v) + norm = 0.82 / peak if peak > 1e-9 else 1.0 + for i in range(n): + v = mix[i] * norm + if v > 0.9: + v = 0.9 + 0.1 * tanh((v - 0.9) * 10) + elif v < -0.9: + v = -0.9 - 0.1 * tanh((-0.9 - v) * 10) + out[i] = int(v * 32767) + if sys.byteorder == "big": # pragma: no cover - WAV PCM is little-endian + out.byteswap() + return out.tobytes() + + +def _glide_pitch_and_amp( + seg: int, + t: float, + onsets: list[float], + pitches: list[float], + vels: list[float], + glide_frac: float, +) -> tuple[float, float]: + """Pitch (MIDI, possibly fractional) and base amplitude at time ``t``. + + Holds note ``seg``, then glides to the next pitch over ``glide_frac`` of + the inter-onset gap, arriving at the next onset; the final note simply + holds. Factored out of :func:`_render_glide`'s sample loop so that loop + stays simple; the arithmetic (and thus the output bytes) is unchanged. + """ + if seg + 1 >= len(onsets): + return pitches[seg], vels[seg] + t0, t1 = onsets[seg], onsets[seg + 1] + interval = max(1e-6, t1 - t0) + glide_time = glide_frac * interval + gstart = t1 - glide_time + if t < gstart: + return pitches[seg], vels[seg] + f = _smoothstep((t - gstart) / max(1e-6, glide_time)) + pitch = pitches[seg] + (pitches[seg + 1] - pitches[seg]) * f + amp_base = vels[seg] + (vels[seg + 1] - vels[seg]) * f + return pitch, amp_base + + +def _render_glide( + seq: Sequence[NoteEvent], + *, + sample_rate: int, + glide_frac: float, + vibrato_hz: float, + vibrato_cents: float, + tail: float = _GLIDE_TAIL, + legato_floor: float = _GLIDE_LEGATO_FLOOR, + partials: tuple[tuple[float, float], ...] = _GLIDE_PARTIALS, +) -> bytes: + """One continuous oscillator gliding through the note pitches. + + ``glide_frac`` is the fraction of each inter-onset interval spent + sliding to the next pitch (the rest of the interval holds); ``0`` would + be stepped, ``1`` would always be gliding. Amplitude stays up between + words (``legato_floor``) so the voice never goes silent mid-phrase; a + light vibrato (``vibrato_hz``/``vibrato_cents``) gives the organic, + speech-or-alien quality. Ported verbatim (same math) from the approved + glide-lab prototype's ``render_glide``, minus its dead no-op + articulation-envelope code. + """ + notes = sorted(seq, key=lambda ev: ev.start) + if not notes: + return _wav_bytes(_glide_quantize(array("d")), sample_rate) + + onsets = [ev.start for ev in notes] + pitches = [float(ev.pitch) for ev in notes] + vels = [float(ev.velocity) for ev in notes] + last_end = notes[-1].start + notes[-1].duration + total = last_end + tail + n_samples = int(total * sample_rate) + + mix = array("d", bytes(8 * n_samples)) + phase = 0.0 + seg = 0 # index of the current note (the pitch we're sitting on / gliding from) + sin = math.sin + for i in range(n_samples): + t = i / sample_rate + # advance current segment + while seg + 1 < len(onsets) and t >= onsets[seg + 1]: + seg += 1 + + # ---- pitch: hold this note, then glide to the next near its onset ---- + pitch, amp_base = _glide_pitch_and_amp(seg, t, onsets, pitches, vels, glide_frac) + + # ---- vibrato + frequency ---- + vib = 2.0 ** ((vibrato_cents / 1200.0) * sin(_TWO_PI * vibrato_hz * t)) + freq = _midi_hz(pitch) * vib + phase += _TWO_PI * freq / sample_rate + + # ---- amplitude: legato body + global attack/release ---- + env = amp_base * (legato_floor + (1 - legato_floor)) # legato: stay up + atk = min(1.0, t / 0.035) # global attack (35ms) + rel = 1.0 if t < last_end else max(0.0, 1.0 - (t - last_end) / tail) + gain = env * atk * rel + + s = 0.0 + for ratio, amp in partials: + s += amp * sin(ratio * phase) + s /= sum(a for _, a in partials) + mix[i] = gain * s + + return _wav_bytes(_glide_quantize(mix), sample_rate) + + +# --- public API ---------------------------------------------------------------- + + +def render_wav( + seq: Sequence[NoteEvent], + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + articulation: str = "discrete", +) -> bytes: + """Render a note sequence to mono 16-bit PCM WAV bytes. + + ``articulation`` selects HOW the notes are synthesized (see + :data:`ARTICULATIONS`); the note sequence itself never changes. + ``articulation="discrete"`` (the default, for backward compatibility) is + byte-identical to every prior release of this function. Deterministic: + the same ``(seq, sample_rate, articulation)`` always renders to + byte-identical output — there is no randomness or wall-clock dependency + anywhere in this module. An empty sequence renders a valid, zero-length + WAV rather than raising. Raises :class:`ValueError` for an unknown + ``articulation``. + """ + if articulation not in ARTICULATIONS: + raise ValueError( + f"unknown articulation {articulation!r}; choose one of: " + + ", ".join(sorted(ARTICULATIONS)) + ) + params = ARTICULATIONS[articulation] + if params is None: # "discrete" — the only non-glide entry + return _render_discrete(seq, sample_rate) + return _render_glide(seq, sample_rate=sample_rate, **params) + + +def write_wav( + seq: Sequence[NoteEvent], + path: str | Path, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + articulation: str = "discrete", +) -> None: + """Render ``seq`` and write the WAV bytes to ``path``. + + No audio device is touched — this only writes a file, so it needs + nothing beyond :func:`render_wav`'s own stdlib dependencies. + """ + data = render_wav(seq, sample_rate=sample_rate, articulation=articulation) + Path(path).write_bytes(data) + + +def play( + seq: Sequence[NoteEvent], + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + articulation: str = "discrete", +) -> None: + """Render ``seq`` and play it through a live playback backend. + + Tries an optional playback library, in order: ``simpleaudio``, then + ``sounddevice`` — both imported LAZILY, right here, so importing + :mod:`harmonics.audio` (or calling :func:`render_wav`/:func:`write_wav`) + never requires either to be installed. If neither is importable, raises + the project's structured :class:`~harmonics.cli._errors.CliError` + (exit code :data:`~harmonics.cli._errors.EXIT_ENV_ERROR`) with a + remediation hint, instead of letting a bare ``ImportError`` (or a + silent no-op) reach the caller. + """ + data = render_wav(seq, sample_rate=sample_rate, articulation=articulation) + + try: + import simpleaudio # type: ignore[import-not-found] + except ImportError: + simpleaudio = None # type: ignore[assignment] + + if simpleaudio is not None: + with wave.open(io.BytesIO(data), "rb") as wf: + frames = wf.readframes(wf.getnframes()) + channels = wf.getnchannels() + sampwidth = wf.getsampwidth() + framerate = wf.getframerate() + play_obj = simpleaudio.WaveObject(frames, channels, sampwidth, framerate).play() + play_obj.wait_done() + return + + try: + import sounddevice # type: ignore[import-not-found] + except ImportError: + sounddevice = None # type: ignore[assignment] + + if sounddevice is not None: + with wave.open(io.BytesIO(data), "rb") as wf: + frames = wf.readframes(wf.getnframes()) + framerate = wf.getframerate() + samples = array("h") + # ``frames`` is always little-endian 16-bit PCM (the WAV format's + # required byte order, guaranteed by :func:`_quantize`/ + # :func:`_glide_quantize` regardless of host endianness). + # ``array.frombytes`` has no notion of byte order of its own — it + # copies the raw bytes and interprets them using the HOST's native + # order. On a little-endian host that's a no-op; on a big-endian + # host it silently misreads every sample. So, mirroring + # ``_quantize``'s own correction, byteswap back on a big-endian host + # to undo that misinterpretation before handing samples to the + # device. + samples.frombytes(frames) + if sys.byteorder == "big": + samples.byteswap() + sounddevice.play(samples, framerate) + sounddevice.wait() + return + + raise CliError( + code=EXIT_ENV_ERROR, + message="no audio playback backend is installed", + remediation="install 'simpleaudio' or 'sounddevice', or use --wav to write a file", + ) diff --git a/harmonics/axes.py b/harmonics/axes.py new file mode 100644 index 0000000..4aded4b --- /dev/null +++ b/harmonics/axes.py @@ -0,0 +1,105 @@ +"""The canonical expressive-axis vocabulary — the shared contract every +harmonics module speaks. + +harmonics is the *inverse of TTS*: it renders an agent's live **meaning** (not +words) into short, pleasant sonic gestures. Five axes carry that meaning, per +the design spine in ``CLAUDE.md`` and the build brief (issue #1): + +* **intent** — what kind of utterance it is (ack, question, success, …). +* **confidence** — how sure the agent is (low → high). +* **urgency** — how much it wants your attention (calm → urgent). +* **state** — the agent's mode (idle, working, blocked, done). +* **identity** — *who* is speaking (an agent nick / id string). + +This module defines ONLY the vocabulary and a validated container — the allowed +values and a frozen :class:`Axes` record. It deliberately makes **no sound +decisions**: how an axis maps to timbre/pitch/tempo/velocity is the mapping +module's job (the design spine). Keeping the vocabulary here, dependency-free, +lets the inference path (sentence → axes), the mapping path (axes → notes), and +the CLI verbs all agree on one set of names without importing each other. + +Pure stdlib, no third-party imports — this sits in the offline-testable core. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +# --- the allowed values per axis (the shared vocabulary) --------------------- + +#: Intent — the motif *family* an utterance belongs to (its "what"). +INTENTS: tuple[str, ...] = ( + "ack", + "question", + "success", + "failure", + "thinking", + "handoff", +) + +#: Confidence — an ordered low→high scale (consonance / cadence resolution). +CONFIDENCES: tuple[str, ...] = ("low", "medium", "high") + +#: Urgency — an ordered calm→urgent scale (tempo / attack / repetition). +URGENCIES: tuple[str, ...] = ("calm", "normal", "urgent") + +#: State — the agent's mode (sustained-vs-discrete character). +STATES: tuple[str, ...] = ("idle", "working", "blocked", "done") + +#: Which values are valid for each optional axis, keyed by field name. +ALLOWED: dict[str, tuple[str, ...]] = { + "intent": INTENTS, + "confidence": CONFIDENCES, + "urgency": URGENCIES, + "state": STATES, +} + + +@dataclass(frozen=True) +class Axes: + """One expressive utterance's meaning, as the five axes. + + ``intent`` is required (there is always *something* being expressed); the + other four are optional — ``None`` means "unspecified", and a downstream + consumer (mapping / render) decides the neutral reading of an unspecified + axis. ``identity`` is a free-form agent id/nick string (validated only as + non-empty when present), not an enumerated value: any agent can have a + voice, and its signature is *derived* from this string elsewhere. + + Frozen so an ``Axes`` can be hashed / used as a cache key and never mutates + under a renderer. Use :meth:`with_` for a modified copy. + """ + + intent: str + confidence: str | None = None + urgency: str | None = None + state: str | None = None + identity: str | None = None + + def __post_init__(self) -> None: + if self.intent not in INTENTS: + raise ValueError( + f"unknown intent {self.intent!r}; expected one of {', '.join(INTENTS)}" + ) + for field in ("confidence", "urgency", "state"): + value = getattr(self, field) + if value is not None and value not in ALLOWED[field]: + raise ValueError( + f"unknown {field} {value!r}; expected one of {', '.join(ALLOWED[field])}" + ) + if self.identity is not None and not self.identity.strip(): + raise ValueError("identity, when given, must be a non-empty string") + + def with_(self, **changes: str | None) -> "Axes": + """Return a copy with the given axis fields replaced (validated).""" + return replace(self, **changes) + + def as_dict(self) -> dict[str, str | None]: + """A plain dict of the five axes (for ``--json`` and note metadata).""" + return { + "intent": self.intent, + "confidence": self.confidence, + "urgency": self.urgency, + "state": self.state, + "identity": self.identity, + } diff --git a/harmonics/cli/__init__.py b/harmonics/cli/__init__.py index 0a91e17..96b5326 100644 --- a/harmonics/cli/__init__.py +++ b/harmonics/cli/__init__.py @@ -67,11 +67,17 @@ def _build_parser() -> argparse.ArgumentParser: from harmonics.cli._commands import explain as _explain_cmd from harmonics.cli._commands import learn as _learn_cmd from harmonics.cli._commands import overview as _overview_cmd + from harmonics.cli._commands import play as _play_cmd + from harmonics.cli._commands import say as _say_cmd from harmonics.cli._commands import whoami as _whoami_cmd parser = _CliArgumentParser( prog="harmonics", - description="harmonics-cli — a clonable template for AgentCulture mesh agents.", + description=( + "harmonics-cli — an agent or robot's own non-speech VOICE: render " + "live intent, confidence, urgency, state, and identity into short, " + "pleasant sonic gestures (text-to-notes, the inverse of TTS)." + ), ) parser.add_argument( "--version", @@ -87,6 +93,8 @@ def _build_parser() -> argparse.ArgumentParser: _explain_cmd.register(sub) _overview_cmd.register(sub) _doctor_cmd.register(sub) + _play_cmd.register(sub) + _say_cmd.register(sub) _cli_group.register(sub) # Register your own noun groups here: # from harmonics.cli._commands import my_noun as _my_noun_group diff --git a/harmonics/cli/_commands/learn.py b/harmonics/cli/_commands/learn.py index ab9c0fa..ee1d640 100644 --- a/harmonics/cli/_commands/learn.py +++ b/harmonics/cli/_commands/learn.py @@ -12,15 +12,17 @@ from harmonics.cli._output import emit_result _TEXT = """\ -harmonics-cli — a clonable template for AgentCulture mesh agents. +harmonics-cli — an agent or robot's own non-speech VOICE. Installed from PyPI as harmonics-cli; the command you run is `harmonics`. Purpose ------- -Scaffold for a new Culture mesh agent: an agent-first CLI (cited from the teken -`python-cli` reference), an identity (culture.yaml + CLAUDE.md), the canonical -guildmaster skill kit under .claude/skills/, and a deploy/CI baseline. Clone it, -rename the package, and edit culture.yaml to mint a new agent. +The inverse of text-to-speech: render an agent's live meaning — intent, +confidence, urgency, state, identity — into short, pleasant sonic gestures +(chimes, flutes, pulses, motifs) a listener recognizes by who is speaking and +what they mean. A first-person utterance the agent emits as itself, driven by +its own axes — not a third-person spectator soundtrack, and never words or +phonemes (text-to-notes, not TTS). Commands -------- @@ -53,7 +55,11 @@ def _as_json_payload() -> dict[str, object]: return { "tool": "harmonics-cli", "version": __version__, - "purpose": "Clonable scaffold for a new AgentCulture mesh agent.", + "purpose": ( + "An agent or robot's own non-speech voice: render live " + "intent/confidence/urgency/state/identity into short, pleasant " + "sonic gestures (text-to-notes, the inverse of TTS)." + ), "commands": [ {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, {"path": ["learn"], "summary": "Self-teaching prompt."}, diff --git a/harmonics/cli/_commands/overview.py b/harmonics/cli/_commands/overview.py index 94b1658..014f5eb 100644 --- a/harmonics/cli/_commands/overview.py +++ b/harmonics/cli/_commands/overview.py @@ -1,7 +1,7 @@ """``harmonics overview`` — read-only descriptive snapshot of the agent. Describes the agent to an agent reader: identity (from culture.yaml), the verb -surface, and the sibling-pattern artifacts this template carries. The shared +surface, and the sibling-pattern artifacts this agent carries. The shared section/render helpers here are reused by the ``cli`` noun's ``overview`` (see :mod:`harmonics.cli._commands.cli`). diff --git a/harmonics/cli/_commands/play.py b/harmonics/cli/_commands/play.py new file mode 100644 index 0000000..71ef497 --- /dev/null +++ b/harmonics/cli/_commands/play.py @@ -0,0 +1,277 @@ +"""``harmonics play`` — render explicit axes to a note sequence. + +The first domain verb (see the design spine in ``CLAUDE.md`` and the build +brief, issue #1): render an explicit set of axes — ``intent`` (required) and +optionally ``confidence``/``urgency``/``state`` — into a short, deterministic +note-event gesture, colored by an agent's voice signature (a tonal center + +instrument, derived from ``--as`` or this package's own default identity). + +Composes the domain modules end to end, each already unit-tested in +isolation: + +* :mod:`harmonics.axes` — validates the axis vocabulary (``Axes``). +* :mod:`harmonics.identity` — resolves *who* is speaking into a + :class:`~harmonics.identity.Signature` (``signature_for`` / + ``derive_signature``). +* :mod:`harmonics.mapping` — the design spine: renders ``(axes, signature)`` + to a :class:`~harmonics.notes.NoteEvent` sequence (``render_gesture``). +* :mod:`harmonics.variation` — an optional, deterministic micro-variation pass + keyed by ``--seq`` (``apply_variation``), so a repeated utterance doesn't + sound robotically identical between calls. + +**Dry-run by default** — the safe-by-default rule for a CLI agents call in a +loop. With no ``--out``/``--wav``/``--play``, this verb only prints the note +sequence to stdout: no file is written, no sound is made. ``--out FILE`` +captures the note sequence as JSON and ``--wav FILE`` renders and writes an +actual WAV file (:mod:`harmonics.audio`) — neither needs a live audio device, +and either raises a structured :class:`~harmonics.cli._errors.CliError` (not a +bare traceback) if the write itself fails, e.g. a missing parent directory. +``--play`` renders the gesture and plays it via a live backend +(:func:`harmonics.audio.play`, tried in order: ``simpleaudio``, then +``sounddevice``) if either is installed; with neither installed it fails +loudly with a friendly ``CliError`` hint rather than silently no-op'ing — use +``--wav`` instead when you just want a file and no device at all. + +``--articulation`` (default ``"smooth"``) selects HOW ``--wav``/``--play`` +synthesize the gesture — a continuous gliding voice by default, or the +original discrete per-note tones (``discrete``) — without ever changing the +note sequence itself; see :mod:`harmonics.audio.synth` for the four styles. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from harmonics.axes import CONFIDENCES, INTENTS, STATES, URGENCIES, Axes +from harmonics.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from harmonics.cli._output import emit_result +from harmonics.identity import derive_signature, signature_for +from harmonics.mapping import render_gesture +from harmonics.notes import NoteEvent, sequence_to_json +from harmonics.variation import apply_variation + +#: The voice signature used when ``--as`` is not given: harmonics-cli's own +#: identity (mirrors ``whoami``'s ``_FALLBACK_NICK``). +DEFAULT_IDENTITY = "harmonics-cli" + + +def _build_axes(args: argparse.Namespace) -> Axes: + """Validate the CLI axis flags into an :class:`Axes`. + + ``--intent``/``--confidence``/``--urgency``/``--state`` are already + constrained by argparse ``choices=``, so :class:`Axes`'s own validation + should never fire in normal use — but any :class:`ValueError` it does + raise (e.g. an empty ``--as`` string finding its way into ``identity``) + is still translated into the structured :class:`CliError` contract rather + than leaking a Python traceback. + """ + try: + return Axes( + intent=args.intent, + confidence=args.confidence, + urgency=args.urgency, + state=args.state, + identity=args.as_agent, + ) + except ValueError as err: + raise CliError( + code=EXIT_USER_ERROR, + message=f"invalid axes: {err}", + remediation="run 'harmonics explain play' for the allowed values per axis", + ) from err + + +def _format_text(notes: list[NoteEvent]) -> str: + """A compact, human-readable listing: one line per note.""" + if not notes: + return "(no notes)" + lines = [ + f"{ev.start:.3f} {ev.duration:.3f} {ev.pitch} {ev.velocity:.3f} {ev.voice}" for ev in notes + ] + return "\n".join(lines) + + +def _raise_write_error(path: str, err: OSError) -> None: + """Translate an ``OSError`` from a file write into the structured + :class:`CliError` contract (missing parent dir, permission denied, disk + full, …) instead of letting it bubble up as an "unexpected" error.""" + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not write {path}: {err}", + remediation="check the path, permissions, and that the parent directory exists", + ) from err + + +def _raise_articulation_error(articulation: str, err: ValueError) -> None: + """Translate an unknown-articulation :class:`ValueError` from + :mod:`harmonics.audio` into the structured :class:`CliError` contract. + Unreachable in normal CLI use (``--articulation`` is constrained by + argparse ``choices=``) but keeps the contract honest for any other + caller of these helpers.""" + raise CliError( + code=EXIT_USER_ERROR, + message=f"invalid articulation {articulation!r}: {err}", + remediation="choose one of: discrete, speechy, smooth, alien", + ) from err + + +def _play_live(notes: list[NoteEvent], articulation: str, json_mode: bool) -> None: + """``--play``: render and play ``notes`` through a live backend.""" + # Lazy import: harmonics.audio's own optional playback backend is + # isolated behind this call, so importing this module (and every other + # CLI path) never requires a sound stack. Tries 'simpleaudio' then + # 'sounddevice'; with neither installed, harmonics.audio.play raises a + # friendly CliError rather than failing silently. + from harmonics.audio import play as play_audio + + try: + play_audio(notes, articulation=articulation) + except ValueError as err: + _raise_articulation_error(articulation, err) + if json_mode: + emit_result({"played": True, "notes": len(notes)}, json_mode=True) + else: + emit_result(f"played {len(notes)} note(s)", json_mode=False) + + +def _write_wav_file(notes: list[NoteEvent], path: str, articulation: str, json_mode: bool) -> None: + """``--wav``: render and write a WAV file — no live device needed.""" + from harmonics.audio import write_wav + + try: + write_wav(notes, path, articulation=articulation) + except ValueError as err: + _raise_articulation_error(articulation, err) + except OSError as err: + _raise_write_error(path, err) + if json_mode: + emit_result({"wrote": path, "notes": len(notes)}, json_mode=True) + else: + emit_result(f"wrote {len(notes)} note(s) to {path}", json_mode=False) + + +def _write_out_file(notes: list[NoteEvent], path: str, json_mode: bool) -> None: + """``--out``: write the note-sequence JSON to ``path``.""" + try: + Path(path).write_text(sequence_to_json(notes), encoding="utf-8") + except OSError as err: + _raise_write_error(path, err) + if json_mode: + emit_result({"wrote": path, "notes": len(notes)}, json_mode=True) + else: + emit_result(f"wrote {len(notes)} note(s) to {path}", json_mode=False) + + +def _dry_run(notes: list[NoteEvent], json_mode: bool) -> None: + """The dry-run default: print the note sequence, write no file, make no sound.""" + if json_mode: + emit_result([ev.to_dict() for ev in notes], json_mode=True) + else: + emit_result(_format_text(notes), json_mode=False) + + +def _emit(notes: list[NoteEvent], args: argparse.Namespace, json_mode: bool) -> None: + """Dispatch to the right output path: ``--play`` > ``--wav`` > ``--out`` > + the dry-run default — mirrors the priority documented on each flag.""" + if args.play: + _play_live(notes, args.articulation, json_mode) + elif args.wav: + _write_wav_file(notes, args.wav, args.articulation, json_mode) + elif args.out: + _write_out_file(notes, args.out, json_mode) + else: + _dry_run(notes, json_mode) + + +def cmd_play(args: argparse.Namespace) -> int | None: + json_mode = bool(getattr(args, "json", False)) + + axes = _build_axes(args) + + sig = signature_for(args.as_agent) if args.as_agent else derive_signature(DEFAULT_IDENTITY) + notes = render_gesture(axes, root_pitch=sig.root_pitch, instrument=sig.instrument) + if args.seq is not None: + notes = apply_variation(notes, args.seq) + + _emit(notes, args, json_mode) + return None + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "play", + help="Render explicit axes to a note sequence (dry-run by default).", + ) + p.add_argument( + "--intent", + required=True, + choices=INTENTS, + help="What kind of utterance this is (picks the motif family).", + ) + p.add_argument( + "--confidence", + choices=CONFIDENCES, + default=None, + help="How sure the agent is (low -> high; shades the cadence).", + ) + p.add_argument( + "--urgency", + choices=URGENCIES, + default=None, + help="How much attention this wants (calm -> urgent; shades tempo).", + ) + p.add_argument( + "--state", + choices=STATES, + default=None, + help="The agent's mode (idle/working/blocked/done; shades sustain).", + ) + p.add_argument( + "--as", + dest="as_agent", + default=None, + metavar="AGENT", + help=f"Agent identity to derive the voice signature from (default: {DEFAULT_IDENTITY}).", + ) + p.add_argument( + "--seq", + dest="seq", + default=None, + metavar="NONCE", + help="Deterministic micro-variation nonce (int or string); same nonce -> same output.", + ) + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + p.add_argument( + "--out", + default=None, + metavar="FILE", + help="Write the note-sequence JSON to FILE instead of the dry-run listing.", + ) + p.add_argument( + "--wav", + default=None, + metavar="FILE", + help="Render and write a WAV audio file to FILE (no live device needed).", + ) + p.add_argument( + "--play", + action="store_true", + help=( + "Render and play audio live via 'simpleaudio' or 'sounddevice' if " + "installed; otherwise fails with a friendly error (use --wav to " + "capture to a file with no device)." + ), + ) + p.add_argument( + "--articulation", + choices=("discrete", "speechy", "smooth", "alien"), + default="smooth", + help=( + "How the voice moves between notes, for --wav/--play only (the " + "note sequence itself never changes): 'discrete' is separate " + "per-note tones; 'speechy'/'smooth'/'alien' are one continuous " + "gliding voice, increasingly slurred/vibrato'd. Default: smooth." + ), + ) + p.set_defaults(func=cmd_play) diff --git a/harmonics/cli/_commands/say.py b/harmonics/cli/_commands/say.py new file mode 100644 index 0000000..47f2653 --- /dev/null +++ b/harmonics/cli/_commands/say.py @@ -0,0 +1,415 @@ +"""``harmonics say`` — render a whole SENTENCE to notes in the agent's voice. + +The payoff verb of the text-to-notes path (see the design spine in +``CLAUDE.md`` and the build brief, issue #1): ``harmonics say ""`` +turns a plain-English sentence into the agent's own non-speech voice. Unlike +``play`` (explicit axes in, a *gesture* out), ``say`` takes free text and +composes the whole text->notes pipeline end to end: + +1. :func:`harmonics.stress.parse_emphasis` strips ``*word*``/ALL-CAPS emphasis + markers out of the raw sentence, returning the clean text plus the word + indices to stress. +2. :func:`harmonics.inference.infer_axes` reads the CLEAN text (never the + markers) and infers ``intent``/``confidence``/``urgency``/``state`` — a + documented, static cue-table classifier, not a model. +3. :func:`harmonics.identity.signature_for` / ``derive_signature`` resolve + *who* is speaking (``--as``, else this package's own identity) to a voice + signature (tonal centre + instrument). +4. :func:`harmonics.contour.text_contour` renders the clean text to a + followable melodic line — ONE note per word, in the agent's key — so a + human can trace the tune back to the words, the way you'd follow a hummed + phrase. +5. **Axis shading** (this module's own small addition — see below): the + inferred axes color that contour, the same way ``play``'s axes color a + gesture, without ever touching pitch (so the contour, and thus the + word-tracking property, is preserved). +6. :func:`harmonics.stress.apply_stress` re-emphasizes the stressed word + indices from step 1 (louder + registered up an octave), on top of + whatever shading step 5 already applied. +7. :func:`harmonics.variation.apply_variation` adds an optional, deterministic + micro-variation pass keyed by ``--seq``, so a repeated utterance doesn't + sound robotically identical between calls. + +Axis shading — the design choice (documented, per the build brief) +-------------------------------------------------------------------- +The brief asks for, "at minimum," an urgency-driven tempo scale and a +confidence-driven ending. This module applies exactly those two, and nothing +else, so the melody stays legible as *the same words* however it is shaded: + +* **urgency -> tempo** (:func:`_shade_by_urgency`). A single scale factor + (:data:`_URGENCY_TEMPO_SCALE`) is applied to every note's ``start`` AND + ``duration`` together, so the whole contour speeds up or slows down as one + gesture rather than warping individual gaps: ``urgent`` tightens the whole + line (factor ``< 1``), ``calm`` loosens it (factor ``> 1``), ``normal`` / + unspecified leaves timing untouched. Durations stay clamped to + :data:`~harmonics.mapping.MIN_NOTE_DURATION` / + :data:`~harmonics.mapping.MAX_NOTE_DURATION` — the same attack floor/ceiling + ``play`` and ``contour`` already honor — so an urgent line never clicks and + a calm line never drones. The no-op case is decided by branching on the + ``urgency`` STRING itself (``None``/``"normal"``), not by comparing the + derived scale factor to ``1.0`` with ``==`` — floating-point equality is + fragile, and the string check is exactly the same set of cases. +* **confidence -> the ending** (:func:`_shade_ending_by_confidence`). Only the + FINAL note changes: ``high`` confidence shortens it and nudges its velocity + up a little (a crisp, resolved landing); ``low`` confidence lengthens it and + nudges velocity down a little (a soft, lingering, less-certain tail); + ``medium`` / unspecified leaves it alone. The velocity nudge is always + clamped to :data:`~harmonics.mapping.VELOCITY_CEILING`, so shading — like + stress — only ever uses the headroom already reserved under the palette's + non-alarm ceiling. Like the urgency pass, the no-op case is decided by + branching on the ``confidence`` STRING (``None``/``"medium"``), not by + comparing the derived duration-scale/velocity-delta floats to ``0``/``1``. + +Neither shading step ever changes a note's ``pitch`` — the contour's +letter-derived scale degree (and therefore its in-key consonance and its +word-tracking property) is untouched by axis shading; only *when* and *how +long/loud* notes sound is shaded by meaning. + +Dry-run by default (matches ``play``) +-------------------------------------- +With no ``--out``/``--midi``/``--wav``/``--play``, this verb only prints the +note sequence: no file is written, no sound is made. ``--out FILE`` captures +the note sequence as JSON; ``--midi FILE`` captures the MIDI-like tick +representation (:func:`harmonics.notes.to_midi_notes`); ``--wav FILE`` renders +and writes an actual WAV file (:mod:`harmonics.audio`) — none of these three +need a live audio device, and each raises a structured +:class:`~harmonics.cli._errors.CliError` (not a bare traceback) if the write +itself fails, e.g. a missing parent directory. ``--play`` renders the +utterance and plays it via a live backend (:func:`harmonics.audio.play`, +tried in order: ``simpleaudio``, then ``sounddevice``) if either is +installed; with neither installed it fails loudly with a friendly +``CliError`` hint rather than silently no-op'ing — use ``--wav`` instead when +you just want a file and no device at all. + +``--articulation`` (default ``"smooth"``) selects HOW ``--wav``/``--play`` +synthesize the utterance — a continuous gliding voice by default, or the +original discrete per-note tones (``discrete``) — without ever changing the +note sequence itself; see :mod:`harmonics.audio.synth` for the four styles. +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import replace +from pathlib import Path + +from harmonics.axes import Axes +from harmonics.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from harmonics.cli._output import emit_result +from harmonics.contour import text_contour +from harmonics.identity import derive_signature, signature_for +from harmonics.inference import infer_axes +from harmonics.mapping import MAX_NOTE_DURATION, MIN_NOTE_DURATION, VELOCITY_CEILING +from harmonics.notes import NoteEvent, sequence_to_json, to_midi_notes +from harmonics.stress import apply_stress, parse_emphasis +from harmonics.variation import apply_variation + +#: The voice signature used when ``--as`` is not given: harmonics-cli's own +#: identity (mirrors ``play.DEFAULT_IDENTITY`` / ``whoami``'s fallback nick). +DEFAULT_IDENTITY = "harmonics-cli" + +# --- axis shading: urgency -> tempo -------------------------------------------- + +#: Uniform scale applied to every note's ``start`` and ``duration`` together. +#: ``< 1`` tightens the whole contour (urgent); ``> 1`` loosens it (calm); +#: ``1.0`` (normal/unspecified) leaves timing untouched. +_URGENCY_TEMPO_SCALE: dict[str | None, float] = { + None: 1.0, + "normal": 1.0, + "calm": 1.35, + "urgent": 0.7, +} + +#: The subset of ``urgency`` values that are a no-op for the tempo pass — +#: exactly the keys :data:`_URGENCY_TEMPO_SCALE` maps to ``1.0``. Checked +#: against the axis STRING (see module doc) rather than the derived float, to +#: avoid comparing floats with ``==`` (python:S1244). +_URGENCY_NO_OP: tuple[str | None, ...] = (None, "normal") + +# --- axis shading: confidence -> the ending ------------------------------------ + +#: Duration scale applied ONLY to the final note. ``< 1`` = a crisper, +#: resolved landing (high confidence); ``> 1`` = a lingering, less-certain +#: tail (low confidence); ``1.0`` (medium/unspecified) leaves it alone. +_CONFIDENCE_ENDING_DURATION_SCALE: dict[str | None, float] = { + None: 1.0, + "medium": 1.0, + "high": 0.85, + "low": 1.6, +} + +#: Additive velocity nudge applied ONLY to the final note, always clamped to +#: ``VELOCITY_CEILING`` (never below ``0.0``) — same headroom discipline as +#: ``harmonics.stress``. +_CONFIDENCE_ENDING_VELOCITY_DELTA: dict[str | None, float] = { + None: 0.0, + "medium": 0.0, + "high": 0.08, + "low": -0.08, +} + +#: The subset of ``confidence`` values that are a no-op for the ending pass — +#: exactly the keys both ``_CONFIDENCE_ENDING_DURATION_SCALE`` and +#: ``_CONFIDENCE_ENDING_VELOCITY_DELTA`` map to their identity values. Checked +#: against the axis STRING (see module doc) rather than the derived floats, to +#: avoid comparing floats with ``==`` (python:S1244). +_CONFIDENCE_NO_OP: tuple[str | None, ...] = (None, "medium") + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _shade_by_urgency(seq: list[NoteEvent], urgency: str | None) -> list[NoteEvent]: + """Scale every note's ``start``/``duration`` by urgency (see module doc).""" + if not seq or urgency in _URGENCY_NO_OP: + return list(seq) + factor = _URGENCY_TEMPO_SCALE.get(urgency, 1.0) + return [ + replace( + ev, + start=round(ev.start * factor, 6), + duration=round(_clamp(ev.duration * factor, MIN_NOTE_DURATION, MAX_NOTE_DURATION), 6), + ) + for ev in seq + ] + + +def _shade_ending_by_confidence(seq: list[NoteEvent], confidence: str | None) -> list[NoteEvent]: + """Re-shape only the FINAL note's duration/velocity by confidence (see + module doc); every other note is returned unchanged.""" + if not seq: + return seq + if confidence in _CONFIDENCE_NO_OP: + return list(seq) + dur_factor = _CONFIDENCE_ENDING_DURATION_SCALE.get(confidence, 1.0) + vel_delta = _CONFIDENCE_ENDING_VELOCITY_DELTA.get(confidence, 0.0) + out = list(seq) + last = out[-1] + out[-1] = replace( + last, + duration=round(_clamp(last.duration * dur_factor, MIN_NOTE_DURATION, MAX_NOTE_DURATION), 6), + velocity=round(_clamp(last.velocity + vel_delta, 0.0, VELOCITY_CEILING), 6), + ) + return out + + +def _shade_by_axes(seq: list[NoteEvent], axes: Axes) -> list[NoteEvent]: + """Apply both axis-shading passes, in order: tempo, then the ending.""" + seq = _shade_by_urgency(seq, axes.urgency) + seq = _shade_ending_by_confidence(seq, axes.confidence) + return seq + + +def _format_text(notes: list[NoteEvent]) -> str: + """A compact, human-readable listing: one line per note (matches ``play``).""" + if not notes: + return "(no notes)" + lines = [ + f"{ev.start:.3f} {ev.duration:.3f} {ev.pitch} {ev.velocity:.3f} {ev.voice}" for ev in notes + ] + return "\n".join(lines) + + +def _raise_write_error(path: str, err: OSError) -> None: + """Translate an ``OSError`` from a file write into the structured + :class:`CliError` contract (missing parent dir, permission denied, disk + full, …) instead of letting it bubble up as an "unexpected" error.""" + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not write {path}: {err}", + remediation="check the path, permissions, and that the parent directory exists", + ) from err + + +def _raise_articulation_error(articulation: str, err: ValueError) -> None: + """Translate an unknown-articulation :class:`ValueError` from + :mod:`harmonics.audio` into the structured :class:`CliError` contract. + Unreachable in normal CLI use (``--articulation`` is constrained by + argparse ``choices=``) but keeps the contract honest for any other + caller of these helpers.""" + raise CliError( + code=EXIT_USER_ERROR, + message=f"invalid articulation {articulation!r}: {err}", + remediation="choose one of: discrete, speechy, smooth, alien", + ) from err + + +def _play_live(seq: list[NoteEvent], articulation: str, json_mode: bool) -> None: + """``--play``: render and play ``seq`` through a live backend.""" + # Lazy import: harmonics.audio's own optional playback backend is + # isolated behind this call, so importing this module (and every other + # CLI path) never requires a sound stack. Tries 'simpleaudio' then + # 'sounddevice'; with neither installed, harmonics.audio.play raises a + # friendly CliError rather than failing silently. Checked before any + # file is written, so --play always takes priority over --out/--midi/ + # --wav. + from harmonics.audio import play as play_audio + + try: + play_audio(seq, articulation=articulation) + except ValueError as err: + _raise_articulation_error(articulation, err) + if json_mode: + emit_result({"played": True, "notes": len(seq)}, json_mode=True) + else: + emit_result(f"played {len(seq)} note(s)", json_mode=False) + + +def _write_out_file(seq: list[NoteEvent], path: str) -> None: + """``--out``: write the note-sequence JSON to ``path``.""" + try: + Path(path).write_text(sequence_to_json(seq), encoding="utf-8") + except OSError as err: + _raise_write_error(path, err) + + +def _write_midi_file(seq: list[NoteEvent], path: str) -> None: + """``--midi``: write the MIDI-like tick representation to ``path``.""" + try: + Path(path).write_text(json.dumps(to_midi_notes(seq), ensure_ascii=False), encoding="utf-8") + except OSError as err: + _raise_write_error(path, err) + + +def _write_wav_file(seq: list[NoteEvent], path: str, articulation: str) -> None: + """``--wav``: render and write a WAV file — no live device needed.""" + from harmonics.audio import write_wav + + try: + write_wav(seq, path, articulation=articulation) + except ValueError as err: + _raise_articulation_error(articulation, err) + except OSError as err: + _raise_write_error(path, err) + + +def _write_requested_files(seq: list[NoteEvent], args: argparse.Namespace) -> list[str]: + """Write whichever of ``--out``/``--midi``/``--wav`` were requested — all + independent of one another (unlike ``--play``, which takes priority and + returns early); returns the list of paths written, for the summary line.""" + wrote: list[str] = [] + if args.out: + _write_out_file(seq, args.out) + wrote.append(args.out) + if args.midi: + _write_midi_file(seq, args.midi) + wrote.append(args.midi) + if args.wav: + _write_wav_file(seq, args.wav, args.articulation) + wrote.append(args.wav) + return wrote + + +def _dry_run(seq: list[NoteEvent], json_mode: bool) -> None: + """The dry-run default: print the note sequence, write no file, make no sound.""" + if json_mode: + emit_result([ev.to_dict() for ev in seq], json_mode=True) + else: + emit_result(_format_text(seq), json_mode=False) + + +def _emit(seq: list[NoteEvent], args: argparse.Namespace, json_mode: bool) -> None: + """Dispatch to the right output path: ``--play`` takes priority; otherwise + write whichever of ``--out``/``--midi``/``--wav`` were requested (any + combination), or fall back to the dry-run default.""" + if args.play: + _play_live(seq, args.articulation, json_mode) + return + + wrote = _write_requested_files(seq, args) + if wrote: + if json_mode: + emit_result({"wrote": wrote, "notes": len(seq)}, json_mode=True) + else: + emit_result(f"wrote {len(seq)} note(s) to {', '.join(wrote)}", json_mode=False) + return + + _dry_run(seq, json_mode) + + +def cmd_say(args: argparse.Namespace) -> int | None: + json_mode = bool(getattr(args, "json", False)) + + clean, stressed = parse_emphasis(args.sentence) + axes = infer_axes(clean) + if args.as_agent: + axes = axes.with_(identity=args.as_agent) + sig = signature_for(args.as_agent) + else: + sig = derive_signature(DEFAULT_IDENTITY) + + seq = text_contour(clean, root_pitch=sig.root_pitch, instrument=sig.instrument) + seq = _shade_by_axes(seq, axes) + seq = apply_stress(seq, stressed) + if args.seq is not None: + seq = apply_variation(seq, args.seq) + + _emit(seq, args, json_mode) + return None + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "say", + help="Render a sentence to a note sequence in the agent's voice (dry-run by default).", + ) + p.add_argument( + "sentence", + help="The sentence to speak. Emphasize a word with *asterisks* or ALL-CAPS.", + ) + p.add_argument( + "--as", + dest="as_agent", + default=None, + metavar="AGENT", + help=f"Agent identity to derive the voice signature from (default: {DEFAULT_IDENTITY}).", + ) + p.add_argument( + "--seq", + dest="seq", + default=None, + metavar="NONCE", + help="Deterministic micro-variation nonce (int or string); same nonce -> same output.", + ) + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + p.add_argument( + "--out", + default=None, + metavar="FILE", + help="Write the note-sequence JSON to FILE instead of the dry-run listing.", + ) + p.add_argument( + "--midi", + default=None, + metavar="FILE", + help="Write the MIDI-like note list (harmonics.notes.to_midi_notes) to FILE.", + ) + p.add_argument( + "--wav", + default=None, + metavar="FILE", + help="Render and write a WAV audio file to FILE (no live device needed).", + ) + p.add_argument( + "--play", + action="store_true", + help=( + "Render and play audio live via 'simpleaudio' or 'sounddevice' if " + "installed; otherwise fails with a friendly error (use --wav to " + "capture to a file with no device)." + ), + ) + p.add_argument( + "--articulation", + choices=("discrete", "speechy", "smooth", "alien"), + default="smooth", + help=( + "How the voice moves between notes, for --wav/--play only (the " + "note sequence itself never changes): 'discrete' is separate " + "per-note tones; 'speechy'/'smooth'/'alien' are one continuous " + "gliding voice, increasingly slurred/vibrato'd. Default: smooth." + ), + ) + p.set_defaults(func=cmd_say) diff --git a/harmonics/contour.py b/harmonics/contour.py new file mode 100644 index 0000000..e5e8656 --- /dev/null +++ b/harmonics/contour.py @@ -0,0 +1,229 @@ +"""Text -> melodic contour — the "voice tracks the text" core of ``say``. + +This is what makes ``harmonics say ""`` a **voice** rather than a +soundtrack. Where :mod:`harmonics.mapping` renders the agent's *meaning* (its +axes) to a gesture, this module renders the **sentence's own words** to a +followable melodic line: a human can *approximately* connect the tune back to +the text, the way you follow a hummed phrase, without a single phoneme being +reproduced. It is still fully non-speech and fully deterministic — the same +text always yields the same contour, sung in the agent's key. + +Per the design spine (``CLAUDE.md``, build brief issue #1, and the spec's +legibility requirement + honesty condition), the contour is derived from the +sentence's own units, and it is offline and pure: no clock, no unseeded +randomness, no audio, no third-party import. Assertions land on the emitted +:class:`~harmonics.notes.NoteEvent` sequence, never on a speaker. + +Granularity: **one note per word** (with letter/length coloring) +--------------------------------------------------------------- +The spec parks the exact text->contour unit (per-letter vs per-syllable vs +per-word) as an implementation decision; this module picks **per word**, which +is the granularity a listener most naturally follows in a hummed phrase — one +sung note per spoken word. Within that choice, a word's *letters* still color +the note (they feed the hash that picks its scale degree), and a word's +*length* nudges its register and duration, so the melody carries word-level +text information rather than a constant tune. + +The text -> note mapping (documented & deterministic) +---------------------------------------------------- +For each word token, in order: + +* **Pitch class (scale degree) <- the word's letters.** A stable + :mod:`hashlib` SHA-256 digest of the lowercased word selects one degree of + :data:`~harmonics.mapping.CONSONANT_SCALE` (the same warm major pentatonic + the rest of harmonics uses), so every note is in-key. SHA-256 — *not* + Python's builtin :func:`hash`, which is randomized per process for strings — + so the contour is stable across processes and machines. +* **Register (octave) <- the word's length.** Short words (<=3 letters) sit an + octave below the root, medium words (4-6) at the root octave, long words + (>=7) an octave above. Adding whole octaves preserves the pitch class, so + consonance is untouched while word length becomes audible as pitch height. +* **Duration <- the word's length.** Longer words sound a little longer + (bounded by the palette's attack floor / ceiling). +* **Velocity <- the word's letters.** A small, bounded value derived from the + same digest, always at or below :data:`~harmonics.mapping.VELOCITY_CEILING`. +* **Rhythm / rests <- punctuation.** Punctuation *between* words lengthens the + gap before the next note: a clause mark (``, ; :``) inserts a small rest, a + terminal mark (``. ? !``) a larger one — phrasing you can hear. + +Because every degree comes from a hash of the word's own letters, the **same +words always yield the same contour** and two different words usually land on +different degrees, so the melodic shape tracks the sentence. Empty or +punctuation-only input yields an empty (well-formed) sequence. + +Pure stdlib + :mod:`harmonics.notes` + :mod:`harmonics.mapping`. No audio. +""" + +from __future__ import annotations + +import hashlib +import re + +from harmonics.mapping import ( + BASE_DURATION, + BASE_IOI, + CONSONANT_SCALE, + MAX_NOTE_DURATION, + MIN_NOTE_DURATION, + VELOCITY_CEILING, +) +from harmonics.notes import NoteEvent + +# --- tokenizer ----------------------------------------------------------------- + +#: A word unit: a run of letters/digits/apostrophes. Everything between two +#: matches (spaces, punctuation) is the inter-word gap that shapes rhythm. +_WORD_RE = re.compile(r"[A-Za-z0-9']+") + +# --- register (octave) <- word length ----------------------------------------- + +#: Length buckets (inclusive upper bounds) -> octave offset in semitones. Whole +#: octaves keep the pitch class — and thus the consonance — intact while making +#: word length audible: short words sit low, long words high. +_SHORT_MAX_LEN = 3 +_MEDIUM_MAX_LEN = 6 +_LOW_OCTAVE = -12 +_MID_OCTAVE = 0 +_HIGH_OCTAVE = 12 + +# --- duration <- word length -------------------------------------------------- + +#: Seconds of extra sounding length per letter, added to the palette's base +#: duration and then clamped into the palette's [min, max] attack envelope. +_DURATION_PER_LETTER = 0.015 + +# --- velocity <- word letters ------------------------------------------------- + +#: Contour velocities live in ``[_VELOCITY_FLOOR, VELOCITY_CEILING]`` — always +#: pleasant, never above the palette's non-alarm ceiling. +_VELOCITY_FLOOR = 0.45 + +# --- rhythm / rests <- punctuation -------------------------------------------- + +#: Inter-onset gap multipliers applied when punctuation sits between two words. +_CLAUSE_PUNCTUATION = frozenset(",;:") +_TERMINAL_PUNCTUATION = frozenset(".?!") +_CLAUSE_REST_FACTOR = 1.6 +_TERMINAL_REST_FACTOR = 2.5 + + +def _word_digest(word: str) -> bytes: + """A stable SHA-256 digest of ``word`` (lowercased). + + :mod:`hashlib`, never builtin :func:`hash` — so the derived degree and + velocity are identical in this process, a fresh process, or on another + machine, regardless of ``PYTHONHASHSEED``. + """ + return hashlib.sha256(word.lower().encode("utf-8")).digest() + + +def _degree_for_word(digest: bytes) -> int: + """Pick one :data:`~harmonics.mapping.CONSONANT_SCALE` degree from a digest.""" + index = int.from_bytes(digest[:4], byteorder="big") % len(CONSONANT_SCALE) + return CONSONANT_SCALE[index] + + +def _octave_for_length(length: int) -> int: + """Map a word's letter count to a register offset (whole octaves).""" + if length <= _SHORT_MAX_LEN: + return _LOW_OCTAVE + if length <= _MEDIUM_MAX_LEN: + return _MID_OCTAVE + return _HIGH_OCTAVE + + +def _velocity_for_word(digest: bytes) -> float: + """A bounded, deterministic velocity in ``[floor, VELOCITY_CEILING]``.""" + raw = digest[4] / 255.0 # a stable byte in [0.0, 1.0] + velocity = _VELOCITY_FLOOR + raw * (VELOCITY_CEILING - _VELOCITY_FLOOR) + return round(min(velocity, VELOCITY_CEILING), 6) + + +def _duration_for_length(length: int) -> float: + """Longer words sound a little longer, within the palette's envelope.""" + duration = BASE_DURATION + length * _DURATION_PER_LETTER + return round(max(MIN_NOTE_DURATION, min(duration, MAX_NOTE_DURATION)), 6) + + +def _fold_into_range(pitch: int) -> int: + """Fold ``pitch`` into the valid MIDI range by octaves (edge guard for + extreme roots), preserving its pitch class and thus its consonance.""" + while pitch < 0: + pitch += 12 + while pitch > 127: + pitch -= 12 + return pitch + + +def _rest_factor(gap: str) -> float: + """How much the gap-text between two words stretches the next onset. + + A terminal mark (``. ? !``) wins over a clause mark (``, ; :``); a gap with + neither leaves the base inter-onset interval unchanged. + """ + if any(ch in _TERMINAL_PUNCTUATION for ch in gap): + return _TERMINAL_REST_FACTOR + if any(ch in _CLAUSE_PUNCTUATION for ch in gap): + return _CLAUSE_REST_FACTOR + return 1.0 + + +def text_contour( + sentence: str, + *, + root_pitch: int = 60, + instrument: str = "flute", +) -> list[NoteEvent]: + """Render ``sentence`` to a followable melodic contour — one note per word. + + The melody is derived from the sentence's **own words** (see the module + docstring for the full text->note mapping): each word's letters pick an + in-key scale degree over ``root_pitch`` and a bounded velocity, its length + nudges register and duration, and punctuation between words shapes the + rhythm. Every emitted pitch stays in :data:`~harmonics.mapping.CONSONANT_SCALE` + and every velocity at or below :data:`~harmonics.mapping.VELOCITY_CEILING`, + so the contour is pleasant and in the agent's key. + + Deterministic and offline: the same ``sentence`` (and ``root_pitch`` / + ``instrument``) always returns an equal list, seeded with :mod:`hashlib` + rather than the per-process-randomized builtin :func:`hash`, so it is + reproducible across processes. Non-speech: it emits only + :class:`~harmonics.notes.NoteEvent`\\ s — no phonemes. Empty or + punctuation-only input returns an empty list. + + ``root_pitch`` is the agent's tonal centre and ``instrument`` its timbre; + both come from identity and are passed in, keeping the contour decoupled + from identity just as :func:`harmonics.mapping.render_gesture` is. + """ + matches = list(_WORD_RE.finditer(sentence)) + if not matches: + return [] + + events: list[NoteEvent] = [] + onset = 0.0 + for position, match in enumerate(matches): + word = match.group() + length = len(word) + digest = _word_digest(word) + + degree = _degree_for_word(digest) + octave = _octave_for_length(length) + pitch = _fold_into_range(root_pitch + octave + degree) + + events.append( + NoteEvent( + start=round(onset, 6), + duration=_duration_for_length(length), + pitch=pitch, + velocity=_velocity_for_word(digest), + voice=instrument, + ) + ) + + # Advance the onset to the next word, stretching the gap for any + # punctuation that sits between this word and the next. + if position + 1 < len(matches): + gap = sentence[match.end() : matches[position + 1].start()] + onset += BASE_IOI * _rest_factor(gap) + + return events diff --git a/harmonics/explain/catalog.py b/harmonics/explain/catalog.py index 6622757..bcde64b 100644 --- a/harmonics/explain/catalog.py +++ b/harmonics/explain/catalog.py @@ -17,21 +17,32 @@ _ROOT = """\ # harmonics-cli -A clonable template for AgentCulture mesh agents. It carries an agent-first CLI -(cited from the teken `python-cli` reference), a mesh identity (`culture.yaml` + -`CLAUDE.md`), the canonical guildmaster skill kit under `.claude/skills/`, and a -buildable/deployable package baseline. Clone it, rename the package, edit -`culture.yaml`, and you have a new agent. +harmonics-cli gives an agent or robot its own **non-speech voice**. It renders +the agent's live meaning — five axes: **intent, confidence, urgency, state, +identity** — into short, pleasant sonic gestures (chimes, flutes, pulses, tonal +motifs) a listener recognizes by *who* is speaking and *what* they mean. It is +the first-person inverse of text-to-speech: it maps *meaning*, not phonemes, and +reproduces no words. + +This is a first-person *utterance* the agent emits as itself, live and driven by +its own axes — not a third-person spectator soundtrack (like league-of-agents' +replay score, which narrates a match from the outside off an event log). Voice, +not background; meaning, not TTS. Installed from PyPI as `harmonics-cli`; the command you run is `harmonics`. -## Verbs +## Verbs (today) - `harmonics whoami` — identity probe from `culture.yaml`. - `harmonics learn` — structured self-teaching prompt. - `harmonics explain ` — markdown docs for any noun/verb. - `harmonics overview` — descriptive snapshot of the agent. - `harmonics doctor` — check the agent-identity invariants. +- `harmonics play` — render explicit axes to a note sequence (dry-run + default; `--wav` for an offline WAV file, `--play` for live audio). +- `harmonics say ""` — sentence → inferred axes + text contour + + emphasis → notes, in the agent's voice (dry-run default; `--wav` for an + offline WAV file, `--play` for live audio). - `harmonics cli overview` — describe the CLI surface. ## Exit-code policy @@ -110,6 +121,176 @@ harmonics doctor --json """ +_PLAY = """\ +# harmonics play + +Renders explicit axes to a note sequence — the first domain verb (see the +design spine in `CLAUDE.md` and the build brief, issue #1). Composes +`harmonics.axes` (the vocabulary), `harmonics.identity` (the *who* → voice +signature), and `harmonics.mapping` (axes → notes), plus an optional +deterministic micro-variation pass (`harmonics.variation`) via `--seq`. + +**Dry-run by default** — with no `--out`/`--wav`/`--play`, this only prints +the note sequence: no file is written, no sound is made. Safe to call in a +loop. `--wav FILE` renders and writes a real WAV file (`harmonics.audio`) +with no live device needed. `--play` renders and plays it live through +`simpleaudio` or `sounddevice` (tried in that order, whichever is +installed) and takes priority over `--out`/`--wav` if given alongside them; +if neither library is installed it fails with a structured `CliError` and a +remediation hint instead of a silent no-op. + +## Axes + +- `--intent` (required) — one of: ack, question, success, failure, thinking, + handoff. Picks the motif family. +- `--confidence` — low, medium, high. Shades the cadence (resolved vs. + suspended). +- `--urgency` — calm, normal, urgent. Shades tempo, attack, repetition. +- `--state` — idle, working, blocked, done. Shades sustain vs. discrete. +- `--as AGENT` — derive the voice signature (tonal center + instrument) from + this identity string. Default: harmonics-cli's own signature. +- `--seq NONCE` — deterministic micro-variation nonce (int or string); the + same nonce always renders the same variation. + +## Usage + + harmonics play --intent success + harmonics play --intent question --confidence low --as my-agent + harmonics play --intent success --json + harmonics play --intent success --seq 7 + harmonics play --intent success --out gesture.json + harmonics play --intent success --wav gesture.wav + harmonics play --intent success --play + harmonics play --intent success --wav gesture.wav --articulation alien + +## Output modes + +- Dry-run (default) — the note sequence to stdout: one line per note + (`start dur pitch vel voice`) in text mode, or a JSON list of note objects + with `--json`. Writes no file, makes no sound. +- `--out FILE` — writes the note-sequence JSON to `FILE`; prints a one-line + confirmation. No audio backend required. +- `--wav FILE` — renders and writes a real WAV audio file to `FILE`. No live + device needed — this only touches the filesystem. +- `--play` — renders and plays the gesture live through `simpleaudio` or + `sounddevice` (tried in that order, whichever is installed). If neither is + installed, fails with a structured `CliError` and a remediation hint to + install one or use `--wav`/`--out` instead. + +## `--articulation` — how the voice moves between notes + +Only affects `--wav`/`--play` (dry-run/`--json`/`--out` are note-sequence +output and never change). Four styles (`harmonics.audio.synth.ARTICULATIONS`): + +- `discrete` — the original synth: each note is its own short tone, with + silence possible between them (a "music box"). +- `speechy` — a continuous gliding voice (legato + portamento) that slides + between word-pitches instead of stepping between them; mild vibrato. The + gentlest glide. +- `smooth` (**default**) — the same continuous glide, more pronounced — + slower transitions, a bit more vibrato. +- `alien` — the most pronounced glide and vibrato of the three — an + otherworldly, always-sliding voice. + +Gliding is the default (`smooth`); pass `--articulation discrete` for the +original per-note behavior. +""" + +_SAY = """\ +# harmonics say + +Renders a whole SENTENCE to notes in the agent's own voice — the payoff verb +of the text-to-notes path (see the design spine in `CLAUDE.md` and the build +brief, issue #1). Where `play` takes explicit axes, `say` takes free text and +composes the full pipeline: + +1. `harmonics.stress.parse_emphasis` strips `*word*`/ALL-CAPS emphasis + markers, returning the clean text plus which word indices to stress. +2. `harmonics.inference.infer_axes` reads the clean text and infers + intent/confidence/urgency/state — a static cue-table, not a model. +3. `harmonics.identity` resolves *who* is speaking (`--as`, else + harmonics-cli's own identity) to a voice signature. +4. `harmonics.contour.text_contour` renders the clean text to a followable + melody — ONE note per word, in the agent's key, so a human can trace the + tune back to the words. +5. Axis shading colors that contour: urgency scales the whole contour's tempo + (urgent tightens, calm loosens); confidence reshapes only the final note + (high = a crisp resolved landing, low = a soft lingering tail). Neither + ever touches pitch, so the word-tracking melody and its in-key consonance + are preserved. +6. `harmonics.stress.apply_stress` re-emphasizes the stressed word indices + from step 1 (louder + an octave up), on top of the shading above. +7. `harmonics.variation.apply_variation` adds an optional deterministic + micro-variation pass via `--seq`. + +**Dry-run by default** — with no `--out`/`--midi`/`--wav`/`--play`, this only +prints the note sequence: no file is written, no sound is made. Safe to call +in a loop. `--wav FILE` renders and writes a real WAV file +(`harmonics.audio`) with no live device needed. `--play` renders and plays +the utterance live through `simpleaudio` or `sounddevice` (tried in that +order, whichever is installed) and takes priority over +`--out`/`--midi`/`--wav` if given alongside them; if neither library is +installed it fails with a structured `CliError` and a remediation hint +instead of a silent no-op. + +## Flags + +- `sentence` (positional, required) — the text to speak. Emphasize a word + with `*asterisks*` or ALL-CAPS. +- `--as AGENT` — derive the voice signature from this identity string. + Default: harmonics-cli's own signature. +- `--seq NONCE` — deterministic micro-variation nonce (int or string); the + same nonce always renders the same variation. + +## Usage + + harmonics say "done, tests all green" + harmonics say "did it pass?" --as my-agent + harmonics say "push it *now*" --json + harmonics say "wrap it up, no rush" --seq 7 + harmonics say "all tests passed" --out utterance.json + harmonics say "all tests passed" --midi utterance.midi.json + harmonics say "all tests passed" --wav utterance.wav + harmonics say "all tests passed" --play + harmonics say "all tests passed" --wav utterance.wav --articulation alien + +## Output modes + +- Dry-run (default) — the note sequence to stdout: one line per note + (`start dur pitch vel voice`) in text mode, or a JSON list of note objects + with `--json`. Writes no file, makes no sound. +- `--out FILE` — writes the note-sequence JSON to `FILE`. +- `--midi FILE` — writes the MIDI-like tick representation + (`harmonics.notes.to_midi_notes`) to `FILE`. No audio backend required for + either. +- `--wav FILE` — renders and writes a real WAV audio file to `FILE`. No live + device needed — this only touches the filesystem. +- `--play` — renders and plays the utterance live through `simpleaudio` or + `sounddevice` (tried in that order, whichever is installed); takes + priority over `--out`/`--midi`/`--wav` if given alongside them. If neither + library is installed, fails with a structured `CliError` and a + remediation hint to install one or use `--wav`/`--out`/`--midi` instead. + +## `--articulation` — how the voice moves between notes + +Only affects `--wav`/`--play` (dry-run/`--json`/`--out`/`--midi` are +note-sequence output and never change). Four styles +(`harmonics.audio.synth.ARTICULATIONS`): + +- `discrete` — the original synth: each note is its own short tone, with + silence possible between them (a "music box"). +- `speechy` — a continuous gliding voice (legato + portamento) that slides + between word-pitches instead of stepping between them; mild vibrato. The + gentlest glide. +- `smooth` (**default**) — the same continuous glide, more pronounced — + slower transitions, a bit more vibrato. +- `alien` — the most pronounced glide and vibrato of the three — an + otherworldly, always-sliding voice. + +Gliding is the default (`smooth`); pass `--articulation discrete` for the +original per-note behavior. +""" + _CLI = """\ # harmonics cli @@ -132,6 +313,8 @@ ("explain",): _EXPLAIN, ("overview",): _OVERVIEW, ("doctor",): _DOCTOR, + ("play",): _PLAY, + ("say",): _SAY, ("cli",): _CLI, ("cli", "overview"): _CLI, } diff --git a/harmonics/identity.py b/harmonics/identity.py new file mode 100644 index 0000000..ec8251e --- /dev/null +++ b/harmonics/identity.py @@ -0,0 +1,159 @@ +"""Identity → voice-print — the deterministic mapping from *who* is speaking +to a recognizable sonic signature. + +harmonics's promise is that you can tell **who** is speaking by ear, the way +league-of-agents gives each team its own register — generalized here to +*per-agent* identity. This module owns that one job: turn an agent's identity +string (a nick / id, e.g. ``"harmonics-cli"``) into a stable +:class:`Signature` — a tonal center, a base timbre, and a seed for +downstream deterministic variety. The mapping module (axes → notes) consumes +a ``Signature`` to color a gesture with the speaking agent's voice; it does +not derive one itself. + +Determinism is the whole point: the same identity must produce the same +signature **every run, in every process** — including a fresh interpreter on +a different machine. Python's builtin :func:`hash` is salted per-process +(``PYTHONHASHSEED``) and is therefore unusable here; this module hashes with +:mod:`hashlib` (SHA-256) instead, which is stable across processes and +platforms by construction. + +A signature is otherwise *derived*, not chosen — but a hand-authored +``overrides`` mapping (identity → partial field dict) lets an operator pin +specific agents to specific voices (e.g. giving ``harmonics-cli`` itself a +fixed, memorable signature) without touching the hashing scheme for everyone +else. + +Pure stdlib, no third-party imports — this sits in the offline-testable core. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Mapping + +#: Base timbre palette a signature's ``instrument`` is drawn from. Kept small +#: and deliberately gentle/sustained-friendly (no harsh or fatiguing +#: timbres) — see the design spine's "pleasant and non-fatiguing" rule. +INSTRUMENTS: tuple[str, ...] = ("chime", "flute", "pulse", "bell", "pluck", "glass") + +#: Documented set of pleasant tonal centers a signature's ``root_pitch`` is +#: drawn from: MIDI note numbers 55-67 (G3-G4), i.e. the diatonic (white-key) +#: degrees of a mid-range major scale. Restricting to diatonic degrees, with +#: no chromatic neighbors, keeps any two agents' root pitches either unison +#: or a consonant scale interval apart — never a dissonant semitone clash. +ROOT_PITCHES: tuple[int, ...] = (55, 57, 59, 60, 62, 64, 65, 67) + +#: The pleasant mid-range a ``root_pitch`` must fall within (inclusive) — +#: the bounds of :data:`ROOT_PITCHES`, kept as named constants so validation +#: reads as a range check rather than a re-derivation of the tuple's ends. +_MIN_ROOT_PITCH = min(ROOT_PITCHES) +_MAX_ROOT_PITCH = max(ROOT_PITCHES) + +#: Fields a partial override dict (see :func:`signature_for`) may set. +_OVERRIDE_FIELDS = frozenset({"root_pitch", "instrument", "seed"}) + + +@dataclass(frozen=True) +class Signature: + """One agent's voice-print — the identity-derived (or overridden) sonic + fingerprint the mapping module colors a gesture with. + + ``root_pitch`` is the agent's tonal center as a MIDI note number, drawn + from the pleasant mid-range documented in :data:`ROOT_PITCHES`. + ``instrument`` is the base timbre, drawn from :data:`INSTRUMENTS`. + ``seed`` is a stable integer derived from the identity's hash, handed to + downstream renderers that want deterministic-but-varied embellishment + (e.g. picking among several motifs for the same agent) without hashing + the identity string again themselves. + + Frozen so a ``Signature`` can be hashed / cached and never mutates once + derived. + """ + + root_pitch: int + instrument: str + seed: int + + def __post_init__(self) -> None: + if not (_MIN_ROOT_PITCH <= self.root_pitch <= _MAX_ROOT_PITCH): + raise ValueError( + f"root_pitch must be a MIDI note number in [{_MIN_ROOT_PITCH}, " + f"{_MAX_ROOT_PITCH}], got {self.root_pitch!r}" + ) + if self.instrument not in INSTRUMENTS: + raise ValueError( + f"unknown instrument {self.instrument!r}; expected one of " + f"{', '.join(INSTRUMENTS)}" + ) + + +def derive_signature(identity: str) -> Signature: + """Deterministically derive a :class:`Signature` from an identity string. + + Hashes ``identity`` with SHA-256 (:mod:`hashlib` — stable across + processes and machines, unlike the builtin, per-process-salted + :func:`hash`) and slices the digest into three independent chunks: one + picks the :data:`ROOT_PITCHES` index, one picks the :data:`INSTRUMENTS` + index, and one becomes ``seed``. Using disjoint byte ranges for pitch and + instrument avoids correlating the two axes, so two identities that land + on the same root pitch don't also collide on instrument (and vice + versa). + + Same ``identity`` in, same :class:`Signature` out — always, in any + process. + """ + if not identity.strip(): + raise ValueError("identity must be a non-empty string") + digest = hashlib.sha256(identity.encode("utf-8")).digest() + pitch_index = int.from_bytes(digest[0:8], "big") % len(ROOT_PITCHES) + instrument_index = int.from_bytes(digest[8:16], "big") % len(INSTRUMENTS) + seed = int.from_bytes(digest[16:24], "big") + return Signature( + root_pitch=ROOT_PITCHES[pitch_index], + instrument=INSTRUMENTS[instrument_index], + seed=seed, + ) + + +def signature_for( + identity: str, + overrides: Mapping[str, Mapping[str, int | str]] | None = None, +) -> Signature: + """Resolve ``identity``'s :class:`Signature`, honoring a hand-authored + override if one applies. + + ``overrides`` maps identity → a *partial* field dict (any subset of + ``root_pitch`` / ``instrument`` / ``seed``) that replaces just those + fields on the derived signature; fields the override omits keep their + :func:`derive_signature` value. An identity absent from ``overrides`` + (or a ``None``/empty ``overrides``) gets the plain derived signature. + Overrides are a plain ``dict`` on purpose — the runtime core stays + dependency-free, with no YAML parser required to accept one. + + Overridden fields are validated exactly like derived ones (e.g. an + override can't set ``instrument`` to something outside + :data:`INSTRUMENTS`); an unknown override key raises ``ValueError`` + rather than being silently ignored, to catch a typo'd field name. + """ + base = derive_signature(identity) + if not overrides: + return base + partial = overrides.get(identity) + if not partial: + return base + unknown = set(partial) - _OVERRIDE_FIELDS + if unknown: + raise ValueError( + f"unknown Signature field(s) in override for {identity!r}: " + f"{', '.join(sorted(unknown))}" + ) + # Build the merged Signature explicitly (rather than returning + # ``dataclasses.replace(base, **partial)`` directly) so the declared and + # inferred return type is ``Signature``, not the generic + # ``DataclassInstance`` that ``replace`` returns. + return Signature( + root_pitch=partial.get("root_pitch", base.root_pitch), + instrument=partial.get("instrument", base.instrument), + seed=partial.get("seed", base.seed), + ) diff --git a/harmonics/inference.py b/harmonics/inference.py new file mode 100644 index 0000000..84e7da3 --- /dev/null +++ b/harmonics/inference.py @@ -0,0 +1,169 @@ +"""Sentence -> :class:`~harmonics.axes.Axes` inference — the front half of the +``say`` path (design spine, ``CLAUDE.md``; build brief, issue #1). + +``harmonics say ""`` needs to turn a plain-English utterance into +the five expressive axes before the mapping module can render it to sound. +This module does *only* that half: text in, a validated :class:`Axes` out. +It never touches audio, never calls the mapping/render layer, and — per the +domain build rule that the text->notes core stay unit-testable offline — +never calls a model or the network. Inference is a **documented, static +cue/keyword rule table**, not a classifier: the same sentence always yields +the same :class:`Axes`, with no hidden state and nothing to train or fetch. + +Pure stdlib + :mod:`harmonics.axes` only. Importing this module must never +require a third-party package or a network call — see +``test_inference_module_imports_only_stdlib_and_harmonics`` in +``tests/test_inference.py``, which parses this file's own imports to enforce +it. + +How matching works +------------------- +Every rule below is a tuple of *cues* tested against the lowercased sentence: + +* A cue that is alphanumeric with no internal space (``"done"``, ``"now"``) + matches as a **whole word/token** — the sentence is tokenized with + :data:`_WORD_RE`, so ``"now"`` matches "act now" but not "unknown". +* A cue containing punctuation or a space (``"?"``, ``"not sure"``, + ``"working on"``) matches as a **raw substring** of the sentence, since + tokenizing would strip the punctuation or split the phrase apart. + +Each axis is resolved independently by walking its own cue groups in a fixed, +documented priority order and taking the first group that hits; axes with no +cue hit stay ``None`` (unspecified — the mapping layer's job to default), except +``intent``, which always falls back to ``"ack"`` (there is always *something* +being expressed, even a plain, cue-free statement). +""" + +from __future__ import annotations + +import re + +from harmonics.axes import Axes + +# --- tokenizer ----------------------------------------------------------------- + +#: Word boundary used for whole-token cue matching (letters, digits, '). +_WORD_RE = re.compile(r"[a-z0-9']+") + +# --- intent cue groups, in resolution priority order (first hit wins) ---------- +# Rationale for the order: a concrete pass/fail report is the highest-value +# signal and should win over a softer cue in the same sentence; a handoff is +# the next most concrete signal (a state transition between agents); an +# explicit question mark or question word is unambiguous; hedging language +# ("hmm", "not sure") is the weakest/most ambiguous signal and is checked +# last, right before the "ack" default. + +#: A result was reported and it succeeded. +SUCCESS_CUES: tuple[str, ...] = ( + "done", + "complete", + "passed", + "green", + "success", + "✓", # ✓ +) + +#: A result was reported and it failed. +FAILURE_CUES: tuple[str, ...] = ("error", "failed", "broke", "exception", "crash") + +#: Work is being explicitly handed to another agent. +HANDOFF_CUES: tuple[str, ...] = ("handing off", "over to") + +#: Question words considered even without a trailing "?" (see ``_is_question``). +QUESTION_WORD_CUES: tuple[str, ...] = ("how", "what", "why", "should") + +#: Hedging / uncertainty language -> also drives confidence=low (see below). +THINKING_CUES: tuple[str, ...] = ("hmm", "not sure", "maybe", "might", "unsure") + +# --- confidence cue groups (checked low-first: hedging is the stronger tell) --- + +#: Same cues as THINKING_CUES: hedging language is evidence of low confidence. +UNCERTAINTY_CUES: tuple[str, ...] = THINKING_CUES + +#: Assertive/definite language -> confidence=high. +CERTAINTY_CUES: tuple[str, ...] = ("definitely", "certain", "all", ">=") + +# --- urgency cue groups (checked urgent-first: an urgent cue should not be +# masked by an incidental calm word elsewhere in the sentence) ----------------- + +URGENT_CUES: tuple[str, ...] = ("now", "urgent", "immediately", "!", "asap") + +CALM_CUES: tuple[str, ...] = ( + "whenever", + "no rush", + "no hurry", + "take your time", + "eventually", +) + +# --- state cue groups (checked blocked, then done, then working: an agent +# reporting itself blocked or finished is a stronger/more final claim than a +# generic "working on") --------------------------------------------------------- + +BLOCKED_CUES: tuple[str, ...] = ("blocked", "stuck", "waiting") +DONE_CUES: tuple[str, ...] = ("done", "finished") +WORKING_CUES: tuple[str, ...] = ("working on", "running") + + +def _cue_matches(cue: str, text: str, tokens: set[str]) -> bool: + """Whole-token match for a bare alphanumeric cue, else raw substring.""" + if cue.isalnum(): + return cue in tokens + return cue in text + + +def _any_cue(cues: tuple[str, ...], text: str, tokens: set[str]) -> bool: + return any(_cue_matches(cue, text, tokens) for cue in cues) + + +def _is_question(text: str, tokens: set[str]) -> bool: + return text.rstrip().endswith("?") or _any_cue(QUESTION_WORD_CUES, text, tokens) + + +def infer_axes(sentence: str) -> Axes: + """Infer the five expressive axes from a plain-English ``sentence``. + + Deterministic and offline: the same ``sentence`` always yields an equal + :class:`Axes`, produced purely by the module-level cue tables above (no + model, no network, no hidden state). ``intent`` always resolves to a + valid vocabulary value (default ``"ack"`` when no cue matches); the other + axes are left ``None`` when unspecified. ``identity`` is never inferred + here — the caller (the ``say`` verb) knows its own identity. + """ + text = sentence.lower() + tokens = set(_WORD_RE.findall(text)) + + if _any_cue(FAILURE_CUES, text, tokens): + intent = "failure" + elif _any_cue(SUCCESS_CUES, text, tokens): + intent = "success" + elif _any_cue(HANDOFF_CUES, text, tokens): + intent = "handoff" + elif _is_question(text, tokens): + intent = "question" + elif _any_cue(THINKING_CUES, text, tokens): + intent = "thinking" + else: + intent = "ack" + + confidence: str | None = None + if _any_cue(UNCERTAINTY_CUES, text, tokens): + confidence = "low" + elif _any_cue(CERTAINTY_CUES, text, tokens): + confidence = "high" + + urgency: str | None = None + if _any_cue(URGENT_CUES, text, tokens): + urgency = "urgent" + elif _any_cue(CALM_CUES, text, tokens): + urgency = "calm" + + state: str | None = None + if _any_cue(BLOCKED_CUES, text, tokens): + state = "blocked" + elif _any_cue(DONE_CUES, text, tokens): + state = "done" + elif _any_cue(WORKING_CUES, text, tokens): + state = "working" + + return Axes(intent=intent, confidence=confidence, urgency=urgency, state=state) diff --git a/harmonics/mapping.py b/harmonics/mapping.py new file mode 100644 index 0000000..716c86d --- /dev/null +++ b/harmonics/mapping.py @@ -0,0 +1,351 @@ +"""The design spine — map an :class:`~harmonics.axes.Axes` to a note sequence. + +This is the heart of harmonics (see ``CLAUDE.md`` and the build brief, issue +#1): the inverse of TTS. Where TTS renders *words*, this renders an agent's +live **meaning** — its intent, confidence, urgency, and state — into a short, +pleasant sonic *gesture* (a :class:`~harmonics.notes.NoteEvent` sequence). + +:func:`render_gesture` is a **pure function** of ``(axes, root_pitch, +instrument)``: no clock, no randomness, no I/O, no third-party import. The same +inputs always yield an identical sequence, so the whole path is unit-testable +offline with no audio device (``tests/test_mapping.py``). + +Decoupled from identity +----------------------- +``root_pitch`` (the agent's tonal centre) and ``instrument`` (its base timbre) +are the agent's *voice print*; the caller derives them from identity and passes +them **in**. This module never reads ``axes.identity`` and never imports an +identity/signature module. **Timbre is identity; contour is intent** — so the +emitted ``voice`` is simply the passed ``instrument`` (identity owns the +timbre), while everything below the timbre — the motif shape, cadence, tempo — +is chosen from the axes. + +The intent -> motif table (the mapping spine) +--------------------------------------------- +Every gesture is built from a warm major-pentatonic ladder around the root, so +no two notes can clash. Intent picks the *contour*; the other axes shade it. + +======== ============================================ ================= ========= +intent gesture shape default ending contour +======== ============================================ ================= ========= +success rising resolved arpeggio I-III-V-I' tonic (resolved) rising +question rising, left hanging I-III-V dominant V (open) rising +failure gentle falling sigh V-III-I tonic (soft) falling +ack short two-note figure V-I tonic (resolved) compact +thinking neighbour oscillation I-II-I-II II (unsettled) hovering +handoff upward passing gesture I-V-II' high II' (open) handed up +======== ============================================ ================= ========= + +How the remaining axes shade the contour +---------------------------------------- +* **confidence -> cadence & pitch stability.** ``high`` (and ``medium``) let a + resolving intent land on the tonic; ``low`` re-points the ending to a + suspended neighbour a step above the tonic *and* inserts a wavering neighbour + tone before it (audible pitch instability). Confidence never adds dissonance. +* **urgency -> tempo, attack, repetition — never dissonance or loudness.** + ``urgent`` shortens the inter-onset gaps, crisps the notes (shorter + durations), and repeats the motif; ``calm`` stretches and legatos it. Urgency + touches only *timing and note count* — it never introduces a new pitch and + never raises the velocity ceiling, so an urgent voice is attention-grabbing + but never an alarm-clock. +* **state -> sustained vs. discrete.** ``idle`` / ``working`` lengthen and lean + sustained; ``done`` is discrete (tighter, staccato) and resolves; ``blocked`` + holds/suspends the final note. State only nudges the cadence when confidence + is unspecified (confidence, when given, wins the cadence). + +The pleasant / non-alarm contract (checkable on the note sequence) +------------------------------------------------------------------ +Three module constants make "pleasant, non-fatiguing" an assertion, not a +promise (proved in ``tests/test_mapping.py``): + +* :data:`CONSONANT_SCALE` — the only pitch classes (relative to ``root_pitch``) + any note may take. A warm major pentatonic: no semitones, no tritone. +* :data:`VELOCITY_CEILING` — no note is louder than this, ever. +* :data:`MIN_NOTE_DURATION` — the attack floor; no note is shorter (no clicks). + +Pure stdlib + :mod:`harmonics.axes` + :mod:`harmonics.notes`. No audio. +""" + +from __future__ import annotations + +from typing import NamedTuple + +from harmonics.axes import Axes +from harmonics.notes import NoteEvent + +# --- the pleasant / non-alarm constants (the honesty contract) ---------------- + +#: Allowed pitch classes relative to the root — a warm **major pentatonic** +#: (root, major 2nd, major 3rd, perfect 5th, major 6th). Every pair of these +#: is consonant: no semitone clashes and no tritone, so the palette stays +#: pleasant and non-fatiguing however it is arranged. Every emitted note's +#: ``(pitch - root_pitch) % 12`` is one of these. +CONSONANT_SCALE: tuple[int, ...] = (0, 2, 4, 7, 9) + +#: No note's velocity ever exceeds this. Urgency and (later) stress modulate +#: *within* this ceiling; they never raise it — that is what keeps an urgent +#: voice from becoming an alarm. +VELOCITY_CEILING: float = 0.8 + +#: The attack floor: no note is shorter than this many seconds, so even the +#: crispest urgent note is a tone rather than a click. +MIN_NOTE_DURATION: float = 0.06 + +#: An upper bound so held/idle notes stay musical rather than drone. +MAX_NOTE_DURATION: float = 2.0 + +#: Neutral timing at ``urgency == "normal"`` / no state, in seconds. +BASE_IOI: float = 0.22 # inter-onset interval between successive notes +BASE_DURATION: float = 0.16 # sounding length of a single note + +# --- the scale ladder --------------------------------------------------------- +# Semitone offsets from the root, every one a member of CONSONANT_SCALE. The +# ladder spans ~1.5 octaves so gestures have room to rise, fall, and hand +# upward while every rung stays consonant. + +_I, _II, _III, _V, _VI = 0, 2, 4, 7, 9 +_I8, _II8, _III8, _V8 = 12, 14, 16, 19 + +#: Ordered rungs (a couple below the root, then up past the octave). Adjacent +#: rungs are the "neighbour" of one another — used for wavering / suspension. +_LADDER: tuple[int, ...] = (-5, -3, _I, _II, _III, _V, _VI, _I8, _II8, _III8, _V8) + +# --- roles -> velocity (all comfortably under VELOCITY_CEILING) --------------- +# A note's loudness is chosen by its *role* in the gesture, never by urgency, +# so urgent and calm renders of the same axes share an identical peak velocity. + +_ROLE_VELOCITY: dict[str, float] = { + "lead": 0.60, # an ordinary melodic note + "resolve": 0.68, # the confirmed landing on the tonic + "suspend": 0.52, # an open, unresolved ending + "soft": 0.46, # a gentle note (failure sigh, thinking) + "neighbour": 0.42, # a wavering / ornamental neighbour tone +} + + +class _Step(NamedTuple): + """One rung of a motif: a scale ``degree`` (semitones from root) + role.""" + + degree: int + role: str + + +# --- the intent -> motif table (see the module docstring) --------------------- + +_BASE_MOTIFS: dict[str, tuple[_Step, ...]] = { + # rising resolved arpeggio, lands on the octave tonic + "success": ( + _Step(_I, "lead"), + _Step(_III, "lead"), + _Step(_V, "lead"), + _Step(_I8, "resolve"), + ), + # rises and is left hanging on the dominant (a half-cadence "?") + "question": ( + _Step(_I, "lead"), + _Step(_III, "lead"), + _Step(_V, "suspend"), + ), + # a gentle falling sigh, soft throughout, settling on the tonic + "failure": ( + _Step(_V, "soft"), + _Step(_III, "soft"), + _Step(_I, "soft"), + ), + # a compact two-note "got it", resolving home + "ack": ( + _Step(_V, "lead"), + _Step(_I, "resolve"), + ), + # a tentative neighbour oscillation, hovering in a narrow band + "thinking": ( + _Step(_I, "soft"), + _Step(_II, "neighbour"), + _Step(_I, "soft"), + _Step(_II, "neighbour"), + ), + # a passing gesture that hands the line upward past the octave + "handoff": ( + _Step(_I, "lead"), + _Step(_V, "lead"), + _Step(_II8, "suspend"), + ), +} + +# --- urgency & state timing profiles ------------------------------------------ + + +class _Urgency(NamedTuple): + ioi: float # inter-onset multiplier (smaller = faster) + dur: float # duration multiplier (smaller = crisper attack) + reps: int # how many times the motif repeats + + +class _State(NamedTuple): + dur: float # duration multiplier (larger = more sustained) + gap: float # inter-onset multiplier + hold: float # extra multiplier on the *final* note (held/suspended) + + +#: Urgency shapes only timing and repetition — never pitch or velocity. +_URGENCY: dict[str | None, _Urgency] = { + None: _Urgency(ioi=1.0, dur=1.0, reps=1), + "normal": _Urgency(ioi=1.0, dur=1.0, reps=1), + "calm": _Urgency(ioi=1.6, dur=1.7, reps=1), # slower, legato, softer attack + "urgent": _Urgency(ioi=0.55, dur=0.7, reps=2), # tighter, crisper, repeated +} + +#: State shapes the sustained-vs-discrete character. +_STATE: dict[str | None, _State] = { + None: _State(dur=1.0, gap=1.0, hold=1.0), + "idle": _State(dur=1.7, gap=1.2, hold=1.0), # long, sustained + "working": _State(dur=1.3, gap=1.0, hold=1.0), # leaning sustained + "blocked": _State(dur=1.4, gap=1.1, hold=2.2), # held / suspended tail + "done": _State(dur=0.8, gap=0.85, hold=1.0), # discrete / staccato +} + +_RESOLVING_INTENTS = frozenset({"success", "ack", "failure"}) + + +# --- small musical helpers ---------------------------------------------------- + + +def _neighbour_degree(degree: int) -> int: + """The adjacent rung on the ladder — always a consonant step away.""" + if degree in _LADDER: + idx = _LADDER.index(degree) + return _LADDER[idx + 1] if idx + 1 < len(_LADDER) else _LADDER[idx - 1] + return min((d for d in _LADDER if d != degree), key=lambda d: abs(d - degree)) + + +def _suspend_degree(tonic_degree: int) -> int: + """A whole step above a tonic rung — the open, unresolved neighbour (still + in the scale: 0 -> 2, 12 -> 14, both pitch-class 2).""" + return tonic_degree + 2 + + +def _nearest_tonic(degree: int) -> int: + """The closest tonic rung (0 or the octave) to ``degree``.""" + return _I8 if degree >= (_I8 - _II) else _I + + +def _safe_pitch(pitch: int) -> int: + """Fold ``pitch`` into the valid MIDI range by octaves (edge guard for + extreme roots), preserving its pitch class and thus its consonance.""" + while pitch < 0: + pitch += 12 + while pitch > 127: + pitch -= 12 + return pitch + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +# --- cadence: how the gesture ends -------------------------------------------- + + +def _ends_open(intent: str, confidence: str | None, state: str | None) -> bool: + """Decide whether the gesture should end *open* (suspended, off the tonic) + or *closed* (resolved onto the tonic). + + Confidence, when given, owns the cadence: ``low`` always suspends; ``high`` + / ``medium`` let a resolving intent close and keep a suspending intent + open. When confidence is unspecified, state may nudge it (``done`` resolves, + ``blocked`` suspends); otherwise the intent's own disposition decides. + """ + if confidence == "low": + return True + if confidence in ("high", "medium"): + return intent not in _RESOLVING_INTENTS + if state == "done": + return False + if state == "blocked": + return True + return intent not in _RESOLVING_INTENTS + + +def _apply_cadence( + steps: tuple[_Step, ...], + intent: str, + confidence: str | None, + state: str | None, +) -> list[_Step]: + """Re-point the final note per the cadence, and add a wavering neighbour + tone for low confidence (audible pitch instability).""" + out = list(steps) + last = out[-1] + last_is_tonic = last.degree % 12 == 0 + open_ending = _ends_open(intent, confidence, state) + + if open_ending and last_is_tonic: + out[-1] = _Step(_suspend_degree(last.degree), "suspend") + elif not open_ending and not last_is_tonic: + out[-1] = _Step(_nearest_tonic(last.degree), "resolve") + + if confidence == "low": + final = out[-1] + out = out[:-1] + [_Step(_neighbour_degree(final.degree), "neighbour"), final] + return out + + +# --- the renderer ------------------------------------------------------------- + + +def render_gesture( + axes: Axes, + *, + root_pitch: int = 60, + instrument: str = "chime", +) -> list[NoteEvent]: + """Render ``axes`` to a note-event *gesture* — the design spine. + + ``root_pitch`` is the agent's tonal centre (the pitch of scale-degree 0) and + ``instrument`` its base timbre; both come from the agent's identity and are + passed in, keeping this mapping decoupled from identity. Everything else — + the motif shape, cadence, tempo, and dynamics — is chosen from the axes per + the table in this module's docstring. + + Pure and deterministic: identical arguments always return an equal list, so + the gesture can be asserted on offline with no audio device. + """ + urg = _URGENCY[axes.urgency] + st = _STATE[axes.state] + + steps = _apply_cadence(_BASE_MOTIFS[axes.intent], axes.intent, axes.confidence, axes.state) + steps = steps * urg.reps + + note_duration = _clamp(BASE_DURATION * urg.dur * st.dur, MIN_NOTE_DURATION, MAX_NOTE_DURATION) + ioi = BASE_IOI * urg.ioi * st.gap + + events: list[NoteEvent] = [] + onset = 0.0 + for step in steps: + events.append( + NoteEvent( + start=round(onset, 6), + duration=round(note_duration, 6), + pitch=_safe_pitch(root_pitch + step.degree), + velocity=round(min(_ROLE_VELOCITY[step.role], VELOCITY_CEILING), 6), + voice=instrument, + ) + ) + onset += ioi + + # A held/suspended tail (e.g. blocked) sustains the final note. Branch on + # the source axis (a string) rather than comparing the derived float + # multiplier for equality — "blocked" is the only state whose ``hold`` + # differs from the neutral 1.0 (see ``_STATE`` above). + if axes.state == "blocked" and events: + last = events[-1] + held = _clamp(last.duration * st.hold, MIN_NOTE_DURATION, MAX_NOTE_DURATION) + events[-1] = NoteEvent( + start=last.start, + duration=round(held, 6), + pitch=last.pitch, + velocity=last.velocity, + voice=last.voice, + ) + + return events diff --git a/harmonics/notes.py b/harmonics/notes.py new file mode 100644 index 0000000..437c4eb --- /dev/null +++ b/harmonics/notes.py @@ -0,0 +1,134 @@ +"""The note-event core — the pure text→notes representation every renderer +and every test in this project shares. + +A **gesture** (harmonics's unit of expression) is a sequence of +:class:`NoteEvent` objects: short, timed, pitched events. This one +representation serves two purposes at once, per the design spine in +``CLAUDE.md``: + +* it is the **unit-test surface** — assertions land on note sequences, not on + a speaker or a sound card; +* it is the **MIDI/robot-consumable representation** — :func:`to_midi_notes` + turns a sequence into a plain, dependency-free MIDI-like form that a synth, + a robot, or an actual MIDI-writing library downstream can consume. + +Nothing here makes sound. This module only defines the event, its validated +ranges, and lossless (de)serialization to dict / JSON / MIDI-like ticks. How +axes (see ``harmonics/axes.py``) map to notes is a different module's job. + +Pure stdlib, no third-party imports — this sits in the offline-testable core. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from typing import Any + +#: MIDI note numbers are a 7-bit value (0-127 inclusive). +_MIN_PITCH = 0 +_MAX_PITCH = 127 + +#: Velocity is normalized to the unit interval; renderers scale it themselves +#: (see :func:`to_midi_notes` for the 0-127 MIDI scaling). +_MIN_VELOCITY = 0.0 +_MAX_VELOCITY = 1.0 + + +@dataclass(frozen=True) +class NoteEvent: + """One timed, pitched event within a gesture. + + ``start`` and ``duration`` are seconds relative to the gesture's own + onset (a gesture is self-contained; it does not know its place in a + larger timeline). ``pitch`` is a MIDI note number (0-127). ``velocity`` + is normalized to ``0.0``-``1.0`` (loudness/emphasis), not the MIDI 0-127 + scale — :func:`to_midi_notes` does that scaling at the render boundary. + ``voice`` names the timbre/instrument family (e.g. ``"chime"``, + ``"flute"``, ``"pulse"``) that should render this event; it is a free-form + string here — the palette of valid voice names lives with the renderer, + not with the event. + + Frozen so a ``NoteEvent`` can be hashed / compared and never mutates + once placed in a sequence. + """ + + start: float + duration: float + pitch: int + velocity: float + voice: str + + def __post_init__(self) -> None: + if self.start < 0: + raise ValueError(f"start must be >= 0, got {self.start!r}") + if self.duration < 0: + raise ValueError(f"duration must be >= 0, got {self.duration!r}") + if not (_MIN_PITCH <= self.pitch <= _MAX_PITCH): + raise ValueError( + f"pitch must be a MIDI note number in [{_MIN_PITCH}, {_MAX_PITCH}], " + f"got {self.pitch!r}" + ) + if not (_MIN_VELOCITY <= self.velocity <= _MAX_VELOCITY): + raise ValueError( + f"velocity must be in [{_MIN_VELOCITY}, {_MAX_VELOCITY}], got {self.velocity!r}" + ) + if not self.voice.strip(): + raise ValueError("voice must be a non-empty string") + + def to_dict(self) -> dict[str, Any]: + """A plain, JSON-serializable dict of this event's fields.""" + return asdict(self) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "NoteEvent": + """Reconstruct a :class:`NoteEvent` from :meth:`to_dict` output. + + Round-trips losslessly: ``NoteEvent.from_dict(ev.to_dict()) == ev``. + """ + return cls( + start=d["start"], + duration=d["duration"], + pitch=d["pitch"], + velocity=d["velocity"], + voice=d["voice"], + ) + + +def sequence_to_json(seq: list[NoteEvent]) -> str: + """Serialize a note sequence to a stable JSON list of objects. + + ``sequence_from_json(sequence_to_json(seq)) == seq`` for any sequence. + """ + return json.dumps([ev.to_dict() for ev in seq], ensure_ascii=False) + + +def sequence_from_json(s: str) -> list[NoteEvent]: + """Parse a JSON string produced by :func:`sequence_to_json` back into a + note sequence, losslessly.""" + data = json.loads(s) + return [NoteEvent.from_dict(item) for item in data] + + +def to_midi_notes(seq: list[NoteEvent], ticks_per_second: int = 1000) -> list[dict[str, Any]]: + """Render a note sequence to a plain, dependency-free MIDI-like form. + + Each event becomes a dict with integer ``start_tick`` / ``duration_tick`` + (computed from ``start`` / ``duration`` at ``ticks_per_second`` ticks per + second) and ``velocity`` scaled from the event's normalized ``0.0``- + ``1.0`` range to the MIDI ``0``-``127`` range. This is deliberately *not* + a real MIDI file or event stream — no third-party MIDI library is + involved — just a plain representation a downstream encoder (or a robot + consuming ticks directly) can build on. + """ + notes: list[dict[str, Any]] = [] + for ev in seq: + notes.append( + { + "pitch": ev.pitch, + "start_tick": round(ev.start * ticks_per_second), + "duration_tick": round(ev.duration * ticks_per_second), + "velocity": round(ev.velocity * _MAX_PITCH), + } + ) + return notes diff --git a/harmonics/stress.py b/harmonics/stress.py new file mode 100644 index 0000000..db94c84 --- /dev/null +++ b/harmonics/stress.py @@ -0,0 +1,218 @@ +"""Expressive stress — pitch + velocity emphasis, layered within the ceiling. + +An agent EMOTEs by stressing part of its utterance the way a human raises +pitch and loudness on an emphasized word, layered *on top of* the +intent/confidence/urgency axes rather than replacing them (see the design +spine in ``CLAUDE.md`` and the build brief, issue #1). This module is that +layer: it takes a note sequence someone else already built — +:func:`harmonics.mapping.render_gesture`'s gesture, :func:`harmonics.contour +.text_contour`'s melody, or any other :class:`~harmonics.notes.NoteEvent` +list — and re-emphasizes specific notes by index, without touching the rest. + +Why there is room for this at all +---------------------------------- +:mod:`harmonics.mapping` deliberately leaves headroom: its loudest role +(``"resolve"``, a confirmed landing on the tonic) peaks at velocity ``0.68``, +comfortably under :data:`~harmonics.mapping.VELOCITY_CEILING` (``0.8``). That +gap is not slack to be trimmed — it is reserved *precisely* so stress has +somewhere to push a note louder without ever reaching for an alarm. Emphasis +is expressive, never urgent: it borrows the same never-exceeded ceiling that +:mod:`harmonics.mapping` already enforces, it just enters from below. + +Two independent knobs, applied together +---------------------------------------- +Stressing a note raises BOTH of its expressive dimensions at once: + +* **velocity** — additive boost (:data:`STRESS_VELOCITY_BOOST`), clamped so + it never exceeds the ``ceiling`` argument (:data:`~harmonics.mapping. + VELOCITY_CEILING` by default) — louder, but never past the pleasant limit; +* **pitch** — lifted a full octave (:data:`STRESS_PITCH_LIFT` = ``12`` + semitones). Adding whole octaves preserves pitch *class* + (``(pitch + 12) % 12 == pitch % 12``), so a stressed note stays on the + exact same consonant scale degree it started on — only its register + changes, never its key. + +Doing both means a note that is *already* at the ceiling still audibly +changes when stressed (it rises in pitch even though its velocity cannot +rise further) — see :func:`apply_stress`. + +The neutral baseline +--------------------- +An EMPTY ``stressed`` set/list is the documented no-op: ``apply_stress(seq, +[]) == seq``. Stress is something a caller explicitly opts a note into; it +never changes anything on its own. + +Emphasis markers in text (``parse_emphasis``) +---------------------------------------------- +:func:`parse_emphasis` recognizes two independent, documented marker +conventions in free text and reports which WORD indices they stress: + +* **Asterisks** — a single word tightly wrapped in a matching pair, e.g. + ``"*now*"``. The asterisks are stripped from the returned clean text; the + bare word remains. (Wrapping a whole phrase, e.g. ``"*a b*"``, is not + supported — each word needs its own pair.) +* **ALL-CAPS** — a word of two or more letters that is entirely upper-case, + e.g. ``"NOW"``. Nothing is stripped (case is the marker itself, not extra + syntax); a lone capital such as the pronoun "I" does not count. + +Word indices are 0-based over a tokenizer — a run of letters, digits, or +apostrophes — that intentionally MIRRORS :mod:`harmonics.contour`'s +``_WORD_RE`` convention (duplicated here rather than imported: this module's +allowed imports are stdlib + :mod:`harmonics.notes` + :mod:`harmonics.mapping` +only). Matching the convention means a stressed word index equals the note +index :func:`harmonics.contour.text_contour` would assign that same word, so +a caller can feed ``parse_emphasis``'s ``stressed`` output straight into +:func:`apply_stress` alongside a ``text_contour`` rendering of the cleaned +text. + +Pure stdlib + :mod:`harmonics.notes` + :mod:`harmonics.mapping`. No audio, no +randomness — both functions are deterministic pure functions of their inputs. +""" + +from __future__ import annotations + +import re +from dataclasses import replace + +from harmonics.mapping import VELOCITY_CEILING +from harmonics.notes import NoteEvent + +#: Additive velocity boost applied to a stressed note, before being clamped +#: to ``ceiling``. Sized against :mod:`harmonics.mapping`'s role velocities +#: (roughly ``0.42``-``0.68``, see the module docstring's headroom note): a +#: typical stressed note becomes audibly louder while the clamp keeps it from +#: ever crossing the pleasant, non-alarm ceiling. +STRESS_VELOCITY_BOOST: float = 0.15 + +#: How many semitones a stressed note's pitch is lifted: one full octave. +#: ``+12`` preserves pitch class, so a stressed note stays on the same +#: consonant scale degree — only its register changes, never its key. +STRESS_PITCH_LIFT: int = 12 + +#: MIDI's valid pitch ceiling (mirrors ``harmonics.notes.NoteEvent``'s own +#: validated range). If lifting a pitch by :data:`STRESS_PITCH_LIFT` would +#: leave the valid MIDI range, the lift is skipped for that note (the +#: velocity boost still applies) rather than folding back down an octave, +#: which would silently cancel the emphasis instead of just bounding it. +_MAX_MIDI_PITCH: int = 127 + +# --- apply_stress --------------------------------------------------------------- + + +def apply_stress( + seq: list[NoteEvent], + stressed: set[int] | list[int], + *, + ceiling: float = VELOCITY_CEILING, +) -> list[NoteEvent]: + """Return a COPY of ``seq`` with the notes at ``stressed`` indices emphasized. + + Emphasis layers onto whatever ``seq`` already is (a ``render_gesture`` + gesture, a ``text_contour`` melody, or any other note sequence) — the way + a human raises pitch and loudness on one word without changing what they + are saying. Each stressed note gets BOTH: + + * velocity raised by :data:`STRESS_VELOCITY_BOOST`, clamped so it never + exceeds ``ceiling`` (default :data:`~harmonics.mapping.VELOCITY_CEILING`) + — stress is always expressive, never an alarm; + * pitch lifted a full octave (:data:`STRESS_PITCH_LIFT` semitones), which + keeps the same pitch class (and therefore the same scale degree) — + only the register changes. Skipped if the lift would leave the valid + MIDI range (0-127); the velocity boost still applies in that case. + + Notes NOT in ``stressed`` are returned unchanged — the exact same + :class:`~harmonics.notes.NoteEvent` values (and objects) as in ``seq``. + An EMPTY ``stressed`` is the documented NEUTRAL BASELINE: + ``apply_stress(seq, []) == seq``. + + ``stressed`` may be a ``set`` or a ``list``; duplicates and indices + outside ``range(len(seq))`` are ignored, so a caller can pass a raw word + index list (e.g. straight from :func:`parse_emphasis`) without + pre-validating it. + + Pure and deterministic: the same ``(seq, stressed, ceiling)`` always + returns an equal list — no clock, no randomness. + """ + stressed_indices = set(stressed) + out: list[NoteEvent] = [] + for index, event in enumerate(seq): + if index not in stressed_indices: + out.append(event) + continue + + lifted_pitch = event.pitch + STRESS_PITCH_LIFT + if lifted_pitch > _MAX_MIDI_PITCH: + lifted_pitch = event.pitch + + boosted_velocity = min(round(event.velocity + STRESS_VELOCITY_BOOST, 6), ceiling) + + out.append(replace(event, pitch=lifted_pitch, velocity=boosted_velocity)) + + return out + + +# --- parse_emphasis --------------------------------------------------------------- + +#: Word tokenizer mirroring ``harmonics.contour``'s ``_WORD_RE`` convention (a +#: run of letters/digits/apostrophes). Duplicated rather than imported — see +#: the module docstring for why — so that a word index reported here lines +#: up with the note index ``text_contour`` would assign the same word. +_WORD_RE = re.compile(r"[A-Za-z0-9']+") + +#: The asterisk marker character. A word tightly wrapped in a matching pair +#: (no interior spaces/punctuation), e.g. ``"*now*"``, marks that word's +#: index as stressed; the asterisks are stripped from the clean text. +_ASTERISK = "*" + + +def _is_all_caps(word: str) -> bool: + """True if ``word`` is an ALL-CAPS emphasis marker. + + Requires at least two letters, every cased character upper-case, and at + least one actual letter present — so a bare digit run like ``"42"`` + never triggers it (``str.isupper()`` already requires a cased character + to be true at all), and a lone capital like the pronoun "I" is excluded + by the length check. + """ + return len(word) > 1 and word.isupper() and any(ch.isalpha() for ch in word) + + +def parse_emphasis(text: str) -> tuple[str, list[int]]: + """Parse emphasis markers out of ``text``, returning ``(clean_text, stressed)``. + + Two independent, documented marker conventions; either marks a word's + index as stressed (see the module docstring for the full rationale): + + * **Asterisks** — a word tightly wrapped in a matching pair, e.g. + ``"*now*"``. Stripped from ``clean_text``; the bare word remains. + * **ALL-CAPS** — a word of two or more letters that is entirely + upper-case, e.g. ``"NOW"``. Left exactly as written in ``clean_text`` + (case is the marker itself, nothing to strip); a lone capital such as + "I" does not count. + + Word indices are 0-based over the SAME tokenization convention + :func:`harmonics.contour.text_contour` uses (a run of letters / digits / + apostrophes) — so index ``i`` in the returned list is the index of the + note ``text_contour`` would emit for that word, letting a caller feed + ``stressed`` straight into :func:`apply_stress` alongside a + ``text_contour`` rendering of ``clean_text``. + + No markers -> ``(text, [])``, i.e. ``clean_text == text`` unchanged. + + Pure and deterministic; touches neither notes nor audio itself. + """ + matches = list(_WORD_RE.finditer(text)) + stressed: list[int] = [] + for index, match in enumerate(matches): + word = match.group() + wrapped_in_asterisks = ( + match.start() > 0 + and text[match.start() - 1] == _ASTERISK + and match.end() < len(text) + and text[match.end()] == _ASTERISK + ) + if wrapped_in_asterisks or _is_all_caps(word): + stressed.append(index) + + clean_text = text.replace(_ASTERISK, "") + return clean_text, stressed diff --git a/harmonics/variation.py b/harmonics/variation.py new file mode 100644 index 0000000..49d40c2 --- /dev/null +++ b/harmonics/variation.py @@ -0,0 +1,120 @@ +"""Deterministic micro-variation — a note sequence that never sounds robotic +twice in a row, without ever sounding random. + +A voice that renders byte-identically on every repetition reads as robotic. +harmonics answers that with DETERMINISTIC micro-variation: repeated +utterances vary subtly, but the variation is a pure function of a +caller-supplied nonce (the CLI's ``--seq``) — NEVER wall-clock, NEVER +unseeded randomness — so the whole thing stays 100% reproducible. This +generalizes league-of-agents' field-hashed variety to harmonics's note-event +core. + +Determinism is seeded with :mod:`hashlib` (SHA-256) over ``seq_nonce`` — +never Python's builtin ``hash()`` (randomized per-process for strings unless +``PYTHONHASHSEED`` is pinned, so it is *not* stable across processes) and +never :mod:`random` without an explicit seed. Given the same +``(seq, seq_nonce, amount)``, :func:`apply_variation` always returns +byte-identical output, in this process or any other. + +The variation itself stays small and pleasant, per the design spine's +"pleasant and non-fatiguing" rule: a tiny onset-timing jitter and a small +velocity delta per note, both bounded by ``amount``. Pitch, duration, voice, +note count, and note order are never touched — the varied sequence is +recognizably the *same* gesture, just not robotically identical between +repetitions. + +Pure stdlib, no third-party imports — this sits in the offline-testable +core. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import replace + +from harmonics.notes import NoteEvent + +#: The neutral-baseline nonce. ``apply_variation(seq, DEFAULT_NONCE)`` always +#: returns ``seq`` unchanged (as a new list of equal events) — callers that +#: have not opted into a ``--seq`` get the plain, unvaried rendering. +DEFAULT_NONCE: int = 0 + +#: Default variation magnitude, in ``[0.0, 1.0]``. Scales both the +#: timing-jitter and velocity-delta bounds below. ``0.0`` disables variation +#: outright (equivalent to :data:`DEFAULT_NONCE`); ``1.0`` is the maximum +#: variation this module will ever produce. Chosen small on purpose — see +#: the bound constants below for the concrete seconds/velocity envelope this +#: default implies. +DEFAULT_AMOUNT: float = 0.15 + +#: Onset-timing jitter never exceeds this many seconds at ``amount=1.0``. +#: At :data:`DEFAULT_AMOUNT` (``0.15``) the actual bound is ``0.0075``s +#: (7.5ms) — well under human perception of "off the beat". +_MAX_TIMING_JITTER_SECONDS: float = 0.05 + +#: Velocity delta never exceeds this at ``amount=1.0`` (output velocity is +#: additionally always clamped to ``[0.0, 1.0]`` regardless of this bound). +#: At :data:`DEFAULT_AMOUNT` the actual bound is ``0.03``. +_MAX_VELOCITY_DELTA: float = 0.2 + + +def _signed_unit(seq_nonce: str, index: int, salt: str) -> float: + """A value in ``[-1.0, 1.0)``, deterministic in ``(seq_nonce, index, salt)``. + + Derived from a SHA-256 digest of the three inputs joined with ``:`` — + never builtin ``hash()``, never unseeded :mod:`random` — so the result + is stable across processes, machines, and interpreter hash-seed + settings. ``salt`` lets two different derived quantities (timing vs. + velocity) for the same note draw independent-looking values from the + same nonce/index without colliding. + """ + digest = hashlib.sha256(f"{seq_nonce}:{index}:{salt}".encode("utf-8")).digest() + raw = int.from_bytes(digest[:8], byteorder="big") + unit = raw / 2**64 # [0.0, 1.0) + return unit * 2.0 - 1.0 # [-1.0, 1.0) + + +def apply_variation( + seq: list[NoteEvent], + seq_nonce: int | str, + *, + amount: float = DEFAULT_AMOUNT, +) -> list[NoteEvent]: + """Return a subtly varied COPY of ``seq``, as a pure function of ``seq_nonce``. + + ``seq_nonce`` may be an ``int`` or a ``str`` (e.g. a CLI ``--seq`` value, + or an agent turn counter); it is normalized to a string before hashing. + When ``seq_nonce == `` :data:`DEFAULT_NONCE`, the result is ``seq`` + unchanged (structurally equal — the neutral baseline). For any other + nonce, each note in the returned sequence gets its own small, independent + onset-timing jitter (bounded by ``amount * 0.05`` seconds) and velocity + delta (bounded by ``amount * 0.2``, and always clamped into + ``[0.0, 1.0]``); pitch, duration, voice, note count, and note order are + never changed, so the result is recognizably the same gesture. + + Pure function: calling this twice with the same arguments — in this + process, a fresh process, or a different machine — returns equal + results, because the only source of variation is a SHA-256 digest of + ``(seq_nonce, note index, salt)`` (see :func:`_signed_unit`), never + wall-clock time or unseeded entropy. + + Raises :class:`ValueError` if ``amount`` is outside ``[0.0, 1.0]``. + """ + if not (0.0 <= amount <= 1.0): + raise ValueError(f"amount must be in [0.0, 1.0], got {amount!r}") + + if seq_nonce == DEFAULT_NONCE: + return list(seq) + + nonce_str = str(seq_nonce) + varied: list[NoteEvent] = [] + for index, ev in enumerate(seq): + timing_jitter = _signed_unit(nonce_str, index, "t") * amount * _MAX_TIMING_JITTER_SECONDS + velocity_delta = _signed_unit(nonce_str, index, "v") * amount * _MAX_VELOCITY_DELTA + + new_start = max(0.0, ev.start + timing_jitter) + new_velocity = min(1.0, max(0.0, ev.velocity + velocity_delta)) + + varied.append(replace(ev, start=new_start, velocity=new_velocity)) + + return varied diff --git a/pyproject.toml b/pyproject.toml index 2791092..2d8e8fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harmonics-cli" -version = "0.4.1" +version = "0.5.0" description = "An agent + CLI giving agents and robots non-TTS (non-speech) audio: express meaning through pleasant sonic signatures — chimes, flutes, pulses, and tonal motifs mapped to intent, confidence, urgency, state, and identity. Turns text/sentences into notes and audio (text-to-notes / text-to-audio)." readme = "README.md" license = "Apache-2.0" diff --git a/tests/ear_harness.py b/tests/ear_harness.py new file mode 100644 index 0000000..253e53e --- /dev/null +++ b/tests/ear_harness.py @@ -0,0 +1,163 @@ +"""Reusable offline assertions for note sequences (the "ear harness"). + +This module is the offline half of the two-part measurement the project's +honesty conditions require for audio claims (see +``docs/ear-test-protocol.md`` for the human half — the blind-listening +protocol). It is a plain, importable helper module, **not** a test module +itself: pytest will not collect it (its name doesn't match ``test_*.py`` / +``*_test.py``), and it defines no ``test_*`` functions. Other test modules +import from it and call its assertions on the sequences they build or +render. + +Every helper here operates on the generic :class:`~harmonics.notes.NoteEvent` +model only — it has no dependency on any axes/mapping/contour module, on +purpose: a gesture is "well-formed" or "deterministic" independent of how its +notes were chosen. + +Pure stdlib + ``harmonics.notes`` — no third-party imports, so this module +stays importable with no audio device, matching the offline-testable-core +rule in ``CLAUDE.md``. +""" + +from __future__ import annotations + +import importlib +import sys +from typing import Any, Callable + +from harmonics.notes import NoteEvent + +#: MIDI note numbers are a 7-bit value (0-127 inclusive); mirrors +#: harmonics/notes.py's own range so this module doesn't need to import +#: its private constants. +_MIN_PITCH = 0 +_MAX_PITCH = 127 + +#: Velocity is normalized to the unit interval. +_MIN_VELOCITY = 0.0 +_MAX_VELOCITY = 1.0 + +#: Third-party modules known to be part of an audio/sound stack. Matched by +#: top-level module name. This list is a belt-and-suspenders complement to +#: the substring heuristic in :func:`assert_offline_no_audio` below — it +#: catches names (like ``pygame``) that the "audio"/"sound" substring check +#: would otherwise miss. +_KNOWN_AUDIO_MODULES = frozenset( + { + "sounddevice", + "simpleaudio", + "pyaudio", + "pygame", + "playsound", + "pydub", + "ossaudiodev", + "winsound", + "alsaaudio", + "pyalsaaudio", + "rtmidi", + "mido", + "miniaudio", + "soundfile", + "librosa", + } +) + + +def assert_well_formed(seq: list[NoteEvent]) -> None: + """Assert a note sequence is structurally valid. + + Re-checks every invariant :class:`~harmonics.notes.NoteEvent` normally + enforces at construction time, independent of that enforcement — a + sequence handed to this helper may contain events built by means other + than the validated constructor (e.g. deserialized from an untrusted + source, or hand-built for a test). Checks, in order: + + * the sequence is non-empty; + * every event's onset (``start``) is non-negative; + * every event's ``duration`` is non-negative; + * every event's ``velocity`` lies in ``[0.0, 1.0]``; + * every event's ``pitch`` is a MIDI note number in ``[0, 127]``. + + Deliberately does **not** require onsets to be sorted/monotonic across + events: a gesture may contain overlapping or simultaneous events (a + chord, or two parallel voices), which is legitimate. + + Raises ``AssertionError`` naming the offending index and field on the + first violation found. + """ + assert seq, "note sequence must be non-empty" + for i, ev in enumerate(seq): + assert ev.start >= 0, f"event {i}: start must be >= 0, got {ev.start!r}" + assert ev.duration >= 0, f"event {i}: duration must be >= 0, got {ev.duration!r}" + assert _MIN_VELOCITY <= ev.velocity <= _MAX_VELOCITY, ( + f"event {i}: velocity must be in [{_MIN_VELOCITY}, {_MAX_VELOCITY}], " + f"got {ev.velocity!r}" + ) + assert ( + _MIN_PITCH <= ev.pitch <= _MAX_PITCH + ), f"event {i}: pitch must be in [{_MIN_PITCH}, {_MAX_PITCH}], got {ev.pitch!r}" + + +def assert_deterministic( + render_callable: Callable[..., list[NoteEvent]], *args: Any, **kwargs: Any +) -> list[NoteEvent]: + """Assert two calls to ``render_callable(*args, **kwargs)`` are identical. + + Renderers in this project are meant to be pure functions of their + inputs — no wall-clock, no randomness, no hidden state — so calling one + twice with identical arguments must yield an identical sequence, field + for field. ``NoteEvent`` is a frozen dataclass, so list/element equality + is exact value equality; no custom comparator is needed. + + Returns the (shared) sequence on success, so a caller can chain further + assertions (e.g. :func:`assert_well_formed`) without rendering a third + time. + """ + first = render_callable(*args, **kwargs) + second = render_callable(*args, **kwargs) + assert first == second, ( + "render_callable is not deterministic: two calls with identical " + f"arguments produced different sequences:\n{first!r}\n!=\n{second!r}" + ) + return first + + +def assert_offline_no_audio(module_name: str) -> None: + """Assert importing ``module_name`` pulls in no third-party audio library. + + Forces a fresh import of ``module_name`` and inspects every module that + import newly loaded into ``sys.modules`` as a result. Fails if any newly + loaded top-level module name either is a :data:`_KNOWN_AUDIO_MODULES` + entry, or merely *looks like* a sound stack (contains ``"audio"`` or + ``"sound"``, case-insensitively) — covering both known libraries + (``sounddevice``, ``simpleaudio``, ``pyaudio``, ``pygame``, ...) and + unanticipated ones that follow the same naming convention. + + Modules under the stdlib or under the ``harmonics`` package itself are + always allowed — this includes the stdlib ``wave`` module, which only + writes WAV *files* and never touches a sound device. + + This is the same technique ``tests/test_notes.py`` uses inline for + ``harmonics.notes``, generalized into a reusable helper so other modules + (mapping, rendering, CLI verbs) can make the same offline-import claim + without duplicating the logic. + """ + stdlib_names = set(sys.stdlib_module_names) + before = set(sys.modules) + + sys.modules.pop(module_name, None) + importlib.import_module(module_name) + + newly_imported = set(sys.modules) - before + + for mod_name in newly_imported: + top_level = mod_name.split(".")[0] + if top_level == "harmonics" or top_level in stdlib_names or mod_name in stdlib_names: + continue + assert ( + top_level not in _KNOWN_AUDIO_MODULES + ), f"{module_name!r} pulled in known audio-stack module {mod_name!r}" + lowered = top_level.lower() + assert "audio" not in lowered and "sound" not in lowered, ( + f"{module_name!r} pulled in a module that looks like an audio " f"stack: {mod_name!r}" + ) diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..d776fe4 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,347 @@ +"""Tests for the offline audio backend (``harmonics/audio/``, cycle t12). + +Covers the acceptance criteria for the last domain increment: a pure-stdlib +WAV renderer (:func:`~harmonics.audio.render_wav`) that is deterministic and +produces a valid WAV, a file-writing wrapper +(:func:`~harmonics.audio.write_wav`), a live-playback entry point +(:func:`~harmonics.audio.play`) whose optional backend import is isolated and +lazy, and that importing :mod:`harmonics.audio` / :mod:`harmonics.audio.synth` +never requires a third-party or audio-device module. +""" + +from __future__ import annotations + +import io +import struct +import sys +import wave +from array import array + +import pytest + +from harmonics.audio import DEFAULT_SAMPLE_RATE, play, render_wav, write_wav +from harmonics.audio.synth import ARTICULATIONS +from harmonics.cli._errors import EXIT_ENV_ERROR, CliError +from harmonics.notes import NoteEvent +from tests.ear_harness import assert_offline_no_audio + + +def _seq() -> list[NoteEvent]: + return [ + NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.8, voice="chime"), + NoteEvent(start=0.2, duration=0.15, pitch=64, velocity=0.6, voice="flute"), + NoteEvent(start=0.35, duration=0.3, pitch=67, velocity=0.9, voice="pulse"), + ] + + +# --- render_wav: deterministic, valid WAV (criterion 1) --------------------- + + +def test_render_wav_is_deterministic() -> None: + seq = _seq() + first = render_wav(seq) + second = render_wav(seq) + assert first == second + + +def test_render_wav_is_a_valid_wav_file() -> None: + data = render_wav(_seq()) + with wave.open(io.BytesIO(data), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getframerate() == DEFAULT_SAMPLE_RATE + assert wf.getnframes() > 0 + + +def test_render_wav_respects_custom_sample_rate() -> None: + data = render_wav(_seq(), sample_rate=22050) + with wave.open(io.BytesIO(data), "rb") as wf: + assert wf.getframerate() == 22050 + + +def test_render_wav_different_sequences_differ() -> None: + seq_a = [NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.8, voice="chime")] + seq_b = [NoteEvent(start=0.0, duration=0.2, pitch=72, velocity=0.8, voice="chime")] + assert render_wav(seq_a) != render_wav(seq_b) + + +def test_render_wav_empty_sequence_is_a_valid_empty_wav() -> None: + data = render_wav([]) + with wave.open(io.BytesIO(data), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getnframes() == 0 + + +def test_render_wav_frame_count_covers_the_last_note() -> None: + seq = [NoteEvent(start=1.0, duration=0.5, pitch=60, velocity=0.7, voice="bell")] + data = render_wav(seq, sample_rate=1000) + with wave.open(io.BytesIO(data), "rb") as wf: + # start=1.0s + duration=0.5s at 1000 Hz -> at least 1500 frames. + assert wf.getnframes() >= 1500 + + +# --- articulation: discrete (backward compat), speechy/smooth/alien (glide) -- + + +def test_articulations_table_has_the_four_named_styles() -> None: + assert set(ARTICULATIONS) == {"discrete", "speechy", "smooth", "alien"} + assert ARTICULATIONS["discrete"] is None + + +def test_render_wav_default_articulation_is_discrete() -> None: + """Backward compat: the bare ``render_wav(seq)`` call (no ``articulation`` + kwarg) must still be byte-identical to explicit ``articulation="discrete"`` + -- and to every prior release's output, since ``discrete`` is unchanged.""" + seq = _seq() + assert render_wav(seq) == render_wav(seq, articulation="discrete") + + +@pytest.mark.parametrize("articulation", sorted(ARTICULATIONS)) +def test_render_wav_articulation_is_deterministic(articulation: str) -> None: + seq = _seq() + first = render_wav(seq, articulation=articulation) + second = render_wav(seq, articulation=articulation) + assert first == second + + +@pytest.mark.parametrize("articulation", sorted(ARTICULATIONS)) +def test_render_wav_articulation_is_a_valid_wav(articulation: str) -> None: + data = render_wav(_seq(), articulation=articulation) + with wave.open(io.BytesIO(data), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getframerate() == DEFAULT_SAMPLE_RATE + assert wf.getnframes() > 0 + + +def test_render_wav_articulation_styles_all_differ_from_each_other() -> None: + seq = _seq() + renders = [render_wav(seq, articulation=name) for name in sorted(ARTICULATIONS)] + for i, a in enumerate(renders): + for b in renders[i + 1 :]: + assert a != b + + +def test_render_wav_glide_styles_never_go_silent_between_notes() -> None: + """A glide articulation is one continuous oscillator -- unlike the + discrete path, it should never render a run of exact-zero samples + between two notes (the whole point of legato).""" + seq = _seq() + data = render_wav(seq, articulation="smooth") + with wave.open(io.BytesIO(data), "rb") as wf: + frames = wf.readframes(wf.getnframes()) + samples = array("h") + samples.frombytes(frames) + if sys.byteorder == "big": + samples.byteswap() + # Sample right at the gap between note 0 (ends 0.2s) and note 1 (starts + # 0.2s) -- the discrete synth would be silent/near-silent there. + gap_index = int(0.2 * DEFAULT_SAMPLE_RATE) + assert any(abs(s) > 50 for s in samples[gap_index - 5 : gap_index + 5]) + + +def test_render_wav_unknown_articulation_raises_value_error() -> None: + with pytest.raises(ValueError): + render_wav(_seq(), articulation="robotic") + + +def test_write_wav_threads_articulation_through_to_render(tmp_path) -> None: + seq = _seq() + target = tmp_path / "gesture.wav" + write_wav(seq, target, articulation="alien") + assert target.read_bytes() == render_wav(seq, articulation="alien") + + +def test_play_threads_articulation_through_to_render(monkeypatch: pytest.MonkeyPatch) -> None: + fake_module = type(sys)("simpleaudio") + fake_module.WaveObject = _FakeWaveObject # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + + seq = _seq() + play(seq, articulation="alien") + + with wave.open(io.BytesIO(render_wav(seq, articulation="alien")), "rb") as wf: + expected_frames = wf.readframes(wf.getnframes()) + assert _FakeWaveObject.last_call is not None + audio_data, *_rest = _FakeWaveObject.last_call + assert audio_data == expected_frames + + +def test_play_unknown_articulation_raises_value_error_before_touching_backend() -> None: + with pytest.raises(ValueError): + play(_seq(), articulation="robotic") + + +# --- write_wav: writes a real file on disk ----------------------------------- + + +def test_write_wav_writes_a_valid_wav_file(tmp_path) -> None: + target = tmp_path / "gesture.wav" + write_wav(_seq(), target) + assert target.is_file() + with wave.open(str(target), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getnframes() > 0 + + +def test_write_wav_matches_render_wav_bytes(tmp_path) -> None: + seq = _seq() + target = tmp_path / "gesture.wav" + write_wav(seq, target) + assert target.read_bytes() == render_wav(seq) + + +def test_write_wav_accepts_a_string_path(tmp_path) -> None: + target = tmp_path / "gesture.wav" + write_wav(_seq(), str(target)) + assert target.is_file() + + +# --- isolation: importing the package/module needs no third-party (criterion 2) -- + + +def test_importing_harmonics_audio_pulls_in_no_third_party() -> None: + assert_offline_no_audio("harmonics.audio") + + +def test_importing_harmonics_audio_synth_pulls_in_no_third_party() -> None: + assert_offline_no_audio("harmonics.audio.synth") + + +# --- play(): no backend installed -> friendly CliError (criterion 4) -------- + + +def test_play_with_no_backend_raises_cli_error() -> None: + with pytest.raises(CliError) as exc: + play(_seq()) + assert exc.value.code == EXIT_ENV_ERROR + assert "no audio playback backend" in exc.value.message + assert "simpleaudio" in exc.value.remediation + assert "sounddevice" in exc.value.remediation + assert "--wav" in exc.value.remediation + + +# --- play(): the lazy-import dispatch actually drives a backend when present ----- + + +class _FakePlayObj: + def __init__(self) -> None: + self.waited = False + + def wait_done(self) -> None: + self.waited = True + + +class _FakeWaveObject: + """Stand-in for ``simpleaudio.WaveObject`` recording how it was built.""" + + last_call: tuple | None = None + last_play_obj: "_FakePlayObj | None" = None + + def __init__( + self, audio_data: bytes, num_channels: int, bytes_per_sample: int, sample_rate: int + ): + type(self).last_call = (audio_data, num_channels, bytes_per_sample, sample_rate) + + def play(self) -> _FakePlayObj: + obj = _FakePlayObj() + type(self).last_play_obj = obj + return obj + + +def test_play_uses_simpleaudio_when_available(monkeypatch: pytest.MonkeyPatch) -> None: + fake_module = type(sys)("simpleaudio") + fake_module.WaveObject = _FakeWaveObject # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + + seq = _seq() + play(seq) + + assert _FakeWaveObject.last_call is not None + audio_data, num_channels, bytes_per_sample, sample_rate = _FakeWaveObject.last_call + assert num_channels == 1 + assert bytes_per_sample == 2 + assert sample_rate == DEFAULT_SAMPLE_RATE + # the raw PCM frames handed to simpleaudio must be the same bytes + # render_wav would have produced (minus the WAV header). + with wave.open(io.BytesIO(render_wav(seq)), "rb") as wf: + expected_frames = wf.readframes(wf.getnframes()) + assert audio_data == expected_frames + assert _FakeWaveObject.last_play_obj is not None + assert _FakeWaveObject.last_play_obj.waited is True + + +class _FakeSoundDevice: + def __init__(self) -> None: + self.played: tuple | None = None + self.waited = False + + def play(self, samples, samplerate) -> None: # noqa: ANN001 - test double + self.played = (samples, samplerate) + + def wait(self) -> None: + self.waited = True + + +def test_play_falls_back_to_sounddevice_when_simpleaudio_absent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + fake_sd = _FakeSoundDevice() + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + seq = _seq() + play(seq) + + assert fake_sd.played is not None + samples, samplerate = fake_sd.played + assert samplerate == DEFAULT_SAMPLE_RATE + assert len(samples) > 0 + assert fake_sd.waited is True + + +# --- play(): sounddevice path decodes correctly on a big-endian host -------- + + +def test_play_sounddevice_byteswaps_samples_on_big_endian_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The sounddevice branch must undo ``_quantize``'s little-endian + encoding on a big-endian host, or every sample is misread. + + ``_quantize`` always emits little-endian 16-bit PCM (it byteswaps its own + native storage when ``sys.byteorder == "big"`` so the WAV bytes stay + portable). ``array.frombytes`` has no notion of byte order of its own — + it blits raw bytes into the array's native storage — so reading those + always-LE frames back on a real big-endian host silently misinterprets + every value unless the same byteswap is undone on the way in. + + Byteswapping is self-inverse, so forcing ``sys.byteorder`` to ``"big"`` + here exercises both legs (the encode in ``_quantize`` and the decode + fix in ``play``) symmetrically and round-trips to the correct sample + values on ANY real host -- this test is honest whether the runner is + genuinely little- or big-endian. + """ + seq = _seq() + + # Ground truth: obtained *before* any byteorder patching, so this + # reflects this real host's genuinely-correct render — and the WAV + # format is always little-endian PCM, so decoding with an explicit " None: + ax = Axes(intent="success") + assert ax.intent == "success" + assert ax.confidence is None and ax.urgency is None and ax.state is None + assert ax.identity is None + + +def test_full_axes_roundtrips_to_dict() -> None: + ax = Axes( + intent="question", + confidence="low", + urgency="calm", + state="working", + identity="harmonics-cli", + ) + assert ax.as_dict() == { + "intent": "question", + "confidence": "low", + "urgency": "calm", + "state": "working", + "identity": "harmonics-cli", + } + + +def test_frozen_and_hashable() -> None: + ax = Axes(intent="ack") + assert hash(ax) == hash(Axes(intent="ack")) + with pytest.raises(FrozenInstanceError): + ax.intent = "failure" # type: ignore[misc] + + +def test_with_replaces_and_validates() -> None: + ax = Axes(intent="thinking").with_(confidence="high") + assert ax.confidence == "high" + assert ax.intent == "thinking" + + +@pytest.mark.parametrize("bad", ["", "shout", "SUCCESS", "acknowledge"]) +def test_unknown_intent_rejected(bad: str) -> None: + with pytest.raises(ValueError): + Axes(intent=bad) + + +def test_unknown_confidence_rejected() -> None: + with pytest.raises(ValueError): + Axes(intent="success", confidence="very-high") + + +def test_blank_identity_rejected() -> None: + with pytest.raises(ValueError): + Axes(intent="success", identity=" ") + + +def test_vocabulary_is_stable() -> None: + # The spine's example values (issue #1) must stay in the vocabulary. + assert {"ack", "question", "success", "failure", "thinking", "handoff"} <= set(INTENTS) + assert CONFIDENCES == ("low", "medium", "high") + assert URGENCIES == ("calm", "normal", "urgent") + assert STATES == ("idle", "working", "blocked", "done") diff --git a/tests/test_contour.py b/tests/test_contour.py new file mode 100644 index 0000000..9b3e1ea --- /dev/null +++ b/tests/test_contour.py @@ -0,0 +1,264 @@ +"""Tests for the text -> melodic contour core (harmonics/contour.py). + +Covers the five acceptance criteria of the "voice tracks the text" surface: + +1. **Determinism** — the same sentence yields an equal contour across calls and + across processes (seeded with :mod:`hashlib`, not builtin ``hash()``), pinned + to a known golden contour. +2. **Text-tracking (legibility proxy)** — one melodic event per word, two + different words usually land on different scale degrees, and two different + sentences produce different contours (the tune carries text, not a constant + motif). +3. **In-key & pleasant** — every pitch class is in :data:`CONSONANT_SCALE` and + every velocity is at or below :data:`VELOCITY_CEILING`. +4. **Non-speech / offline** — the output is a plain ``list[NoteEvent]`` and the + module imports no third-party/audio package. +5. **Edge cases** — empty and punctuation-only input return a well-formed + (empty) sequence without error. +""" + +from __future__ import annotations + +import ast +import hashlib +import itertools +import sys +from pathlib import Path + +import pytest + +from harmonics import contour as contour_module +from harmonics.contour import text_contour +from harmonics.mapping import CONSONANT_SCALE, VELOCITY_CEILING +from harmonics.notes import NoteEvent + +# A small corpus of distinct words for the statistical text-tracking checks. +_CORPUS = [ + "the", + "quick", + "brown", + "fox", + "jumps", + "over", + "lazy", + "dog", + "tests", + "green", + "done", + "hello", + "world", + "system", + "voice", +] + + +# --- 1. determinism ---------------------------------------------------------------- + + +@pytest.mark.parametrize( + "sentence", + ["done tests all green", "hello world", "handing off to the next agent", "one"], +) +def test_same_sentence_yields_equal_contour(sentence: str) -> None: + assert text_contour(sentence) == text_contour(sentence) + + +def test_determinism_does_not_depend_on_time_or_entropy() -> None: + # If the mapping leaned on wall-clock or unseeded randomness, calling it + # many times in a tight loop would eventually disagree with itself. + results = {tuple(text_contour("done tests all green")) for _ in range(25)} + assert len(results) == 1 + + +def test_contour_is_seeded_with_hashlib_not_builtin_hash() -> None: + """Pin a known golden contour, recomputed here independently via hashlib. + + Python's builtin ``hash()`` is randomized per process for strings (via + ``PYTHONHASHSEED``), so it could never reproduce these fixed pitches across + process runs; a match is only possible via a stable digest like SHA-256 + over the literal lowercased word. + """ + events = text_contour("done tests all green") + + # Independent recomputation of the documented degree derivation. + def expected_degree(word: str) -> int: + digest = hashlib.sha256(word.lower().encode("utf-8")).digest() + return CONSONANT_SCALE[int.from_bytes(digest[:4], "big") % len(CONSONANT_SCALE)] + + def expected_octave(word: str) -> int: + n = len(word) + return -12 if n <= 3 else (0 if n <= 6 else 12) + + for event, word in zip(events, ["done", "tests", "all", "green"]): + assert event.pitch == 60 + expected_octave(word) + expected_degree(word) + + # A literal pinned golden (regression pin): if the hashing primitive, the + # scale, or the length->octave rule ever changes, this catches it. + assert [ev.pitch for ev in events] == [60, 64, 50, 62] + assert events[0].velocity == pytest.approx(0.681961) + assert [ev.start for ev in events] == pytest.approx([0.0, 0.22, 0.44, 0.66]) + + +def test_root_pitch_transposes_the_whole_contour() -> None: + low = text_contour("done tests all green", root_pitch=60) + high = text_contour("done tests all green", root_pitch=72) + assert [ev.pitch for ev in high] == [ev.pitch + 12 for ev in low] + + +# --- 2. text-tracking (legibility proxy) ------------------------------------------- + + +@pytest.mark.parametrize( + "sentence, n_words", + [ + ("done tests all green", 4), + ("hello", 1), + ("the quick brown fox jumps", 5), + ("a, b; c. d! e?", 5), + ], +) +def test_one_note_per_word(sentence: str, n_words: int) -> None: + # THE legibility invariant: the number of melodic events equals the number + # of word units in the sentence, so a listener can follow word by word. + assert len(text_contour(sentence)) == n_words + + +def test_most_distinct_words_land_on_distinct_degrees() -> None: + # With only five scale degrees some words must collide (pigeonhole), but a + # documented *majority* of distinct-word pairs differ, so the contour + # actually carries text rather than repeating one tune. + degree_by_word = {word: (text_contour(word)[0].pitch - 60) % 12 for word in _CORPUS} + pairs = list(itertools.combinations(_CORPUS, 2)) + differing = sum(1 for a, b in pairs if degree_by_word[a] != degree_by_word[b]) + assert differing / len(pairs) > 0.5 + + +def test_different_sentences_produce_different_contours() -> None: + assert text_contour("done tests all green") != text_contour("blocked on the network") + # Even a reordering of the same words re-voices the line differently. + assert text_contour("hello brave world") != text_contour("world brave hello") + + +def test_word_length_lifts_register() -> None: + # A short word sits an octave below a long one for the same root. + short = text_contour("hi")[0].pitch + long = text_contour("celebration")[0].pitch + assert long - short >= 12 + + +def test_punctuation_inserts_rests() -> None: + plain = text_contour("done tests") + clause = text_contour("done, tests") + terminal = text_contour("done. tests") + # The first note is unchanged; the second note's onset is pushed later by + # a clause mark and later still by a terminal mark. + assert plain[0].start == clause[0].start == terminal[0].start == 0.0 + assert plain[1].start < clause[1].start < terminal[1].start + + +# --- 3. in-key & pleasant ---------------------------------------------------------- + + +@pytest.mark.parametrize( + "sentence", + [ + "done tests all green", + "handing off to the next agent now", + "hmm, i am not entirely sure about this", + "The Quick Brown Fox!", + "42 is the answer, isn't it?", + ], +) +def test_every_pitch_is_in_the_consonant_scale(sentence: str) -> None: + for event in text_contour(sentence): + assert (event.pitch - 60) % 12 in CONSONANT_SCALE + + +@pytest.mark.parametrize("root_pitch", [36, 48, 60, 72, 84]) +def test_in_key_for_any_root(root_pitch: int) -> None: + for event in text_contour("done tests all green now", root_pitch=root_pitch): + assert (event.pitch - root_pitch) % 12 in CONSONANT_SCALE + + +@pytest.mark.parametrize( + "sentence", + ["done tests all green", "URGENT urgent URGENT loud shouting words", "a b c d e f g"], +) +def test_every_velocity_is_within_the_ceiling(sentence: str) -> None: + for event in text_contour(sentence): + assert 0.0 <= event.velocity <= VELOCITY_CEILING + + +def test_pitches_stay_in_valid_midi_range_for_extreme_roots() -> None: + for root_pitch in (0, 1, 126, 127): + for event in text_contour("a very long celebration indeed", root_pitch=root_pitch): + assert 0 <= event.pitch <= 127 + assert (event.pitch - root_pitch) % 12 in CONSONANT_SCALE + + +# --- 4. non-speech / offline ------------------------------------------------------- + + +def test_output_is_a_plain_list_of_note_events() -> None: + result = text_contour("done tests all green") + assert isinstance(result, list) + assert all(isinstance(event, NoteEvent) for event in result) + + +def test_instrument_sets_the_voice() -> None: + for event in text_contour("done tests", instrument="chime"): + assert event.voice == "chime" + + +def test_contour_module_imports_only_stdlib_and_harmonics() -> None: + """Static-parse the module's own imports and assert every top-level import + root is stdlib or ``harmonics`` — the core must import no audio stack.""" + source = Path(contour_module.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + if node.module and node.level == 0: + modules.add(node.module.split(".")[0]) + stdlib = set(sys.stdlib_module_names) + disallowed = {m for m in modules if m != "harmonics" and m not in stdlib} + assert not disallowed, f"non-stdlib/non-harmonics imports found: {disallowed}" + + +def test_contour_module_mentions_no_audio_or_network_libraries() -> None: + source = Path(contour_module.__file__).read_text(encoding="utf-8").lower() + forbidden = ( + "sounddevice", + "simpleaudio", + "portaudio", + "pyaudio", + "soundfile", + "wave", + "requests", + "urllib", + "socket", + ) + for name in forbidden: + assert name not in source, f"found forbidden reference: {name}" + + +# --- 5. edge cases ----------------------------------------------------------------- + + +@pytest.mark.parametrize("sentence", ["", " ", "\t\n", "...", "!?.,;:", "-- --", "***"]) +def test_empty_or_punctuation_only_returns_empty_sequence(sentence: str) -> None: + result = text_contour(sentence) + assert result == [] + + +def test_single_word_returns_single_note() -> None: + result = text_contour("done") + assert len(result) == 1 + assert result[0].start == 0.0 + + +def test_leading_and_trailing_punctuation_is_ignored_for_count() -> None: + assert len(text_contour(" ...done, tests!!! ")) == 2 diff --git a/tests/test_ear_harness.py b/tests/test_ear_harness.py new file mode 100644 index 0000000..d820c55 --- /dev/null +++ b/tests/test_ear_harness.py @@ -0,0 +1,182 @@ +"""Tests for the ear harness (tests/ear_harness.py), the offline half of the +project's audio-claim measurement (the human half is +``docs/ear-test-protocol.md``). + +Exercises every helper against a hand-built ``list[NoteEvent]`` only — no +mapping/contour module, no audio device, no third-party import. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from harmonics.notes import NoteEvent +from tests.ear_harness import ( + assert_deterministic, + assert_offline_no_audio, + assert_well_formed, +) + + +def _make_event(**overrides: object) -> NoteEvent: + fields: dict[str, object] = { + "start": 0.0, + "duration": 0.25, + "pitch": 60, + "velocity": 0.8, + "voice": "chime", + } + fields.update(overrides) + return NoteEvent(**fields) # type: ignore[arg-type] + + +def _bypass_validation(**fields: object) -> NoteEvent: + """Build a ``NoteEvent`` bypassing ``__post_init__`` entirely. + + ``NoteEvent`` is a frozen dataclass that validates every field in + ``__post_init__``, so calling ``NoteEvent(...)`` directly can never + produce a malformed instance -- there is no way to construct a + bad-velocity or bad-pitch event through the normal constructor. To + exercise ``assert_well_formed``'s *own* checks (as opposed to + re-testing ``NoteEvent`` construction, which ``tests/test_notes.py`` + already covers), this helper bypasses ``__init__``/``__post_init__`` + with ``object.__new__`` + ``object.__setattr__`` (the latter needed + because the dataclass is frozen) to hand-build an event with whatever + field values are given, valid or not. + """ + base: dict[str, object] = { + "start": 0.0, + "duration": 0.25, + "pitch": 60, + "velocity": 0.8, + "voice": "chime", + } + base.update(fields) + ev = NoteEvent.__new__(NoteEvent) + for name, value in base.items(): + object.__setattr__(ev, name, value) + return ev + + +def test_assert_well_formed_accepts_a_hand_built_sequence() -> None: + seq = [ + _make_event(start=0.0, pitch=60, voice="chime"), + _make_event(start=0.25, pitch=64, voice="flute"), + _make_event(start=0.5, pitch=67, voice="pulse"), + ] + assert_well_formed(seq) # must not raise + + +def test_assert_well_formed_rejects_empty_sequence() -> None: + with pytest.raises(AssertionError, match="non-empty"): + assert_well_formed([]) + + +def test_assert_well_formed_rejects_bad_velocity() -> None: + seq = [_bypass_validation(velocity=1.5)] + with pytest.raises(AssertionError, match="velocity"): + assert_well_formed(seq) + + +def test_assert_well_formed_rejects_bad_pitch() -> None: + seq = [_bypass_validation(pitch=200)] + with pytest.raises(AssertionError, match="pitch"): + assert_well_formed(seq) + + +def test_assert_well_formed_rejects_negative_start() -> None: + seq = [_bypass_validation(start=-1.0)] + with pytest.raises(AssertionError, match="start"): + assert_well_formed(seq) + + +def test_assert_well_formed_rejects_negative_duration() -> None: + seq = [_bypass_validation(duration=-0.5)] + with pytest.raises(AssertionError, match="duration"): + assert_well_formed(seq) + + +def test_assert_well_formed_accepts_overlapping_onsets() -> None: + """Simultaneous/overlapping events (a chord) are legitimate -- no + monotonic-onset ordering is required.""" + seq = [ + _make_event(start=0.0, pitch=60, voice="chime"), + _make_event(start=0.0, pitch=64, voice="chime"), + _make_event(start=0.1, pitch=55, voice="pulse"), + ] + assert_well_formed(seq) # must not raise + + +def test_assert_deterministic_passes_for_a_pure_callable() -> None: + def render() -> list[NoteEvent]: + return [_make_event(start=0.0, pitch=60), _make_event(start=0.25, pitch=64)] + + result = assert_deterministic(render) + assert result == render() + + +def test_assert_deterministic_passes_args_and_kwargs_through() -> None: + def render(pitch: int, *, voice: str = "chime") -> list[NoteEvent]: + return [_make_event(pitch=pitch, voice=voice)] + + result = assert_deterministic(render, 67, voice="flute") + assert result == [_make_event(pitch=67, voice="flute")] + + +def test_assert_deterministic_rejects_a_nondeterministic_callable() -> None: + counter = {"n": 0} + + def flaky_render() -> list[NoteEvent]: + counter["n"] += 1 + return [_make_event(pitch=60 + counter["n"])] + + with pytest.raises(AssertionError, match="not deterministic"): + assert_deterministic(flaky_render) + + +def test_assert_offline_no_audio_passes_for_harmonics_notes() -> None: + assert_offline_no_audio("harmonics.notes") # must not raise + + +def test_assert_offline_no_audio_passes_for_harmonics_package_root() -> None: + assert_offline_no_audio("harmonics") # must not raise + + +def test_assert_offline_no_audio_rejects_a_module_that_looks_like_audio(tmp_path: Path) -> None: + """A synthetic stand-in for a third-party audio import: a throwaway + module on ``sys.path`` that imports a fake sound-stack library, proving + the substring heuristic ("audio"/"sound" in the name) fires on a fresh + import. Uses real files under ``tmp_path`` (not ``sys.modules`` + injection) so ``importlib.import_module``'s normal finder machinery + actually re-imports it, matching what ``assert_offline_no_audio`` does + for a real module. + """ + (tmp_path / "fakegreatsound.py").write_text("VALUE = 1\n") + (tmp_path / "fake_audio_wrapper_mod.py").write_text("import fakegreatsound # noqa: F401\n") + sys.path.insert(0, str(tmp_path)) + try: + with pytest.raises(AssertionError, match="looks like"): + assert_offline_no_audio("fake_audio_wrapper_mod") + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop("fake_audio_wrapper_mod", None) + sys.modules.pop("fakegreatsound", None) + + +def test_assert_offline_no_audio_rejects_a_known_audio_module(tmp_path: Path) -> None: + """Exact-name match against the known-audio-library list. ``pygame`` has + no "audio"/"sound" substring, so this exercises the exact-name branch + rather than the substring heuristic exercised above.""" + (tmp_path / "pygame.py").write_text("VALUE = 1\n") + (tmp_path / "fake_pygame_wrapper_mod.py").write_text("import pygame # noqa: F401\n") + sys.path.insert(0, str(tmp_path)) + try: + with pytest.raises(AssertionError, match="known audio-stack module"): + assert_offline_no_audio("fake_pygame_wrapper_mod") + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop("fake_pygame_wrapper_mod", None) + sys.modules.pop("pygame", None) diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..02e0507 --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,158 @@ +"""Tests for the identity → voice-print mapping (harmonics/identity.py).""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from harmonics.identity import ( + INSTRUMENTS, + ROOT_PITCHES, + Signature, + derive_signature, + signature_for, +) + +#: Sample nicks drawn from the AgentCulture mesh (issue #1's own examples), +#: used throughout to check determinism and distinctness. +_SAMPLE_IDENTITIES = ("harmonics-cli", "steward", "league", "daria", "culture") + + +def test_derive_signature_is_deterministic_within_a_process() -> None: + assert derive_signature("harmonics-cli") == derive_signature("harmonics-cli") + + +def test_derive_signature_matches_pinned_expected_value() -> None: + """Pins a known identity's derived signature to a fixed expected value. + + These numbers were computed independently with sha256("harmonics-cli") + and are hardcoded here (not re-derived by mirroring the implementation) + so this test would fail if ``derive_signature`` ever silently switched + to a process-salted source like the builtin ``hash()`` — that would + change the value on every interpreter run instead of holding steady. + """ + sig = derive_signature("harmonics-cli") + assert sig.root_pitch == 60 + assert sig.instrument == "pluck" + assert sig.seed == 14890664945596650471 + + +def test_derive_signature_uses_hashlib_sha256_directly() -> None: + """Independently reproduces the sha256-based derivation to confirm the + module is hashing with :mod:`hashlib` (stable across processes/machines) + rather than Python's per-process-salted builtin ``hash()``.""" + import hashlib + + for identity in _SAMPLE_IDENTITIES: + digest = hashlib.sha256(identity.encode("utf-8")).digest() + expected_pitch = ROOT_PITCHES[int.from_bytes(digest[0:8], "big") % len(ROOT_PITCHES)] + expected_instrument = INSTRUMENTS[int.from_bytes(digest[8:16], "big") % len(INSTRUMENTS)] + expected_seed = int.from_bytes(digest[16:24], "big") + + sig = derive_signature(identity) + assert sig.root_pitch == expected_pitch + assert sig.instrument == expected_instrument + assert sig.seed == expected_seed + + +def test_distinct_identities_yield_distinct_signatures() -> None: + signatures = {identity: derive_signature(identity) for identity in _SAMPLE_IDENTITIES} + # Not all colliding: as (root_pitch, instrument) pairs, every sample + # identity must be pairwise distinguishable from every other. + pairs = {(sig.root_pitch, sig.instrument) for sig in signatures.values()} + assert len(pairs) == len(_SAMPLE_IDENTITIES) + + +def test_override_replaces_only_given_fields() -> None: + derived = derive_signature("harmonics-cli") + overridden = signature_for("harmonics-cli", overrides={"harmonics-cli": {"instrument": "bell"}}) + assert overridden.instrument == "bell" + # Un-overridden fields keep the derived values. + assert overridden.root_pitch == derived.root_pitch + assert overridden.seed == derived.seed + + +def test_override_can_replace_multiple_fields() -> None: + overridden = signature_for( + "harmonics-cli", + overrides={"harmonics-cli": {"root_pitch": 60, "instrument": "bell"}}, + ) + assert overridden.root_pitch == 60 + assert overridden.instrument == "bell" + + +def test_identity_absent_from_overrides_gets_derived_signature() -> None: + derived = derive_signature("steward") + resolved = signature_for("steward", overrides={"harmonics-cli": {"instrument": "bell"}}) + assert resolved == derived + + +def test_signature_for_with_no_overrides_equals_derive_signature() -> None: + assert signature_for("daria") == derive_signature("daria") + assert signature_for("daria", overrides=None) == derive_signature("daria") + assert signature_for("daria", overrides={}) == derive_signature("daria") + + +def test_override_with_unknown_field_rejected() -> None: + with pytest.raises(ValueError): + signature_for("harmonics-cli", overrides={"harmonics-cli": {"volume": "loud"}}) + + +def test_override_with_invalid_instrument_rejected() -> None: + with pytest.raises(ValueError): + signature_for("harmonics-cli", overrides={"harmonics-cli": {"instrument": "kazoo"}}) + + +def test_override_with_out_of_range_root_pitch_rejected() -> None: + with pytest.raises(ValueError): + signature_for("harmonics-cli", overrides={"harmonics-cli": {"root_pitch": 30}}) + + +@pytest.mark.parametrize( + "identity", + [*_SAMPLE_IDENTITIES, "a", "z" * 50, "agent-42", "üñîçødé-agent"], +) +def test_root_pitch_is_valid_midi_in_documented_range(identity: str) -> None: + sig = derive_signature(identity) + assert isinstance(sig.root_pitch, int) + assert min(ROOT_PITCHES) <= sig.root_pitch <= max(ROOT_PITCHES) + assert 0 <= sig.root_pitch <= 127 + + +@pytest.mark.parametrize( + "identity", + [*_SAMPLE_IDENTITIES, "a", "z" * 50, "agent-42", "üñîçødé-agent"], +) +def test_instrument_is_always_from_documented_palette(identity: str) -> None: + sig = derive_signature(identity) + assert sig.instrument in INSTRUMENTS + + +def test_root_pitches_and_instruments_are_documented_and_nonempty() -> None: + assert ROOT_PITCHES == (55, 57, 59, 60, 62, 64, 65, 67) + assert INSTRUMENTS == ("chime", "flute", "pulse", "bell", "pluck", "glass") + + +def test_signature_is_frozen_and_hashable() -> None: + sig = derive_signature("harmonics-cli") + assert hash(sig) == hash(derive_signature("harmonics-cli")) + with pytest.raises(FrozenInstanceError): + sig.root_pitch = 60 # type: ignore[misc] + + +def test_signature_rejects_invalid_root_pitch_directly() -> None: + with pytest.raises(ValueError): + Signature(root_pitch=10, instrument="chime", seed=1) + + +def test_signature_rejects_invalid_instrument_directly() -> None: + with pytest.raises(ValueError): + Signature(root_pitch=60, instrument="kazoo", seed=1) + + +def test_blank_identity_rejected() -> None: + with pytest.raises(ValueError): + derive_signature(" ") + with pytest.raises(ValueError): + derive_signature("") diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..6010574 --- /dev/null +++ b/tests/test_inference.py @@ -0,0 +1,163 @@ +"""Tests for sentence -> Axes inference (harmonics/inference.py). + +Covers: concrete cue-table mappings, determinism, the offline/no-third-party +guarantee (stdlib + ``harmonics`` only, no network/model libraries), and that +every inferred result is always a valid :class:`~harmonics.axes.Axes`. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import pytest + +import harmonics.inference as inference +from harmonics.axes import CONFIDENCES, INTENTS, STATES, URGENCIES, Axes +from harmonics.inference import infer_axes + +# --- 1. concrete mappings ------------------------------------------------------ + + +def test_success_cues_map_to_success_intent() -> None: + assert infer_axes("done, tests all green").intent == "success" + + +def test_uncertainty_cues_map_to_thinking_with_low_confidence() -> None: + axes = infer_axes("hmm, not sure about this") + assert axes.intent == "thinking" + assert axes.confidence == "low" + + +def test_trailing_question_mark_maps_to_question() -> None: + assert infer_axes("did the build pass?").intent == "question" + + +def test_failure_cues_map_to_failure() -> None: + assert infer_axes("error: build failed").intent == "failure" + + +def test_plain_statement_defaults_to_ack() -> None: + assert infer_axes("The sky is blue today.").intent == "ack" + + +def test_question_word_beats_thinking_cue_without_question_mark() -> None: + # "not sure" (thinking) and "why" (question) both appear; question is + # documented as the higher-priority cue. + assert infer_axes("not sure why the build is slow").intent == "question" + + +def test_urgent_cue_sets_urgency() -> None: + assert infer_axes("need this fixed now!").urgency == "urgent" + + +def test_calm_cue_sets_urgency() -> None: + assert infer_axes("no rush, whenever you get a chance").urgency == "calm" + + +def test_state_cues() -> None: + assert infer_axes("still working on the fix").state == "working" + assert infer_axes("blocked, waiting on review").state == "blocked" + assert infer_axes("finished the migration").state == "done" + + +def test_handoff_cue() -> None: + assert infer_axes("handing off to the next shift").intent == "handoff" + + +def test_certainty_cue_sets_high_confidence() -> None: + assert infer_axes("this is definitely correct").confidence == "high" + + +def test_case_insensitive() -> None: + assert infer_axes("ERROR: BUILD FAILED").intent == "failure" + assert infer_axes("DONE, TESTS ALL GREEN").intent == "success" + + +# --- 2. determinism -------------------------------------------------------------- + + +@pytest.mark.parametrize( + "sentence", + [ + "done, tests all green", + "hmm, not sure about this", + "did the build pass?", + "error: build failed", + "handing off to the next shift", + "", + ], +) +def test_infer_axes_is_deterministic(sentence: str) -> None: + first = infer_axes(sentence) + for _ in range(5): + assert infer_axes(sentence) == first + + +# --- 3. offline / no-model --------------------------------------------------------- + + +def test_inference_module_imports_only_stdlib_and_harmonics() -> None: + """Static-parse the module's own imports (no execution side effects) and + assert every top-level import root is either stdlib or ``harmonics``.""" + source = Path(inference.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + if node.module and node.level == 0: + modules.add(node.module.split(".")[0]) + stdlib = set(sys.stdlib_module_names) + disallowed = {m for m in modules if m != "harmonics" and m not in stdlib} + assert not disallowed, f"non-stdlib/non-harmonics imports found: {disallowed}" + + +def test_inference_module_mentions_no_network_or_model_libraries() -> None: + source = Path(inference.__file__).read_text(encoding="utf-8").lower() + forbidden = ( + "requests", + "urllib", + "http.client", + "socket", + "openai", + "anthropic", + "torch", + "tensorflow", + "transformers", + "sklearn", + "grpc", + "aiohttp", + ) + for name in forbidden: + assert name not in source, f"found forbidden reference: {name}" + + +# --- 4. always-valid Axes ----------------------------------------------------------- + + +@pytest.mark.parametrize( + "sentence", + [ + "", + " ", + "done, tests all green", + "hmm, not sure about this", + "did the build pass?", + "error: build failed", + "handing off to the next shift", + "URGENT!! need this NOW", + "\U0001f600 emoji only sentence \U0001f600", + "a" * 500, + ], +) +def test_infer_axes_always_returns_valid_axes(sentence: str) -> None: + axes = infer_axes(sentence) + assert isinstance(axes, Axes) + assert axes.intent in INTENTS + assert axes.confidence is None or axes.confidence in CONFIDENCES + assert axes.urgency is None or axes.urgency in URGENCIES + assert axes.state is None or axes.state in STATES diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..bec89f5 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,246 @@ +"""Tests for the axes -> sonic-parameters mapping (harmonics/mapping.py). + +This is the design spine's unit-test surface. Assertions land on the emitted +note sequence — never on a speaker — so the whole file runs with no audio +device and no third-party import. + +Four groups, matching the acceptance criteria: + +1. per-intent structure — every intent renders a non-empty, structurally + distinct motif (success resolves on the tonic, question ends above it, ...); +2. the non-alarm / pleasantness contract — every pitch is in the documented + consonant scale relative to the root, every velocity is under the ceiling, + every note clears the attack floor, and urgent-vs-calm differ only in + timing / repetition (same pitch set, same peak velocity); +3. determinism — same axes + same root/instrument -> identical sequence; +4. confidence cadence — high resolves to the tonic, low suspends off it. +""" + +from __future__ import annotations + +import ast +import sys +from itertools import product +from pathlib import Path + +import pytest + +import harmonics.mapping as mapping +from harmonics.axes import CONFIDENCES, INTENTS, STATES, URGENCIES, Axes +from harmonics.mapping import ( + CONSONANT_SCALE, + MIN_NOTE_DURATION, + VELOCITY_CEILING, + render_gesture, +) +from harmonics.notes import NoteEvent + +ROOT = 60 + + +def _pcs(seq: list[NoteEvent], root: int = ROOT) -> list[int]: + """Pitch classes of every event relative to ``root`` (0 == the tonic).""" + return [(ev.pitch - root) % 12 for ev in seq] + + +def _starts(seq: list[NoteEvent]) -> list[float]: + return [ev.start for ev in seq] + + +# --- 0. the mapping module stays in the offline, dependency-free core --------- + + +def test_mapping_module_imports_only_stdlib_and_harmonics() -> None: + """Static-parse the module's own imports and assert every top-level root is + stdlib or ``harmonics`` — the text->notes core imports no audio stack.""" + source = Path(mapping.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + roots.add(node.module.split(".")[0]) + stdlib = set(sys.stdlib_module_names) + disallowed = {m for m in roots if m != "harmonics" and m not in stdlib} + assert not disallowed, f"non-stdlib/non-harmonics imports found: {disallowed}" + + +def test_mapping_does_not_import_an_identity_module() -> None: + """Identity is realised via the passed root_pitch / instrument, so the + mapping must stay decoupled from any identity/signature module.""" + source = Path(mapping.__file__).read_text(encoding="utf-8").lower() + for forbidden in ("identity", "signature", "voiceprint", "voice_print"): + assert f"import {forbidden}" not in source + assert f"harmonics.{forbidden}" not in source + + +# --- 1. per-intent structure -------------------------------------------------- + + +@pytest.mark.parametrize("intent", INTENTS) +def test_every_intent_renders_a_non_empty_note_sequence(intent: str) -> None: + seq = render_gesture(Axes(intent=intent), root_pitch=ROOT) + assert isinstance(seq, list) + assert seq, f"{intent} rendered an empty gesture" + assert all(isinstance(ev, NoteEvent) for ev in seq) + + +def test_success_is_a_rising_gesture_resolved_on_the_tonic() -> None: + seq = render_gesture(Axes(intent="success"), root_pitch=ROOT) + assert _pcs(seq)[-1] == 0, "success must resolve onto the tonic pitch-class" + assert seq[-1].pitch > seq[0].pitch, "success must rise" + + +def test_question_ends_above_the_tonic_and_unresolved() -> None: + seq = render_gesture(Axes(intent="question"), root_pitch=ROOT) + assert seq[-1].pitch > ROOT, "question must end above the tonic register" + assert _pcs(seq)[-1] != 0, "question must end unresolved (off the tonic)" + + +def test_failure_is_a_gentle_falling_gesture() -> None: + seq = render_gesture(Axes(intent="failure"), root_pitch=ROOT) + assert seq[-1].pitch < seq[0].pitch, "failure must fall" + # Soft, never harsh: the whole gesture stays well under the ceiling. + assert max(ev.velocity for ev in seq) <= 0.5 + + +def test_ack_is_a_short_two_note_figure_resolving_home() -> None: + seq = render_gesture(Axes(intent="ack"), root_pitch=ROOT) + assert len(seq) == 2, "ack's base motif is a two-note figure" + assert _pcs(seq)[-1] == 0 + + +def test_thinking_is_a_repeated_neighbour_tone() -> None: + seq = render_gesture(Axes(intent="thinking"), root_pitch=ROOT) + pitches = [ev.pitch for ev in seq] + assert max(pitches) - min(pitches) <= 2, "thinking hovers in a narrow band" + assert len(set(pitches)) < len(pitches), "thinking repeats its tones" + + +def test_handoff_hands_the_gesture_upward() -> None: + seq = render_gesture(Axes(intent="handoff"), root_pitch=ROOT) + pitches = [ev.pitch for ev in seq] + assert pitches[-1] == max(pitches), "handoff ends on its highest note" + assert pitches[-1] - pitches[0] >= 12, "handoff hands upward by at least an octave" + + +def test_intents_are_structurally_distinct() -> None: + """No two intents render the same pitch contour under identical inputs.""" + contours = { + intent: tuple(ev.pitch for ev in render_gesture(Axes(intent=intent), root_pitch=ROOT)) + for intent in INTENTS + } + assert len(set(contours.values())) == len(INTENTS) + + +# --- 2. the non-alarm / pleasantness contract --------------------------------- + +_ALL_AXES = [ + Axes(intent=i, confidence=c, urgency=u, state=s) + for i, c, u, s in product( + INTENTS, + (None, *CONFIDENCES), + (None, *URGENCIES), + (None, *STATES), + ) +] + + +@pytest.mark.parametrize("axes", _ALL_AXES, ids=lambda a: repr(a.as_dict())) +def test_every_pitch_is_in_the_consonant_scale(axes: Axes) -> None: + seq = render_gesture(axes, root_pitch=ROOT) + for pc in _pcs(seq): + assert pc in CONSONANT_SCALE, f"{pc} is outside the consonant scale for {axes}" + + +@pytest.mark.parametrize("axes", _ALL_AXES, ids=lambda a: repr(a.as_dict())) +def test_every_velocity_is_under_the_ceiling(axes: Axes) -> None: + seq = render_gesture(axes, root_pitch=ROOT) + assert all(ev.velocity <= VELOCITY_CEILING for ev in seq) + + +@pytest.mark.parametrize("axes", _ALL_AXES, ids=lambda a: repr(a.as_dict())) +def test_every_note_clears_the_attack_floor(axes: Axes) -> None: + seq = render_gesture(axes, root_pitch=ROOT) + assert all(ev.duration >= MIN_NOTE_DURATION for ev in seq) + + +def test_scale_is_consonant_pentatonic_and_ceiling_is_bounded() -> None: + # A warm major pentatonic: no semitone clashes, no tritone -> non-fatiguing. + assert set(CONSONANT_SCALE) == {0, 2, 4, 7, 9} + assert 0 < VELOCITY_CEILING <= 0.8 + assert MIN_NOTE_DURATION > 0 + + +@pytest.mark.parametrize("intent", INTENTS) +def test_urgent_vs_calm_differ_only_in_timing_not_dissonance_or_loudness(intent: str) -> None: + """The 'urgent, never an alarm-clock' proof: for identical axes, swapping + calm->urgent may change onsets and note count but must NOT add a pitch + outside the calm set and must NOT raise the peak velocity.""" + calm = render_gesture(Axes(intent=intent, urgency="calm"), root_pitch=ROOT) + urgent = render_gesture(Axes(intent=intent, urgency="urgent"), root_pitch=ROOT) + + assert {ev.pitch for ev in urgent} == {ev.pitch for ev in calm}, "urgency added a pitch" + calm_peak = max(ev.velocity for ev in calm) + urgent_peak = max(ev.velocity for ev in urgent) + assert urgent_peak == calm_peak, "urgency must not raise the loudness ceiling" + # The difference is real, and it lives only in timing / repetition. + assert _starts(urgent) != _starts(calm) or len(urgent) != len(calm) + + +def test_urgent_adds_repetition_and_tightens_onsets() -> None: + calm = render_gesture(Axes(intent="success", urgency="calm"), root_pitch=ROOT) + urgent = render_gesture(Axes(intent="success", urgency="urgent"), root_pitch=ROOT) + assert len(urgent) > len(calm), "urgent repeats the motif" + # tighter inter-onset gaps + calm_gap = calm[1].start - calm[0].start + urgent_gap = urgent[1].start - urgent[0].start + assert urgent_gap < calm_gap + + +# --- 3. determinism ----------------------------------------------------------- + + +@pytest.mark.parametrize("axes", _ALL_AXES, ids=lambda a: repr(a.as_dict())) +def test_render_is_deterministic(axes: Axes) -> None: + first = render_gesture(axes, root_pitch=ROOT) + for _ in range(4): + assert render_gesture(axes, root_pitch=ROOT) == first + + +def test_render_tracks_root_pitch_and_instrument() -> None: + a = render_gesture(Axes(intent="success"), root_pitch=60, instrument="flute") + b = render_gesture(Axes(intent="success"), root_pitch=67, instrument="flute") + assert [ev.pitch for ev in b] == [ev.pitch + 7 for ev in a], "root_pitch transposes" + assert all(ev.voice == "flute" for ev in a), "instrument sets the voice" + + +def test_extreme_root_stays_in_valid_midi_range() -> None: + for root in (0, 5, 120, 127): + seq = render_gesture(Axes(intent="handoff"), root_pitch=root) + assert all(0 <= ev.pitch <= 127 for ev in seq) + + +# --- 4. confidence cadence ---------------------------------------------------- + + +def test_high_confidence_resolves_low_confidence_suspends() -> None: + high = render_gesture(Axes(intent="ack", confidence="high"), root_pitch=ROOT) + low = render_gesture(Axes(intent="ack", confidence="low"), root_pitch=ROOT) + assert _pcs(high)[-1] == 0, "high confidence resolves onto the tonic" + assert _pcs(low)[-1] != 0, "low confidence ends suspended, off the tonic" + + +def test_low_confidence_wavers_with_a_neighbour_tone() -> None: + stable = render_gesture(Axes(intent="ack", confidence="high"), root_pitch=ROOT) + wavering = render_gesture(Axes(intent="ack", confidence="low"), root_pitch=ROOT) + assert len(wavering) > len(stable), "low confidence adds a wavering neighbour tone" + + +def test_confidence_cadence_holds_for_success_too() -> None: + high = render_gesture(Axes(intent="success", confidence="high"), root_pitch=ROOT) + low = render_gesture(Axes(intent="success", confidence="low"), root_pitch=ROOT) + assert _pcs(high)[-1] == 0 + assert _pcs(low)[-1] != 0 diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..45f9154 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,163 @@ +"""Tests for the note-event core (harmonics/notes.py).""" + +from __future__ import annotations + +import json +import sys +from dataclasses import FrozenInstanceError + +import pytest + +from harmonics.notes import ( + NoteEvent, + sequence_from_json, + sequence_to_json, + to_midi_notes, +) + + +def _make_event(**overrides: object) -> NoteEvent: + fields: dict[str, object] = { + "start": 0.0, + "duration": 0.25, + "pitch": 60, + "velocity": 0.8, + "voice": "chime", + } + fields.update(overrides) + return NoteEvent(**fields) # type: ignore[arg-type] + + +def test_to_dict_from_dict_roundtrips() -> None: + ev = _make_event() + assert NoteEvent.from_dict(ev.to_dict()) == ev + + +def test_to_dict_has_expected_keys() -> None: + ev = _make_event(start=1.5, duration=0.5, pitch=72, velocity=0.5, voice="flute") + d = ev.to_dict() + assert d == { + "start": 1.5, + "duration": 0.5, + "pitch": 72, + "velocity": 0.5, + "voice": "flute", + } + + +def test_sequence_json_roundtrips_and_is_a_list() -> None: + seq = [ + _make_event(start=0.0, pitch=60, voice="chime"), + _make_event(start=0.25, pitch=64, voice="flute"), + _make_event(start=0.5, pitch=67, voice="pulse"), + ] + s = sequence_to_json(seq) + parsed = json.loads(s) + assert isinstance(parsed, list) + assert len(parsed) == 3 + assert sequence_from_json(s) == seq + + +def test_sequence_json_empty_list_roundtrips() -> None: + assert sequence_from_json(sequence_to_json([])) == [] + + +def test_sequence_to_json_is_stable_for_equal_sequences() -> None: + seq_a = [_make_event(start=0.0, pitch=60), _make_event(start=0.25, pitch=64)] + seq_b = [_make_event(start=0.0, pitch=60), _make_event(start=0.25, pitch=64)] + assert sequence_to_json(seq_a) == sequence_to_json(seq_b) + + +def test_to_midi_notes_scales_velocity_to_0_127() -> None: + seq = [_make_event(velocity=1.0), _make_event(velocity=0.0), _make_event(velocity=0.5)] + midi = to_midi_notes(seq) + assert midi[0]["velocity"] == 127 + assert midi[1]["velocity"] == 0 + assert midi[2]["velocity"] == pytest.approx(63.5) or midi[2]["velocity"] == 64 + + +def test_to_midi_notes_computes_integer_ticks() -> None: + seq = [_make_event(start=1.0, duration=0.5, pitch=60)] + midi = to_midi_notes(seq, ticks_per_second=1000) + assert midi[0]["start_tick"] == 1000 + assert midi[0]["duration_tick"] == 500 + assert isinstance(midi[0]["start_tick"], int) + assert isinstance(midi[0]["duration_tick"], int) + assert midi[0]["pitch"] == 60 + + +def test_to_midi_notes_respects_ticks_per_second() -> None: + seq = [_make_event(start=2.0, duration=1.0, pitch=60)] + midi = to_midi_notes(seq, ticks_per_second=480) + assert midi[0]["start_tick"] == 960 + assert midi[0]["duration_tick"] == 480 + + +def test_to_midi_notes_preserves_order_and_count() -> None: + seq = [ + _make_event(start=0.0, pitch=60), + _make_event(start=0.1, pitch=61), + _make_event(start=0.2, pitch=62), + ] + midi = to_midi_notes(seq) + assert [n["pitch"] for n in midi] == [60, 61, 62] + + +def test_notes_module_imports_no_third_party_packages() -> None: + """harmonics.notes must be importable with zero third-party imports. + + Inspect every module actually loaded as a result of importing + harmonics.notes (that wasn't already loaded before the import) and assert + each one lives either in the stdlib or under the harmonics package itself. + """ + stdlib_names = set(sys.stdlib_module_names) + before = set(sys.modules) + + # Force a fresh import to observe what it pulls in. + sys.modules.pop("harmonics.notes", None) + import harmonics.notes # noqa: F401 + + after = set(sys.modules) + newly_imported = after - before + + for mod_name in newly_imported: + top_level = mod_name.split(".")[0] + assert ( + top_level == "harmonics" or top_level in stdlib_names or mod_name in stdlib_names + ), f"harmonics.notes pulled in third-party module: {mod_name!r}" + + +@pytest.mark.parametrize("bad_pitch", [-1, 128, 200]) +def test_out_of_range_pitch_rejected(bad_pitch: int) -> None: + with pytest.raises(ValueError): + _make_event(pitch=bad_pitch) + + +@pytest.mark.parametrize("bad_velocity", [-0.1, 1.1, 1.5]) +def test_out_of_range_velocity_rejected(bad_velocity: float) -> None: + with pytest.raises(ValueError): + _make_event(velocity=bad_velocity) + + +def test_negative_duration_rejected() -> None: + with pytest.raises(ValueError): + _make_event(duration=-0.1) + + +def test_negative_start_rejected() -> None: + with pytest.raises(ValueError): + _make_event(start=-0.5) + + +def test_boundary_values_are_accepted() -> None: + ev = _make_event(pitch=0, velocity=0.0, duration=0.0, start=0.0) + assert ev.pitch == 0 + ev2 = _make_event(pitch=127, velocity=1.0) + assert ev2.pitch == 127 + assert ev2.velocity == 1.0 + + +def test_frozen_event_is_immutable() -> None: + ev = _make_event() + with pytest.raises(FrozenInstanceError): + ev.pitch = 61 # type: ignore[misc] diff --git a/tests/test_play.py b/tests/test_play.py new file mode 100644 index 0000000..fb80037 --- /dev/null +++ b/tests/test_play.py @@ -0,0 +1,357 @@ +"""Tests for ``harmonics play`` — explicit axes -> notes, dry-run by default. + +Covers the acceptance criteria for the first domain verb: dry-run text/JSON +output, determinism (repeat calls, ``--seq``, and cross-identity divergence), +``--out``/``--wav`` file capture vs. the no-file dry-run default, the +structured error contract for a bad ``--intent``, the friendly +no-backend-installed ``--play`` error (cycle t12 — the offline audio backend +exists now; CI simply has no playback library installed), and that +``harmonics explain play`` resolves. +""" + +from __future__ import annotations + +import json +import wave +from pathlib import Path + +import pytest + +from harmonics.cli import main + +# --- dry-run default (criterion 1) ----------------------------------------- + + +def test_play_dry_run_text_default(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + rc = main(["play", "--intent", "success", "--as", "harmonics-cli"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.strip() + # dry-run writes no file anywhere test can see, and produces no stray + # audio-related side effects; the cwd stays untouched. + assert list(tmp_path.iterdir()) == [] + + +def test_play_dry_run_text_is_one_line_per_note(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["play", "--intent", "success"]) + assert rc == 0 + out = capsys.readouterr().out.strip() + lines = out.splitlines() + assert len(lines) >= 1 + for line in lines: + fields = line.split() + # start dur pitch vel voice + assert len(fields) == 5 + float(fields[0]) + float(fields[1]) + int(fields[2]) + float(fields[3]) + assert fields[4] + + +# --- JSON output (criterion 2) ---------------------------------------------- + + +def test_play_json_is_note_list(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["play", "--intent", "success", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) + assert payload + for note in payload: + assert {"start", "duration", "pitch", "velocity", "voice"} <= set(note) + + +# --- determinism (criterion 3) ---------------------------------------------- + + +def test_play_identical_args_identical_output(capsys: pytest.CaptureFixture[str]) -> None: + rc1 = main(["play", "--intent", "success", "--as", "harmonics-cli", "--json"]) + out1 = capsys.readouterr().out + rc2 = main(["play", "--intent", "success", "--as", "harmonics-cli", "--json"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_play_same_seq_twice_identical(capsys: pytest.CaptureFixture[str]) -> None: + args = ["play", "--intent", "success", "--seq", "7", "--json"] + rc1 = main(args) + out1 = capsys.readouterr().out + rc2 = main(args) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_play_different_agents_different_sequences(capsys: pytest.CaptureFixture[str]) -> None: + rc1 = main(["play", "--intent", "success", "--as", "agent-one", "--json"]) + out1 = capsys.readouterr().out + rc2 = main(["play", "--intent", "success", "--as", "agent-two", "--json"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 != out2 + + +# --- --out vs. dry-run default (criterion 4) -------------------------------- + + +def test_play_out_writes_note_sequence_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "gesture.json" + rc = main(["play", "--intent", "success", "--out", str(target)]) + assert rc == 0 + assert target.is_file() + notes = json.loads(target.read_text(encoding="utf-8")) + assert isinstance(notes, list) + assert notes + for note in notes: + assert {"start", "duration", "pitch", "velocity", "voice"} <= set(note) + # a one-line confirmation goes to stdout + out = capsys.readouterr().out.strip() + assert str(target) in out + + +def test_play_default_writes_no_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["play", "--intent", "success"]) + assert rc == 0 + capsys.readouterr() + assert list(tmp_path.iterdir()) == [] + + +# --- --wav: the offline audio backend (cycle t12) --------------------------- + + +def test_play_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "gesture.wav" + rc = main(["play", "--intent", "success", "--wav", str(target)]) + assert rc == 0 + assert target.is_file() + with wave.open(str(target), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getnframes() > 0 + out = capsys.readouterr().out.strip() + assert str(target) in out + + +def test_play_wav_json_reports_wrote_and_note_count( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "gesture.wav" + rc = main(["play", "--intent", "success", "--wav", str(target), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["wrote"] == str(target) + assert payload["notes"] > 0 + + +# --- --articulation: glide-by-default voice styles --------------------------- + + +def test_play_articulation_alien_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "gesture.wav" + rc = main(["play", "--intent", "success", "--articulation", "alien", "--wav", str(target)]) + assert rc == 0 + assert target.is_file() + with wave.open(str(target), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getnframes() > 0 + + +def test_play_default_articulation_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """No ``--articulation`` at all -- the default (gliding) still writes a + valid WAV file.""" + target = tmp_path / "gesture.wav" + rc = main(["play", "--intent", "success", "--wav", str(target)]) + assert rc == 0 + with wave.open(str(target), "rb") as wf: + assert wf.getnframes() > 0 + + +def test_play_default_articulation_is_smooth(tmp_path: Path) -> None: + """The user asked for gliding by default -- verify the no-flag ``--wav`` + output is byte-identical to explicit ``--articulation smooth``.""" + default_target = tmp_path / "default.wav" + smooth_target = tmp_path / "smooth.wav" + rc1 = main( + ["play", "--intent", "success", "--as", "harmonics-cli", "--wav", str(default_target)] + ) + rc2 = main( + [ + "play", + "--intent", + "success", + "--as", + "harmonics-cli", + "--articulation", + "smooth", + "--wav", + str(smooth_target), + ] + ) + assert rc1 == rc2 == 0 + assert default_target.read_bytes() == smooth_target.read_bytes() + + +def test_play_articulation_discrete_wav_differs_from_default(tmp_path: Path) -> None: + discrete_target = tmp_path / "discrete.wav" + default_target = tmp_path / "default.wav" + main( + [ + "play", + "--intent", + "success", + "--as", + "harmonics-cli", + "--articulation", + "discrete", + "--wav", + str(discrete_target), + ] + ) + main(["play", "--intent", "success", "--as", "harmonics-cli", "--wav", str(default_target)]) + assert discrete_target.read_bytes() != default_target.read_bytes() + + +def test_play_invalid_articulation_exits_1_structured(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["play", "--intent", "success", "--articulation", "robotic"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_play_json_output_identical_regardless_of_articulation( + capsys: pytest.CaptureFixture[str], +) -> None: + """Articulation is a synth-only property -- dry-run/``--json`` note + output must not vary with it.""" + rc1 = main( + [ + "play", + "--intent", + "success", + "--as", + "harmonics-cli", + "--articulation", + "discrete", + "--json", + ] + ) + out1 = capsys.readouterr().out + rc2 = main( + [ + "play", + "--intent", + "success", + "--as", + "harmonics-cli", + "--articulation", + "alien", + "--json", + ] + ) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_play_dry_run_text_identical_regardless_of_articulation( + capsys: pytest.CaptureFixture[str], +) -> None: + rc1 = main( + ["play", "--intent", "success", "--as", "harmonics-cli", "--articulation", "speechy"] + ) + out1 = capsys.readouterr().out + rc2 = main(["play", "--intent", "success", "--as", "harmonics-cli", "--articulation", "alien"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +# --- error contract (criterion 5) ------------------------------------------- + + +def test_play_bad_intent_exits_1_structured(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["play", "--intent", "bogus"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_play_missing_intent_exits_1_structured(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["play"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_play_flag_with_no_backend_installed(capsys: pytest.CaptureFixture[str]) -> None: + """CI (and this test env) has neither ``simpleaudio`` nor ``sounddevice`` + installed, so ``--play`` surfaces the offline backend's friendly + ``CliError`` (exit 2) rather than a traceback or a silent no-op.""" + rc = main(["play", "--intent", "success", "--play"]) + assert rc == 2 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "no audio playback backend" in err + assert "hint:" in err + assert "simpleaudio" in err + assert "sounddevice" in err + + +# --- write-failure -> structured CliError, not a traceback (qodo #2) --------- + + +def test_play_out_write_failure_exits_2_structured( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--out`` to a path whose parent directory doesn't exist must raise a + ``CliError`` (exit 2, ``error:``/``hint:``), never a bare traceback.""" + target = tmp_path / "no-such-dir" / "gesture.json" + rc = main(["play", "--intent", "success", "--out", str(target)]) + assert rc == 2 + assert not target.exists() + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert str(target) in err + + +def test_play_wav_write_failure_exits_2_structured( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--wav`` to a path whose parent directory doesn't exist must raise a + ``CliError`` (exit 2, ``error:``/``hint:``), never a bare traceback.""" + target = tmp_path / "no-such-dir" / "gesture.wav" + rc = main(["play", "--intent", "success", "--wav", str(target)]) + assert rc == 2 + assert not target.exists() + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert str(target) in err + + +# --- explain (criterion 6) -------------------------------------------------- + + +def test_explain_play_resolves(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "play"]) + assert rc == 0 + out = capsys.readouterr().out + assert "# harmonics play" in out diff --git a/tests/test_say.py b/tests/test_say.py new file mode 100644 index 0000000..ae88f2a --- /dev/null +++ b/tests/test_say.py @@ -0,0 +1,505 @@ +"""Tests for ``harmonics say`` — sentence -> inferred axes + text contour + +emphasis -> notes, in the agent's voice, dry-run by default. + +Covers the acceptance criteria for the payoff text-to-notes verb: dry-run +text/JSON output that tracks the sentence's words, determinism (repeat calls +and ``--seq``), that inferred axes actually shade the render (a success +sentence differs from a question), that emphasis markers audibly stress a +word, ``--out``/``--midi``/``--wav`` file capture vs. the no-file dry-run +default, the friendly no-backend-installed ``--play`` error (cycle t12 — the +offline audio backend exists now; CI simply has no playback library +installed), and that ``harmonics explain say`` resolves. A few extra +whitebox tests exercise the two axis-shading helpers directly (urgency -> +tempo, confidence -> the ending) so shading is verified independently of the +word-hash noise a sentence-level comparison carries. +""" + +from __future__ import annotations + +import json +import wave +from pathlib import Path + +import pytest + +from harmonics.cli import main +from harmonics.cli._commands.say import _shade_by_urgency, _shade_ending_by_confidence +from harmonics.notes import NoteEvent + +# --- dry-run default, tracks the words (criterion 1) ------------------------- + + +def test_say_dry_run_text_default(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + rc = main(["say", "done, tests all green", "--as", "harmonics-cli"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.strip() + # dry-run writes no file anywhere test can see, and makes no sound. + assert list(tmp_path.iterdir()) == [] + + +def test_say_note_count_tracks_word_count(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["say", "done, tests all green", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + # clean text "done, tests all green" -> 4 words -> 4 notes. + assert len(payload) == 4 + + +def test_say_dry_run_text_is_one_line_per_note(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["say", "done, tests all green"]) + assert rc == 0 + out = capsys.readouterr().out.strip() + lines = out.splitlines() + assert len(lines) == 4 + for line in lines: + fields = line.split() + # start dur pitch vel voice + assert len(fields) == 5 + float(fields[0]) + float(fields[1]) + int(fields[2]) + float(fields[3]) + assert fields[4] + + +# --- JSON output (criterion 2) ------------------------------------------------ + + +def test_say_json_is_note_list(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["say", "did it pass?", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) + assert payload + for note in payload: + assert {"start", "duration", "pitch", "velocity", "voice"} <= set(note) + + +def test_say_empty_sentence_yields_empty_sequence(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["say", "...", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == [] + + +# --- determinism (criterion 3) ------------------------------------------------ + + +def test_say_identical_args_identical_output(capsys: pytest.CaptureFixture[str]) -> None: + args = ["say", "done, tests all green", "--as", "harmonics-cli", "--json"] + rc1 = main(args) + out1 = capsys.readouterr().out + rc2 = main(args) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_say_same_seq_twice_identical(capsys: pytest.CaptureFixture[str]) -> None: + args = ["say", "done, tests all green", "--seq", "7", "--json"] + rc1 = main(args) + out1 = capsys.readouterr().out + rc2 = main(args) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_say_different_agents_different_sequences(capsys: pytest.CaptureFixture[str]) -> None: + rc1 = main(["say", "done, tests all green", "--as", "agent-one", "--json"]) + out1 = capsys.readouterr().out + rc2 = main(["say", "done, tests all green", "--as", "agent-two", "--json"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 != out2 + + +# --- inferred axes actually shade the render (criterion 4) -------------------- + + +def test_say_success_and_question_render_differently(capsys: pytest.CaptureFixture[str]) -> None: + rc1 = main(["say", "all tests passed", "--as", "harmonics-cli", "--json"]) + out1 = capsys.readouterr().out + rc2 = main(["say", "did it pass?", "--as", "harmonics-cli", "--json"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 != out2 + + +def test_shade_by_urgency_tightens_urgent_and_loosens_calm() -> None: + """Whitebox: the urgency->tempo pass in isolation, off the word-hash noise + a full sentence comparison would carry.""" + seq = [ + NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.5, voice="flute"), + NoteEvent(start=0.22, duration=0.2, pitch=62, velocity=0.5, voice="flute"), + ] + urgent = _shade_by_urgency(seq, "urgent") + calm = _shade_by_urgency(seq, "calm") + normal = _shade_by_urgency(seq, "normal") + + assert urgent[1].start < seq[1].start + assert urgent[0].duration <= seq[0].duration + assert calm[1].start > seq[1].start + assert calm[0].duration >= seq[0].duration + assert normal == seq + + +def test_shade_ending_by_confidence_high_resolves_low_lingers() -> None: + """Whitebox: the confidence->ending pass in isolation. Only the final note + changes; earlier notes are untouched.""" + seq = [ + NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.5, voice="flute"), + NoteEvent(start=0.22, duration=0.2, pitch=64, velocity=0.5, voice="flute"), + ] + high = _shade_ending_by_confidence(seq, "high") + low = _shade_ending_by_confidence(seq, "low") + medium = _shade_ending_by_confidence(seq, "medium") + + # earlier note is never touched by the ending pass + assert high[0] == seq[0] + assert low[0] == seq[0] + + # high confidence: crisper (shorter), a touch louder -> resolved landing + assert high[-1].duration < seq[-1].duration + assert high[-1].velocity > seq[-1].velocity + + # low confidence: lingers (longer), a touch softer -> less-certain tail + assert low[-1].duration > seq[-1].duration + assert low[-1].velocity < seq[-1].velocity + + assert medium == seq + + +# --- emphasis / stress (criterion 5) ------------------------------------------ + + +def test_say_asterisk_emphasis_boosts_the_stressed_word( + capsys: pytest.CaptureFixture[str], +) -> None: + rc1 = main(["say", "push it *now*", "--json"]) + stressed_payload = json.loads(capsys.readouterr().out) + rc2 = main(["say", "push it now", "--json"]) + plain_payload = json.loads(capsys.readouterr().out) + assert rc1 == rc2 == 0 + + # "now" is word index 2 in both the stressed and the plain rendering. + stressed_note = stressed_payload[2] + plain_note = plain_payload[2] + + assert stressed_note["velocity"] >= plain_note["velocity"] + assert stressed_note["pitch"] >= plain_note["pitch"] + assert (stressed_note["velocity"] > plain_note["velocity"]) or ( + stressed_note["pitch"] > plain_note["pitch"] + ) + + # only the stressed word's note changed; the rest of the phrase did not. + assert stressed_payload[0] == plain_payload[0] + assert stressed_payload[1] == plain_payload[1] + + +def test_say_all_caps_emphasis_also_boosts(capsys: pytest.CaptureFixture[str]) -> None: + rc1 = main(["say", "push it NOW", "--json"]) + caps_payload = json.loads(capsys.readouterr().out) + rc2 = main(["say", "push it now", "--json"]) + plain_payload = json.loads(capsys.readouterr().out) + assert rc1 == rc2 == 0 + + caps_note = caps_payload[2] + plain_note = plain_payload[2] + assert caps_note["velocity"] >= plain_note["velocity"] + assert caps_note["pitch"] >= plain_note["pitch"] + assert (caps_note["velocity"] > plain_note["velocity"]) or ( + caps_note["pitch"] > plain_note["pitch"] + ) + + +# --- --out / --midi vs. dry-run default, --play (criterion 6) ---------------- + + +def test_say_out_writes_note_sequence_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "utterance.json" + rc = main(["say", "all tests passed", "--out", str(target)]) + assert rc == 0 + assert target.is_file() + notes = json.loads(target.read_text(encoding="utf-8")) + assert isinstance(notes, list) + assert notes + for note in notes: + assert {"start", "duration", "pitch", "velocity", "voice"} <= set(note) + out = capsys.readouterr().out.strip() + assert str(target) in out + + +def test_say_midi_writes_midi_like_list(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + target = tmp_path / "utterance.midi.json" + rc = main(["say", "all tests passed", "--midi", str(target)]) + assert rc == 0 + assert target.is_file() + midi_notes = json.loads(target.read_text(encoding="utf-8")) + assert isinstance(midi_notes, list) + assert midi_notes + for note in midi_notes: + assert {"pitch", "start_tick", "duration_tick", "velocity"} <= set(note) + assert isinstance(note["start_tick"], int) + assert isinstance(note["duration_tick"], int) + out = capsys.readouterr().out.strip() + assert str(target) in out + + +def test_say_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "utterance.wav" + rc = main(["say", "all tests passed", "--wav", str(target)]) + assert rc == 0 + assert target.is_file() + with wave.open(str(target), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getnframes() > 0 + out = capsys.readouterr().out.strip() + assert str(target) in out + + +def test_say_default_writes_no_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["say", "all tests passed"]) + assert rc == 0 + capsys.readouterr() + assert list(tmp_path.iterdir()) == [] + + +def test_say_flag_with_no_backend_installed(capsys: pytest.CaptureFixture[str]) -> None: + """CI (and this test env) has neither ``simpleaudio`` nor ``sounddevice`` + installed, so ``--play`` surfaces the offline backend's friendly + ``CliError`` (exit 2) rather than a traceback or a silent no-op.""" + rc = main(["say", "all tests passed", "--play"]) + assert rc == 2 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "no audio playback backend" in err + assert "hint:" in err + assert "simpleaudio" in err + assert "sounddevice" in err + + +def test_say_play_takes_priority_over_out( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "should-not-exist.json" + rc = main(["say", "all tests passed", "--out", str(target), "--play"]) + assert rc == 2 + assert not target.exists() + + +# --- --articulation: glide-by-default voice styles --------------------------- + + +def test_say_articulation_alien_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "utterance.wav" + rc = main(["say", "all tests passed", "--articulation", "alien", "--wav", str(target)]) + assert rc == 0 + assert target.is_file() + with wave.open(str(target), "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + assert wf.getnframes() > 0 + + +def test_say_default_articulation_wav_writes_a_valid_wav_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """No ``--articulation`` at all -- the default (gliding) still writes a + valid WAV file.""" + target = tmp_path / "utterance.wav" + rc = main(["say", "all tests passed", "--wav", str(target)]) + assert rc == 0 + with wave.open(str(target), "rb") as wf: + assert wf.getnframes() > 0 + + +def test_say_default_articulation_is_smooth(tmp_path: Path) -> None: + """The user asked for gliding by default -- verify the no-flag ``--wav`` + output is byte-identical to explicit ``--articulation smooth``.""" + default_target = tmp_path / "default.wav" + smooth_target = tmp_path / "smooth.wav" + rc1 = main(["say", "all tests passed", "--as", "harmonics-cli", "--wav", str(default_target)]) + rc2 = main( + [ + "say", + "all tests passed", + "--as", + "harmonics-cli", + "--articulation", + "smooth", + "--wav", + str(smooth_target), + ] + ) + assert rc1 == rc2 == 0 + assert default_target.read_bytes() == smooth_target.read_bytes() + + +def test_say_articulation_discrete_wav_differs_from_default(tmp_path: Path) -> None: + discrete_target = tmp_path / "discrete.wav" + default_target = tmp_path / "default.wav" + main( + [ + "say", + "all tests passed", + "--as", + "harmonics-cli", + "--articulation", + "discrete", + "--wav", + str(discrete_target), + ] + ) + main(["say", "all tests passed", "--as", "harmonics-cli", "--wav", str(default_target)]) + assert discrete_target.read_bytes() != default_target.read_bytes() + + +def test_say_invalid_articulation_exits_1_structured(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["say", "all tests passed", "--articulation", "robotic"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_say_json_output_identical_regardless_of_articulation( + capsys: pytest.CaptureFixture[str], +) -> None: + """Articulation is a synth-only property -- dry-run/``--json`` note + output must not vary with it.""" + rc1 = main( + ["say", "all tests passed", "--as", "harmonics-cli", "--articulation", "discrete", "--json"] + ) + out1 = capsys.readouterr().out + rc2 = main( + ["say", "all tests passed", "--as", "harmonics-cli", "--articulation", "alien", "--json"] + ) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_say_dry_run_text_identical_regardless_of_articulation( + capsys: pytest.CaptureFixture[str], +) -> None: + rc1 = main(["say", "all tests passed", "--as", "harmonics-cli", "--articulation", "speechy"]) + out1 = capsys.readouterr().out + rc2 = main(["say", "all tests passed", "--as", "harmonics-cli", "--articulation", "alien"]) + out2 = capsys.readouterr().out + assert rc1 == rc2 == 0 + assert out1 == out2 + + +def test_say_midi_output_identical_regardless_of_articulation( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--midi`` is note-sequence output too -- must not vary with + ``--articulation``.""" + discrete_target = tmp_path / "discrete.midi.json" + alien_target = tmp_path / "alien.midi.json" + main( + [ + "say", + "all tests passed", + "--articulation", + "discrete", + "--midi", + str(discrete_target), + ] + ) + main(["say", "all tests passed", "--articulation", "alien", "--midi", str(alien_target)]) + assert discrete_target.read_text(encoding="utf-8") == alien_target.read_text(encoding="utf-8") + + +# --- write-failure -> structured CliError, not a traceback (qodo #2) --------- + + +def test_say_out_write_failure_exits_2_structured( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--out`` to a path whose parent directory doesn't exist must raise a + ``CliError`` (exit 2, ``error:``/``hint:``), never a bare traceback.""" + target = tmp_path / "no-such-dir" / "utterance.json" + rc = main(["say", "all tests passed", "--out", str(target)]) + assert rc == 2 + assert not target.exists() + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert str(target) in err + + +def test_say_midi_write_failure_exits_2_structured( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--midi`` to a path whose parent directory doesn't exist must raise a + ``CliError`` (exit 2, ``error:``/``hint:``), never a bare traceback.""" + target = tmp_path / "no-such-dir" / "utterance.midi.json" + rc = main(["say", "all tests passed", "--midi", str(target)]) + assert rc == 2 + assert not target.exists() + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert str(target) in err + + +def test_say_wav_write_failure_exits_2_structured( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--wav`` to a path whose parent directory doesn't exist must raise a + ``CliError`` (exit 2, ``error:``/``hint:``), never a bare traceback.""" + target = tmp_path / "no-such-dir" / "utterance.wav" + rc = main(["say", "all tests passed", "--wav", str(target)]) + assert rc == 2 + assert not target.exists() + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert str(target) in err + + +def test_say_out_write_failure_does_not_also_write_midi_or_wav( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``--out`` is written first in ``_write_requested_files``; if it fails, + the CliError propagates immediately and ``--midi``/``--wav`` never run.""" + bad_out = tmp_path / "no-such-dir" / "utterance.json" + midi_target = tmp_path / "utterance.midi.json" + wav_target = tmp_path / "utterance.wav" + rc = main( + [ + "say", + "all tests passed", + "--out", + str(bad_out), + "--midi", + str(midi_target), + "--wav", + str(wav_target), + ] + ) + assert rc == 2 + assert not bad_out.exists() + assert not midi_target.exists() + assert not wav_target.exists() + + +# --- explain (criterion 7) ---------------------------------------------------- + + +def test_explain_say_resolves(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "say"]) + assert rc == 0 + out = capsys.readouterr().out + assert "# harmonics say" in out diff --git a/tests/test_stress.py b/tests/test_stress.py new file mode 100644 index 0000000..007e188 --- /dev/null +++ b/tests/test_stress.py @@ -0,0 +1,307 @@ +"""Tests for expressive stress (harmonics/stress.py). + +Covers the five acceptance criteria for TASK t7: + +1. Stressing an index measurably raises that note's velocity and/or pitch + versus the neutral baseline; non-stressed notes are unchanged. +2. An empty ``stressed`` set/list is the neutral baseline: + ``apply_stress(seq, []) == seq``. +3. The ceiling: no stressed note's velocity ever exceeds ``ceiling``, even + when the boost would otherwise overshoot it. +4. Determinism: the same ``(seq, stressed)`` always yields identical output. +5. ``parse_emphasis`` recognizes the documented marker convention(s) and + strips/reports them correctly; no markers -> unchanged text, empty list. + +Plus supporting checks: pitch-class preservation on octave lift, MIDI-range +guarding, accepting ``set`` or ``list`` for ``stressed``, and that the module +stays in the offline, dependency-free core (stdlib + harmonics only). +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import pytest + +from harmonics import stress as stress_module +from harmonics.contour import text_contour +from harmonics.mapping import VELOCITY_CEILING +from harmonics.notes import NoteEvent +from harmonics.stress import ( + STRESS_PITCH_LIFT, + STRESS_VELOCITY_BOOST, + apply_stress, + parse_emphasis, +) + + +def _note( + *, + start: float = 0.0, + duration: float = 0.16, + pitch: int = 60, + velocity: float = 0.5, + voice: str = "chime", +) -> NoteEvent: + return NoteEvent(start=start, duration=duration, pitch=pitch, velocity=velocity, voice=voice) + + +# --- 0. offline, dependency-free core ------------------------------------------------ + + +def test_stress_module_imports_only_stdlib_and_harmonics() -> None: + """Static-parse the module's own imports: every top-level root must be + stdlib or ``harmonics`` — no third-party/audio import in this core.""" + source = Path(stress_module.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + roots.add(node.module.split(".")[0]) + stdlib = set(sys.stdlib_module_names) + disallowed = {m for m in roots if m != "harmonics" and m not in stdlib} + assert not disallowed, f"non-stdlib/non-harmonics imports found: {disallowed}" + + +def test_stress_module_mentions_no_audio_or_network_libraries() -> None: + source = Path(stress_module.__file__).read_text(encoding="utf-8").lower() + forbidden = ( + "sounddevice", + "simpleaudio", + "portaudio", + "pyaudio", + "soundfile", + "wave", + "requests", + "urllib", + "socket", + ) + for name in forbidden: + assert name not in source, f"found forbidden reference: {name}" + + +# --- 1. stressing measurably changes the note; non-stressed is unchanged ------------ + + +def test_stressing_raises_velocity() -> None: + seq = [_note(pitch=60, velocity=0.5)] + result = apply_stress(seq, {0}) + assert result[0].velocity > seq[0].velocity + + +def test_stressing_lifts_pitch_by_an_octave() -> None: + seq = [_note(pitch=60, velocity=0.5)] + result = apply_stress(seq, {0}) + assert result[0].pitch == seq[0].pitch + STRESS_PITCH_LIFT + + +def test_octave_lift_preserves_pitch_class() -> None: + seq = [_note(pitch=64, velocity=0.5)] + result = apply_stress(seq, {0}) + assert (result[0].pitch - seq[0].pitch) % 12 == 0 + + +def test_non_stressed_notes_are_unchanged() -> None: + seq = [ + _note(pitch=60, velocity=0.5), + _note(pitch=64, velocity=0.6), + _note(pitch=67, velocity=0.4), + ] + result = apply_stress(seq, {1}) + assert result[0] == seq[0] + assert result[2] == seq[2] + # Not just equal — the exact same (unmodified) objects. + assert result[0] is seq[0] + assert result[2] is seq[2] + # Only the stressed note differs. + assert result[1] != seq[1] + + +def test_stress_changes_pitch_even_when_velocity_is_already_at_the_ceiling() -> None: + # "and/or": if velocity can't rise further, pitch still carries the + # emphasis, so the note is still measurably different from baseline. + seq = [_note(pitch=60, velocity=VELOCITY_CEILING)] + result = apply_stress(seq, {0}) + assert result[0].velocity == VELOCITY_CEILING + assert result[0].pitch == seq[0].pitch + STRESS_PITCH_LIFT + assert result[0] != seq[0] + + +def test_stress_still_boosts_velocity_when_pitch_lift_would_leave_midi_range() -> None: + # "and/or" the other way: pitch can't lift (would exceed 127), but + # velocity still carries the emphasis. + seq = [_note(pitch=120, velocity=0.5)] + result = apply_stress(seq, {0}) + assert result[0].pitch == seq[0].pitch # lift skipped, stayed in range + assert result[0].velocity > seq[0].velocity + + +def test_multiple_stressed_indices_all_change() -> None: + seq = [_note(pitch=60, velocity=0.5) for _ in range(4)] + result = apply_stress(seq, {0, 2}) + assert result[0] != seq[0] + assert result[1] == seq[1] + assert result[2] != seq[2] + assert result[3] == seq[3] + + +def test_out_of_range_indices_are_ignored() -> None: + seq = [_note(pitch=60, velocity=0.5)] + result = apply_stress(seq, {5, -1, 100}) + assert result == seq + + +def test_stressed_accepts_a_list_or_a_set() -> None: + seq = [_note(pitch=60, velocity=0.5), _note(pitch=64, velocity=0.5)] + from_list = apply_stress(seq, [1]) + from_set = apply_stress(seq, {1}) + assert from_list == from_set + + +# --- 2. empty stressed -> neutral baseline ------------------------------------------- + + +def test_empty_stressed_is_the_neutral_baseline() -> None: + seq = [_note(pitch=60, velocity=0.5), _note(pitch=64, velocity=0.6)] + assert apply_stress(seq, []) == seq + assert apply_stress(seq, set()) == seq + + +# --- 3. the ceiling: boost clamps, never overshoots ---------------------------------- + + +@pytest.mark.parametrize( + "velocity", + [0.6, 0.65, 0.68, 0.7, 0.75, 0.79, 0.8], +) +def test_stressed_velocity_never_exceeds_the_ceiling(velocity: float) -> None: + seq = [_note(pitch=60, velocity=velocity)] + result = apply_stress(seq, {0}) + assert result[0].velocity <= VELOCITY_CEILING + + +def test_boost_clamps_rather_than_overshoots_when_near_ceiling() -> None: + # Pick a velocity where the raw boost would overshoot the default + # ceiling — the result must be exactly clamped to it, not the raw sum. + velocity = 0.7 + assert velocity + STRESS_VELOCITY_BOOST > VELOCITY_CEILING # sanity: would overshoot + seq = [_note(pitch=60, velocity=velocity)] + result = apply_stress(seq, {0}) + assert result[0].velocity == VELOCITY_CEILING + + +def test_custom_ceiling_is_honored() -> None: + seq = [_note(pitch=60, velocity=0.2)] + result = apply_stress(seq, {0}, ceiling=0.3) + assert result[0].velocity <= 0.3 + assert result[0].velocity > seq[0].velocity + + +# --- 4. determinism ------------------------------------------------------------------- + + +def test_apply_stress_is_deterministic() -> None: + seq = [ + _note(pitch=60, velocity=0.5), + _note(pitch=64, velocity=0.6), + _note(pitch=67, velocity=0.4), + ] + first = apply_stress(seq, {0, 2}) + second = apply_stress(seq, {0, 2}) + assert first == second + + +def test_apply_stress_deterministic_across_many_calls() -> None: + seq = [_note(pitch=60, velocity=0.5)] + results = {tuple(apply_stress(seq, {0})) for _ in range(25)} + assert len(results) == 1 + + +def test_parse_emphasis_is_deterministic() -> None: + assert parse_emphasis("push it *now*") == parse_emphasis("push it *now*") + + +# --- 5. parse_emphasis: the documented marker convention ----------------------------- + + +def test_asterisk_marker_strips_and_reports_index() -> None: + clean_text, stressed = parse_emphasis("push it *now*") + assert clean_text == "push it now" + assert stressed == [2] + + +def test_no_markers_returns_unchanged_text_and_empty_list() -> None: + clean_text, stressed = parse_emphasis("push it now") + assert clean_text == "push it now" + assert stressed == [] + + +def test_multiple_asterisk_markers() -> None: + clean_text, stressed = parse_emphasis("*please* push it *now*") + assert clean_text == "please push it now" + assert stressed == [0, 3] + + +def test_all_caps_marker() -> None: + clean_text, stressed = parse_emphasis("push it NOW") + assert clean_text == "push it NOW" + assert stressed == [2] + + +def test_single_letter_caps_word_is_not_stressed() -> None: + # A lone capital ("I") is not a stress marker. + clean_text, stressed = parse_emphasis("I said stop") + assert clean_text == "I said stop" + assert stressed == [] + + +def test_digit_only_word_is_not_treated_as_all_caps() -> None: + clean_text, stressed = parse_emphasis("push it 42") + assert clean_text == "push it 42" + assert stressed == [] + + +def test_both_marker_conventions_combined() -> None: + clean_text, stressed = parse_emphasis("please STOP *now*") + assert clean_text == "please STOP now" + assert stressed == [1, 2] + + +def test_empty_text_returns_empty_and_no_stress() -> None: + assert parse_emphasis("") == ("", []) + + +# --- word-index alignment with text_contour (the point of the convention) ----------- + + +def test_stressed_word_index_aligns_with_text_contour_note_index() -> None: + clean_text, stressed = parse_emphasis("push it *now*") + assert clean_text == "push it now" + assert stressed == [2] + + contour = text_contour(clean_text) + assert len(contour) == 3 # one note per word, matching the index convention + + stressed_contour = apply_stress(contour, stressed) + # The stressed word's note (index 2, "now") changed; the others didn't. + assert stressed_contour[2] != contour[2] + assert stressed_contour[0] == contour[0] + assert stressed_contour[1] == contour[1] + + +def test_parse_emphasis_output_feeds_apply_stress_without_validation() -> None: + # parse_emphasis returns plain word indices; apply_stress must accept + # them directly even though it has no idea how many notes there will be. + _, stressed = parse_emphasis("push it *now*") + seq = [ + _note(pitch=60, velocity=0.5), + _note(pitch=62, velocity=0.5), + _note(pitch=64, velocity=0.5), + ] + result = apply_stress(seq, stressed) + assert result[2] != seq[2] diff --git a/tests/test_variation.py b/tests/test_variation.py new file mode 100644 index 0000000..4e51a0f --- /dev/null +++ b/tests/test_variation.py @@ -0,0 +1,194 @@ +"""Tests for deterministic micro-variation (harmonics/variation.py). + +Covers: the neutral default nonce, determinism/purity across repeated and +cross-process-equivalent calls, a pinned golden value proving the seeding is +hashlib-based (not builtin ``hash()``, not unseeded ``random``), and the +documented small bounds (pitch/duration/voice/count/order untouched, +velocity always in ``[0, 1]``, timing jitter within ``amount * 0.05``s). +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from harmonics.notes import NoteEvent +from harmonics.variation import ( + _MAX_TIMING_JITTER_SECONDS, + _MAX_VELOCITY_DELTA, + DEFAULT_AMOUNT, + DEFAULT_NONCE, + apply_variation, +) + + +def _gesture() -> list[NoteEvent]: + return [ + NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.8, voice="chime"), + NoteEvent(start=0.25, duration=0.15, pitch=64, velocity=0.6, voice="chime"), + NoteEvent(start=0.45, duration=0.1, pitch=67, velocity=0.4, voice="chime"), + ] + + +# --- 1. default nonce is the neutral baseline ----------------------------------- + + +def test_default_nonce_is_zero() -> None: + assert DEFAULT_NONCE == 0 + + +def test_default_nonce_returns_sequence_unchanged() -> None: + seq = _gesture() + assert apply_variation(seq, DEFAULT_NONCE) == seq + + +def test_default_nonce_returns_a_copy_not_the_same_list_object() -> None: + seq = _gesture() + result = apply_variation(seq, DEFAULT_NONCE) + assert result == seq + assert result is not seq + + +# --- 2. determinism / purity ----------------------------------------------------- + + +def test_same_nonce_is_byte_identical_across_calls() -> None: + seq = _gesture() + first = apply_variation(seq, 42) + second = apply_variation(seq, 42) + assert first == second + + +def test_repeated_calls_do_not_depend_on_time_or_entropy() -> None: + # If this module used wall-clock time or unseeded random state, calling + # it many times in a tight loop would eventually disagree with itself. + seq = _gesture() + results = {tuple(apply_variation(seq, "agent-turn-7")) for _ in range(25)} + assert len(results) == 1 + + +def test_string_and_int_nonce_are_independent_inputs() -> None: + # "7" and 7 hash differently (normalized via str()), which is expected — + # the point is each is internally deterministic, not that they collide. + seq = _gesture() + by_int = apply_variation(seq, 7) + by_str = apply_variation(seq, "7") + assert by_int == by_str # str(7) == "7", so these DO coincide + + +def test_pure_function_same_inputs_same_output_object_by_value() -> None: + seq = _gesture() + a = apply_variation(seq, "release-candidate", amount=0.4) + b = apply_variation(seq, "release-candidate", amount=0.4) + assert a == b + + +# --- 3. two different nonces vary, but stay a valid, recognizable gesture ------- + + +def test_different_nonces_produce_different_sequences() -> None: + seq = _gesture() + a = apply_variation(seq, "nonce-a") + b = apply_variation(seq, "nonce-b") + assert a != b + + +def test_variation_preserves_length_pitch_duration_voice_and_order() -> None: + seq = _gesture() + varied = apply_variation(seq, "nonce-a") + assert len(varied) == len(seq) + for original, changed in zip(seq, varied): + assert changed.pitch == original.pitch + assert changed.duration == original.duration + assert changed.voice == original.voice + + +@pytest.mark.parametrize("nonce", ["nonce-a", "nonce-b", 1, 2, 3, "agent-42"]) +def test_velocity_always_stays_in_unit_interval(nonce: int | str) -> None: + seq = _gesture() + varied = apply_variation(seq, nonce) + for ev in varied: + assert 0.0 <= ev.velocity <= 1.0 + + +@pytest.mark.parametrize("amount", [0.0, 0.15, 0.5, 1.0]) +def test_timing_jitter_stays_within_documented_bound(amount: float) -> None: + seq = _gesture() + varied = apply_variation(seq, "bounded-nonce", amount=amount) + bound = amount * _MAX_TIMING_JITTER_SECONDS + for original, changed in zip(seq, varied): + assert abs(changed.start - original.start) <= bound + 1e-12 + assert changed.start >= 0.0 + + +@pytest.mark.parametrize("amount", [0.0, 0.15, 0.5, 1.0]) +def test_velocity_delta_stays_within_documented_bound(amount: float) -> None: + seq = _gesture() + varied = apply_variation(seq, "bounded-nonce", amount=amount) + bound = amount * _MAX_VELOCITY_DELTA + for original, changed in zip(seq, varied): + # The delta is clamped into [0, 1] on top of the raw bound, so the + # observed delta can only be smaller than (never larger than) it. + assert abs(changed.velocity - original.velocity) <= bound + 1e-12 + + +def test_zero_amount_yields_no_jitter_even_with_a_non_default_nonce() -> None: + seq = _gesture() + varied = apply_variation(seq, "any-nonce", amount=0.0) + for original, changed in zip(seq, varied): + assert changed.start == original.start + assert changed.velocity == original.velocity + + +def test_amount_out_of_bounds_raises_value_error() -> None: + seq = _gesture() + with pytest.raises(ValueError): + apply_variation(seq, "x", amount=1.5) + with pytest.raises(ValueError): + apply_variation(seq, "x", amount=-0.1) + + +def test_default_amount_is_small_and_documented() -> None: + assert 0.0 < DEFAULT_AMOUNT <= 1.0 + assert DEFAULT_AMOUNT == 0.15 + + +# --- 4. hashlib-seeded, not builtin hash() — a pinned golden value -------------- + + +def test_variation_is_seeded_with_hashlib_not_builtin_hash() -> None: + """Pin the exact varied value for a known (seq, nonce, amount). + + This value was computed independently here via :mod:`hashlib` (the same + primitive the implementation is required to use), reproducing the + module's documented derivation: for note index 0 of a nonce ``"42"`` + gesture, the signed unit jitter is derived from + ``sha256(b"42:0:t")`` and ``sha256(b"42:0:v")``. Python's builtin + ``hash()`` is randomized per-process for strings (via + ``PYTHONHASHSEED``), so it could never reproduce this fixed value across + process runs; a match here is only possible via a stable, unsalted + digest like SHA-256 over the literal nonce text. + """ + seq = [NoteEvent(start=0.0, duration=0.2, pitch=60, velocity=0.8, voice="chime")] + varied = apply_variation(seq, 42) + + digest_t = hashlib.sha256(b"42:0:t").digest() + unit_t = int.from_bytes(digest_t[:8], "big") / 2**64 + expected_start = max( + 0.0, 0.0 + (unit_t * 2.0 - 1.0) * DEFAULT_AMOUNT * _MAX_TIMING_JITTER_SECONDS + ) + + digest_v = hashlib.sha256(b"42:0:v").digest() + unit_v = int.from_bytes(digest_v[:8], "big") / 2**64 + expected_velocity = min( + 1.0, max(0.0, 0.8 + (unit_v * 2.0 - 1.0) * DEFAULT_AMOUNT * _MAX_VELOCITY_DELTA) + ) + + assert varied[0].start == pytest.approx(expected_start) + assert varied[0].velocity == pytest.approx(expected_velocity) + + # And a literal pinned golden number (regression pin): if the hashing + # primitive or formula ever changes, this catches it immediately. + assert varied[0].start == pytest.approx(0.005435946193450347) + assert varied[0].velocity == pytest.approx(0.8203939913328051) diff --git a/uv.lock b/uv.lock index 7c08db6..776e057 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "harmonics-cli" -version = "0.4.1" +version = "0.5.0" source = { editable = "." } [package.dev-dependencies]