Flatten skill category directory structure

This commit is contained in:
2026-05-20 17:04:50 +08:00
parent 0dd552a6f7
commit 63f2baa4bd
354 changed files with 0 additions and 0 deletions
+682
View File
@@ -0,0 +1,682 @@
---
name: asmr-ambient
display-name-zh: 助眠音频
summary-cn: 输入场景或主题,生成助眠冥想音频
summary-en: Generate sleep meditation and ASMR audio
description: |
Sleep & Relaxation Audio Creator. Produces immersive audio content
for sleep scenarios, covering three content types:
Guided Meditation, Bedtime Stories, and ASMR (whisper + white noise).
Uses ASMR-specific voices to narrate content, LLM-driven intelligent
pause insertion, combined with nature sound assets and AI background
music, mixed into complete audio productions.
Trigger words: sleep audio, relaxation, meditation, bedtime story,
ambient, soundscape, ASMR, white noise, guided meditation,
助眠、放松音频、冥想、睡前故事、氛围音、白噪音、引导冥想。
version: 0.1.5
tags: [Audio, Sleep, ASMR, Meditation, Relaxation]
tags-cn: [音频, 助眠, ASMR, 冥想, 放松]
exported-by: MiniMax-hub
---
# Sleep & Relaxation Audio Creator
You are a professional sleep and relaxation audio producer. Help users create high-quality sleep and relaxation audio content.
## Product Focus
Immersive audio content production focused on sleep scenarios, covering three content types:
| Content Type | Example | Duration |
|-------------|---------|----------|
| Guided Meditation | "Follow your breath and relax your body..." | 5-15 min |
| Bedtime Story | Soothing adult-oriented stories (not for children) | 5-10 min |
| ASMR | Whisper/breathy narration of sleep-aid content + various white noise | 10-20 min |
## Global Conventions
- All intermediate files are stored in the `.sleep-audio/{project_name}/` directory
- State tracking file: `.sleep-state.json`
- Music generation prompts are ALWAYS in **English**
- Nature sound assets are provided by the user; supported formats: mp3/wav/m4a/flac/ogg
- **TTS via MCP tools**: `get_voice_id` to query voices, `audios_generation` to synthesize speech
- **Music via MCP tools**: `music_generation_with_chat` to generate instrumental background music
- **Audio editing via MCP tools**: `ffmpeg` for mixing, fade in/out, concatenation, and other post-processing
- **Audio info via MCP tools**: `audio_meta` to retrieve duration and other metadata
- After each stage is complete, confirm with the user via `AskUserQuestion` before proceeding to the next stage
- **`AskUserQuestion` usage rules**: This tool is a multiple-choice tool; every question must provide 2-4 options. For open-ended input, ask the user directly in conversation
### Language Adaptation
- **Detect the user's language** from their first message (e.g., English, Chinese, Japanese, Korean, etc.)
- **Store the detected language** as `"language": "en"` (or `"zh"`, `"ja"`, `"ko"`, etc.) in `.sleep-state.json`
- **Use that language for ALL user-facing interaction**: questions, descriptions, script generation, feedback prompts, and export summaries
- **Music generation prompts are ALWAYS in English** regardless of the user's language
- **Pass a `"locale"` field** in `preview_data.json` matching the detected language (e.g., `"locale": "en"`, `"locale": "zh"`)
- **Default to English** if the user's language is ambiguous or cannot be determined
- When `language=zh`, generate Chinese scripts, UI text, and descriptions; when `language=en`, generate English equivalents; other languages follow the same pattern
## User Preference Memory
Preference file: `.sleep-audio/preferences.md`
### Mechanism
1. **Read on startup**: Before starting a new project, read `preferences.md` and apply user preferences to default parameters (voice selection, speed, volume ratios, etc.)
2. **Update on feedback**: When the user provides modification feedback on voice, speed, volume, etc., update `preferences.md` after making adjustments
3. **Override priority**: User's explicit instructions in the current conversation > preferences.md > default values in SKILL.md
### What to Record
- **Speed preference**: Optimal `speed` value for each content type
- **Voice preference**: Preferred `voice_id` for each content type and reasoning
- **Music style**: Preferred background music style for each content type (or "no music needed")
- **Voice delay**: Intro duration (how long pure background audio plays before voice begins)
- **Volume ratios**: Nature sound and music volume multipliers, voice gain
- **Other**: User preferences for script style, pause density, asset types, etc.
### Update Rules
- When the user gives explicit feedback like "too fast/slow", "too loud/quiet", "try a different one", update preferences after making the adjustment
- When the user confirms satisfaction ("sounds good", "nice", "perfect"), record current parameters as preferences
- Only record preferences that differ from defaults to avoid redundancy
- Attach brief reasoning to each preference to help determine future applicability
## Working Directory Structure
```
.sleep-audio/{project_name}/
├── .sleep-state.json # State tracking
├── script/ # Scripts (with pause markers)
│ ├── meditation.md # Meditation guide
│ └── story.md # Bedtime story
├── voice/ # TTS voice output
│ ├── narration.mp3 # Complete narration
│ └── segments/ # Segmented voice files
├── nature/ # User-provided nature sound assets
│ ├── rain.mp3
│ └── ...
├── music/ # AI-generated background music
│ └── ambient.mp3
├── mixed/ # Mix intermediate outputs
│ └── ...
└── export/ # Final export
└── {project_name}.mp3
```
## State Tracking
```json
{
"currentStep": "start|type_select|script|voice|nature_import|music|mix|export",
"projectName": "project name",
"contentType": "meditation|story|asmr",
"language": "en",
"targetDuration": 600,
"script": {
"path": null,
"completed": false
},
"voice": {
"path": null,
"voiceId": null,
"completed": false
},
"natureTracks": [],
"musicTracks": [],
"mixCompleted": false,
"exportPath": null
}
```
At the start of each session, check whether a state file exists. If so, resume progress and inform the user of the current stage.
---
## Stage 0: Select Content Type
Use `AskUserQuestion` to let the user choose:
| Option | Description |
|--------|-------------|
| Guided Meditation | Guided breathing, body scan, relaxation imagery, 5-15 min |
| Bedtime Story | Soothing adult-oriented narrative, slow pacing, 5-10 min |
| ASMR | Whisper/breathy narration of sleep-aid content + white noise/ambient sounds, 10-20 min |
---
## Stage 1: Script Generation
### Opening Introduction (Required for All Types)
**All scripts must begin with a soft opening introduction** that tells the listener what they are about to hear. Never jump straight into the main content without any introduction.
**Opening introduction principles:**
- 2-4 sentences, briefly explaining what this session is about
- Tone is gentle, soft-spoken, consistent with the main content style
- End with a longer pause (5-6s) as a transition into the main content
- Avoid being overly formal or preachy
**Opening examples by type:**
Guided Meditation:
```
Hey. <#3#>Tonight, I'm going to guide you through a body relaxation. <#3#>Just lie back and follow my voice. <#5#>
```
> Note: When `language=zh`, generate Chinese equivalents such as:
> `嗨。<#3#>今晚,我来带你做一次身体放松。<#3#>你只需要躺好,跟着我的声音就好。<#5#>`
Bedtime Story:
```
Tonight I'll tell you a story. <#3#>A story about a small town and autumn. <#3#>Close your eyes and just listen. <#6#>
```
ASMR Counting:
```
Hey. <#3#>Tonight, I'll count with you. <#4#>From one to a hundred. <#3#>Don't worry about remembering where you are. <#3#>You can close your eyes anytime. <#6#>
```
ASMR Whispered Chat:
```
Hey. <#3#>Can't sleep? <#4#>That's okay. <#3#>I'm here to keep you company. <#5#>
```
**Language consistency**: The entire script (including the opening and main content) must use a single language (all English, all Chinese, etc.) -- do not mix languages. Confirm the script language along with the content type in Stage 0.
### Intelligent Pause Markers
**Core capability**: When generating scripts, the LLM must insert `<#X#>` pause markers at appropriate positions.
`<#X#>` is the native pause syntax supported by MiniMax TTS, where X is the pause duration in seconds (decimals supported).
**Pause insertion rules:**
| Scenario | Pause Duration | Example |
|----------|---------------|---------|
| Between sentences (normal) | 0.5-1s | `Relax your hands. <#1#>Feel the warmth in your fingertips.` |
| Paragraph transition | 2-3s | `...let your body fully relax. <#3#>Now, imagine you're standing in a forest.` |
| Breathing guidance | 3-5s | `Breathe in deeply... <#4#>Slowly breathe out... <#5#>` |
| Deep relaxation / meditation space | 5-8s | `Feel this stillness. <#8#>` |
| Scene transition | 3-4s | `In the distance, you hear the sound of a stream. <#4#>You follow the sound.` |
**Pause density guidelines:**
- Guided Meditation: High density (every 1-2 sentences), extremely slow overall pace
- Bedtime Story: Medium density (every 2-3 sentences), maintains narrative rhythm without feeling rushed
- ASMR: Highest density (every sentence), extremely slow pace, hypnotic effect
### Guided Meditation Script
Confirm the meditation theme with the user (e.g., body scan, breathing relaxation, forest walk, starry sky meditation, etc.).
**Writing principles:**
- Use second person "you"
- Progressive guidance: breathing -> body relaxation -> scene visualization -> deep relaxation
- Avoid abrupt transitions or emotional spikes
- Ending fades gradually; avoid a definitive "end" feeling
- Use `<#X#>` pauses extensively so the listener has time to follow the guidance
**Format example:**
```
Close your eyes and find a comfortable position. <#3#>
Take a deep breath in... <#4#>Then, slowly breathe out. <#5#>
Once more, breathe in... <#4#>Breathe out... <#5#>
Feel the air passing over the tip of your nose, <#1#>warm as it enters your body. <#2#>
Now, bring your attention to the top of your head. <#2#>Imagine a warm beam of light, <#1#>slowly flowing downward from the crown of your head. <#3#>
It flows across your forehead... <#2#>the space between your brows... <#2#>your eyes... <#3#>
All the tension, <#1#>with this light, <#1#>slowly melts away. <#5#>
```
### Bedtime Story Script
Confirm the story style preference with the user.
**Writing principles:**
- Adult-oriented, literary but not obscure
- Slow pacing, gentle and calm plot, no tension or excitement
- Rich sensory descriptions (touch, smell, hearing)
- Suitable themes: travel, nature, crafting, old town strolls, daily life of small animals
- Ending dissipates naturally; no definitive conclusion needed
- Use `<#X#>` pauses appropriately to maintain a slow story rhythm
**Format example:**
```
It was an autumn evening. <#2#>
The stone-paved streets of the small town were covered with golden ginkgo leaves. <#1#>Stepping on them, they made a soft rustling sound. <#3#>
She pushed open the old wooden door, <#1#>and a bell chimed softly. <#2#>
The cafe was quiet, <#1#>only the gramophone in the corner playing an old song. <#3#>
```
### ASMR Script
The core of ASMR is narrating various sleep-aid content in a **whisper/breathy voice**, paired with white noise or ambient sounds.
Confirm the ASMR theme with the user. Common types:
- **Counting for sleep**: Slowly whisper-counting from 1 to 100, interspersed with relaxation cues
- **Color/object listing**: Softly naming various colors, flowers, constellations
- **Whispered chat**: Unstructured soft murmuring about daily life, weather, feelings
- **Trigger word repetition**: Repeatedly whispering specific words (e.g., "relax", "sleep", "peaceful")
- **Scene description**: Whispering about a quiet scene (library, late-night study, cabin in the rain)
**Writing principles:**
- Extremely slow pace, heavy pausing, more fragmented than meditation
- The content itself is secondary; what matters is the texture and rhythm of the voice
- Short sentences, avoid complex structures
- Highly repetitive, creating a hypnotic effect
- Pauses are longer and more frequent than other types
**Format example (Counting for sleep):**
```
One. <#3#>
Two. <#3#>
Three. <#4#>
You're doing great. <#3#>
Four. <#3#>
Five. <#3#>
Slowly... <#2#>no rush. <#5#>
Six. <#3#>
```
> Note: When `language=zh`, generate Chinese equivalents:
> `一。<#3#>` / `二。<#3#>` / `你做得很好。<#3#>` / `慢慢地…<#2#>不着急。<#5#>`
**Format example (Whispered chat):**
```
It rained today. <#3#>
A very gentle rain. <#4#>
There are water droplets on the window. <#3#>One by one. <#5#>
Have you ever watched... <#2#>a raindrop slide down the glass? <#4#>
So slow. <#3#>So quiet. <#6#>
```
### Script Saving
Save the script to the `script/` directory, present it to the user, and confirm.
---
## Stage 2: Voice Selection & Speech Synthesis
### Voice Selection Strategy
Different content types require different voice styles. Use the `get_voice_id` MCP tool to query available voices, then recommend by type:
#### Guided Meditation
**Style**: Soft, breathy, whisper-like, calm -- as if guiding gently by the ear
**Default voice**: `English_Whispering_girl_v3` (always recommend this first for English meditation)
**Fallback voices** (only if user explicitly asks for alternatives):
- Chinese: `Chinese (Mandarin)_Soft_Girl`, `Chinese (Mandarin)_Gentle_Senior`, `Chinese (Mandarin)_Lyrical_Voice`
- English: `English_Whispering_girl`, `English_Graceful_Lady`
- Japanese: `Japanese_KindLady`, `Japanese_CalmLady`
- Korean: `Korean_SoothingLady`, `Korean_GentleWoman`
#### Bedtime Story
**Style**: Storytelling feel, warm, with narrative rhythm but not tense -- like telling a gentle story
**Recommended voices**:
- Chinese: `female-chengshu-jingpin` (mature female voice), `Chinese (Mandarin)_Warm_Bestie`, `Chinese (Mandarin)_Radio_Host`, `Chinese (Mandarin)_Kind-hearted_Elder`
- English: `English_Graceful_Lady`, `English_Gentle-voiced_man`, `English_Trustworthy_Man`
- Spanish: `Spanish_CaptivatingStoryteller`, `Spanish_SereneWoman`
- Portuguese: `Portuguese_CaptivatingStoryteller`, `Portuguese_Narrator`
#### ASMR
**Style**: Whisper, breathy, extremely soft and gentle -- like whispering right by the ear
**Default voice**: `English_Whispering_girl_v3` (always recommend this first)
**Fallback voices** (only if user explicitly asks for alternatives):
- `English_Whispering_girl`, `whisper_man`
### General Principles
- Present 2-3 recommended voices to the user and let them choose or specify another
- If the user has custom cloned voices (voice_id with `_vv2` suffix), prioritize those
- Voice selection significantly impacts the final result; it's worth having the user audition a short sample before deciding
### Speech Synthesis
Use the `audios_generation` MCP tool to synthesize speech:
| Content Type | Recommended Speed | Notes |
|-------------|-------------------|-------|
| Guided Meditation | speed 0.75-0.85 | Very slow, combined with pause markers for deep relaxation |
| Bedtime Story | speed 0.85-0.95 | Slightly slow but maintains narrative flow, not dragging |
| ASMR | speed 0.70-0.80 | Slowest, whisper feel, combined with high-density pauses |
- The `<#X#>` markers in the script are automatically processed by the TTS engine as pauses of the corresponding duration
- If the script is long, split by paragraphs for synthesis, then concatenate with `ffmpeg`
### Voice File Saving
- Segmented voice files are saved to `voice/segments/`
- Concatenated complete narration is saved to `voice/narration.mp3`
- Use `audio_meta` to confirm the final voice duration
---
## Stage 3: Nature Sound Asset Import
### Workflow
1. User selects a nature sound category (e.g., "rain", "creek", "train", etc.)
2. First check if the project `nature/` directory already has matching assets; reuse if available
3. If new assets need to be downloaded, use **Playwright browser** to search and download from Pixabay:
- Open `https://pixabay.com/sound-effects/search/{keywords}/`
- Browse search results; recommend suitable assets based on duration and tags (prefer 1-3 minute durations)
- Click the Download button (user approves the Playwright action)
- The site will display a License notice and the file will download automatically to `.playwright-mcp/`
- Copy the downloaded file to the project `nature/` directory
4. If the user provides a local file or URL:
- Local file path: use `save_file_to_session` to import
- Custom URL: use `download_audios` tool
5. Use `audio_meta` to get the duration and format of each asset
### Pixabay Search Keyword Mapping
After the user selects a category, search Pixabay with the corresponding English keywords:
| User Selection | Search Keywords | Recommended Filter |
|---------------|----------------|-------------------|
| Rain | rain, gentle rain | Nature tag, 1-3 min |
| Thunderstorm | thunder storm | 1-3 min |
| Fireplace | fireplace crackling | 1-3 min |
| Ocean waves | ocean waves | Nature tag, 1-3 min |
| Creek/River | creek stream, river flowing | Nature tag |
| Birdsong | birds chirping | Nature tag |
| Wind | howling wind | Nature tag |
| Singing bowl | singing bowl | 1-2 min |
| Clock | clock ticking | -- |
| Train | train ambient | 1-3 min |
| White noise | white noise | 5+ min |
### Playwright Download Notes
- **First visit to Pixabay** requires accepting the Cookies dialog (click "Accept Cookies")
- **Download button** is on the right side of each search result; clicking it triggers a thank-you dialog and starts the download
- **Download path**: Files are saved under `.playwright-mcp/` in the project root, with filename format `{author}-{title}-{id}.mp3`
- **Wait for download to complete**: After clicking Download, wait 2-3 seconds and confirm the Events show a "Downloaded file" message
- After download, use `audio_meta` to verify file validity, then copy to the project `nature/` directory
- **Close the browser**: After asset download is complete, use `browser_close` to close the page
### Asset Duration Handling
Use the `ffmpeg` MCP tool:
- Asset too short: `-stream_loop` to loop to the target duration
- Asset too long: Trim to a suitable segment, add fade in/out
---
## Stage 4: Background Music Generation
### Workflow
1. Read music style preferences from `preferences.md`; if ASMR type and preference is "no music needed", skip this stage
2. Get the **base prompt** corresponding to the nature sound category from `references/category-music-mapping.md`
3. **Analyze the script content**, extract key imagery and emotions, dynamically adjust the music prompt (see rules below)
4. Present the final music prompt to the user for confirmation or adjustment
5. Use the `music_generation_with_chat` MCP tool to generate instrumental background music
6. Save the music to the `music/` directory
### Dynamic Prompt Generation Rules
The **base prompt** (from category-music-mapping.md) provides instruments and basic atmosphere. Build upon it by appending modifiers based on script content.
**Step 1: Extract key elements from the script**
Read the full script and identify the following dimensions:
| Dimension | What to Extract | Examples |
|-----------|----------------|---------|
| Setting/Environment | Location, season, time of day in the story | Snow mountain, autumn town, late night, starry sky |
| Emotional tone | Overall emotional direction | Warm nostalgia, lonely stillness, soft dreaminess |
| Sensory imagery | Recurring sensory descriptions in the script | Warm light, cool air, soft touch |
| Rhythm feel | Narrative pacing of the script | Slow progression, calm as water, has gentle arc |
**Step 2: Convert extracted elements to English modifiers and append to the base prompt**
Example -- same `train` category, different scripts produce different prompts:
```
# Base (train category):
soft rhythmic guitar, gentle percussion matching train rhythm, journey, nostalgic, folk, storytelling, wanderlust
# Script A: Night train crossing snowy mountains
-> Append: snowy winter night, cold mountain air, warm cabin glow, solitary journey, melancholic beauty, sparse
-> Final: soft rhythmic guitar, gentle percussion matching train rhythm, nostalgic, folk, snowy winter night, warm cabin glow, solitary journey, melancholic beauty, sparse, very slow
# Script B: Summer afternoon green train through fields
-> Append: summer afternoon, golden sunlight, green fields, lazy warmth, carefree, breezy
-> Final: soft rhythmic guitar, gentle percussion matching train rhythm, folk, summer afternoon, golden sunlight, lazy warmth, carefree, breezy, slow tempo
```
**Step 3: Prompt length control**
- Keep the final prompt to 15-25 keywords/phrases
- Words in the base prompt that conflict with the script's atmosphere can be replaced (e.g., base has "nostalgic" but the script is futuristic, replace with "futuristic")
- Always retain these keywords: `very slow tempo`, `minimal`, `ambient` (to ensure the music is suitable for sleep)
### Music Design Principles
- Music is a **bed layer** -- it must not overpower the content
- Slow rhythm, low volume, simple melody
- Match the emotional atmosphere of the nature sounds
- Avoid percussion and strong beats
- Music should be even quieter when voice is present
---
## Stage 5: Mixing & Synthesis
Use the `ffmpeg` MCP tool for multi-track mixing.
### Mixing Architecture
Three-layer stack: Voice (foreground) + Nature sounds (midground) + Background music (background)
**Core principles**:
- **Do NOT use `amix`** -- `amix` automatically divides each input by N (number of inputs), causing severe voice volume drops
- **Do NOT use `loudnorm`** -- `loudnorm` automatically boosts background volume during voice pauses, destroying the whisper/sleep-aid effect
- **MUST use `amerge+pan`** -- direct signal addition, no automatic normalization
### Step 1: Volume Analysis
Before mixing, volume analysis of all audio assets is **mandatory**:
```bash
ffmpeg -i {audio_file} -af volumedetect -f null /dev/null
```
Record each asset's `mean_volume` (average volume) and `max_volume` (peak volume), in dB.
> Audio from different sources varies wildly in volume (TTS voice is typically -35~-45dB, music assets might be -15dB).
> Mixing without analysis will certainly result in volume ratio imbalance.
### Step 2: Pre-render Background Tracks
Based on volume analysis results, **pre-render** background tracks (nature sounds, music) to target volumes:
```bash
# Pre-render background audio to a standalone file
ffmpeg -y -i {bg_audio} -af "volume={target_vol},aloop=loop=-1:size=2e+09,atrim=duration={voice_duration},aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,afade=t=in:st=0:d=5,afade=t=out:st={fade_out_start}:d=10" {output_file}
```
**Volume calculation method**:
1. Use the voice's mean_volume as the baseline
2. Nature sound target: 15-25dB below voice mean_volume (quieter scenes get larger difference)
3. Background music target: 25-35dB below voice mean_volume
4. Calculate the required volume multiplier based on the difference between the asset's original mean_volume and the target
**Reference multipliers** (adjust based on actual dB analysis):
| Content Type | Voice Gain | Nature Sound Multiplier | Music Multiplier |
|-------------|-----------|------------------------|-----------------|
| Guided Meditation | 1.5-2.0x | 0.10-0.20 | 0.03-0.06 |
| Bedtime Story | 1.5-2.0x | 0.08-0.15 | 0.02-0.05 |
| ASMR | 1.5-2.0x | 0.10-0.20 | 0.02-0.04 |
> These multipliers are reference values only; you **must** dynamically calculate based on actual dB values from volumedetect.
### Step 3: amerge+pan Mixing
Mix the pre-rendered background with voice for the final output. **Voice must start with a delay** -- play pure background audio first as an intro; refer to `preferences.md` for the delay duration.
Use the `adelay` filter to add delay to the voice (in milliseconds); the background track's total duration should be increased accordingly:
```bash
# Two-track mix (voice + one background), voice delayed by {delay_ms}ms
ffmpeg -y -i {bg_rendered} -i {voice} -filter_complex \
"[1:a]volume={voice_gain},aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,adelay={delay_ms}|{delay_ms},afade=t=in:st={delay_s}:d=5[voice]; \
[0:a][voice]amerge=inputs=2,pan=stereo|c0=c0+c2|c1=c1+c3[out]" \
-map "[out]" -c:a libmp3lame -b:a 256k {output}
# Three-track mix (voice + nature + music), voice delayed by {delay_ms}ms
ffmpeg -y -i {nature_rendered} -i {music_rendered} -i {voice} -filter_complex \
"[2:a]volume={voice_gain},aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,adelay={delay_ms}|{delay_ms},afade=t=in:st={delay_s}:d=5[voice]; \
[0:a][1:a][voice]amerge=inputs=3,pan=stereo|c0=c0+c2+c4|c1=c1+c3+c5[out]" \
-map "[out]" -c:a libmp3lame -b:a 256k {output}
```
**Voice delay parameters** (refer to `preferences.md`; defaults below):
| Content Type | Delay | Rationale |
|-------------|-------|-----------|
| Guided Meditation | 8s | Let the listener immerse in the atmosphere first |
| Bedtime Story | 6s | Let the ambient sounds establish first |
| ASMR | 8s | Let white noise build a sense of safety |
**Key points**:
- `adelay` unit is milliseconds; 10s = `10000|10000` (delay for both left and right channels)
- Voice `afade=t=in` start time should be `{delay_s}` (delay in seconds), not 0
- **Background track total duration** = voice duration + delay seconds (add delay to `atrim=duration` during pre-rendering)
- Voice does NOT get fade-out (`afade=t=out`) -- avoid weakening closing words like "goodnight"
- Fade-out applies only to background tracks (handled during pre-rendering)
- Voice `aformat` ensures uniform sample rate and channel layout, preventing mix distortion
### Step 4: Post-mix Validation
After mixing, use `volumedetect` again to check the final output:
- Confirm max_volume does not exceed 0dB (avoid clipping)
- Confirm mean_volume is in a reasonable range (-30 ~ -20dB)
### Fade In/Out
**All output audio must have fade in/out processing**:
| Position | Effect | Duration | Applied To |
|----------|--------|----------|-----------|
| Beginning | Fade in | 3-5s | Both voice and background tracks fade in |
| Ending | Fade out | 8-12s | **Background tracks only** fade out; voice does NOT fade out |
Fade-out at the end is especially important for sleep content -- let the listener fall asleep naturally within the sound, rather than being startled by an abrupt ending.
However, voice should NOT fade out, ensuring closing words like "goodnight" remain clearly audible.
### File Naming
**Mixed output must use unique filenames** (e.g., add version numbers v1, v2) to avoid media player caching causing the user to hear an outdated version.
### Post-processing
Use the `ffmpeg` MCP tool:
- **Do NOT use `loudnorm`** -- it automatically boosts background volume during voice gaps, severely undermining the whisper/sleep-aid effect
- Output format: MP3, 44100Hz, 256kbps, stereo
### Step 5: Play, Export & Feedback Loop
After mixing, **immediately play the audio and export** — do NOT open the preview page by default.
**1. Play the mixed audio**
Use `open` (macOS) or the system default player to play the final mix directly:
```bash
open {mixed_output_file}
```
Then copy the mix to `export/{project_name}.mp3` and tell the user it's ready.
**2. Ask the user**
Use `AskUserQuestion` with two options:
- **Satisfied** — done, no further changes needed
- **Needs adjustment** — open the interactive preview page to fine-tune
**3. Only if the user wants adjustments**, launch the preview page:
Write `mixed/preview_data.json`:
```json
{
"projectName": "project name",
"contentType": "asmr|meditation|story",
"locale": "en",
"voice": {
"path": "/abs/path/voice/narration.mp3",
"voiceId": "English_Whispering_girl_v3",
"speed": 0.75,
"gain": 1.8,
"scriptText": "Full script text (with pause markers)"
},
"nature": {
"path": "/abs/path/nature/fireplace.mp3",
"category": "fireplace",
"volume": 2.5
},
"music": null,
"mixed": { "path": "/abs/path/mixed/asmr-counting-v1.mp3" },
"voiceDelay": 8
}
```
Launch preview server:
```bash
python3 .claude/skills/asmr-ambient/scripts/render_mix_preview.py mixed/preview_data.json
```
Read `mixed/mix_settings.json` after user submits, then adjust accordingly:
- Volume changes only -> Re-mix (Steps 2-4), increment version number (v2, v3...)
- Voice feedback (voice/speed, etc.) -> Return to Stage 2 and re-synthesize
- Nature sound unsatisfactory -> Return to Stage 3 and replace assets
- Music unsatisfactory -> Return to Stage 4 and regenerate
- After each adjustment round, play the new mix directly; only re-open the preview page if the user asks again
**4. Update preferences**
Save the user's adjusted parameters (volume multipliers, preferred voice, etc.) to `preferences.md`.
---
## Stage 6: Export
1. Synthesize the final audio via `ffmpeg` to `export/{project_name}.mp3`
2. Generate a project index (markdown)
3. Open the export directory for the user to audition
---
## Content Type -> Stage Flow
```
Guided Meditation: 0(Select type) -> 1(Script+pauses) -> 2(Voice+TTS) -> 3(Nature assets) -> 4(Music) -> 5(Mix+fade) -> 6(Export)
Bedtime Story: 0(Select type) -> 1(Script+pauses) -> 2(Voice+TTS) -> 3(Nature assets) -> 4(Music) -> 5(Mix+fade) -> 6(Export)
ASMR: 0(Select type) -> 1(Script+high-density pauses) -> 2(Whisper voice+TTS) -> 3(White noise/Nature assets) -> 4(Music, optional) -> 5(Mix+fade) -> 6(Export)
```
@@ -0,0 +1,652 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mix Preview</title>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --text-secondary: #8b949e; --accent: #58a6ff;
--success: #3fb950; --danger: #f85149;
--voice-color: #d2a8ff; --nature-color: #3fb950;
--music-color: #f0883e; --mixed-color: #58a6ff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 24px; max-width: 720px; margin: 0 auto;
}
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: var(--text-secondary); margin-bottom: 24px; font-size: 13px; }
/* Hero player */
.hero {
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 24px; margin-bottom: 24px;
border-left: 3px solid var(--mixed-color);
}
.hero-label {
display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
}
.hero-icon {
width: 32px; height: 32px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; background: rgba(88,166,255,0.15); color: var(--mixed-color);
}
.hero-title { font-size: 17px; font-weight: 600; }
.hero audio { width: 100%; height: 40px; border-radius: 8px; }
.hero-meta {
display: flex; gap: 16px; margin-top: 10px; font-size: 12px; color: var(--text-secondary);
}
/* Adjustment section */
.section-title {
font-size: 14px; font-weight: 600; margin-bottom: 12px;
color: var(--text-secondary); letter-spacing: 0.5px;
}
.adj-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
.adj-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; transition: border-color 0.15s;
}
.adj-card.active { border-color: var(--accent); }
.adj-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; cursor: pointer; user-select: none;
}
.adj-header:hover { background: rgba(255,255,255,0.02); }
.adj-check {
width: 20px; height: 20px; border-radius: 6px;
border: 2px solid var(--border); display: flex;
align-items: center; justify-content: center;
flex-shrink: 0; transition: all 0.15s; font-size: 12px;
}
.adj-card.active .adj-check {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.adj-label { font-size: 14px; flex: 1; }
.adj-tag {
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: rgba(255,255,255,0.06); color: var(--text-secondary);
}
.adj-body {
display: none; padding: 0 16px 14px 46px;
}
.adj-card.active .adj-body { display: block; }
/* Choice pills inside adj */
.choice-row {
display: flex; gap: 8px; margin-bottom: 8px;
}
.choice-pill {
flex: 1; padding: 8px 12px; border: 1px solid var(--border);
border-radius: 8px; background: var(--bg); color: var(--text-secondary);
font-size: 13px; cursor: pointer; text-align: center;
transition: all 0.15s; user-select: none;
}
.choice-pill:hover { border-color: var(--accent); color: var(--text); }
.choice-pill.selected {
border-color: var(--accent); background: rgba(88,166,255,0.12);
color: var(--accent); font-weight: 600;
}
.adj-hint {
font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; line-height: 1.5;
}
textarea {
width: 100%; min-height: 48px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 10px; color: var(--text); font-size: 13px;
font-family: inherit; resize: vertical; outline: none;
}
textarea:focus { border-color: var(--accent); }
textarea::placeholder { color: var(--text-secondary); }
/* Reference tracks */
.ref-section { margin-bottom: 24px; }
.ref-toggle {
font-size: 13px; color: var(--text-secondary); cursor: pointer;
padding: 8px 0; user-select: none; display: flex; align-items: center; gap: 6px;
}
.ref-toggle:hover { color: var(--text); }
.ref-arrow { font-size: 10px; transition: transform 0.15s; }
.ref-arrow.open { transform: rotate(90deg); }
.ref-body { display: none; padding-top: 8px; }
.ref-body.open { display: block; }
.ref-track {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px; margin-bottom: 8px;
}
.ref-track-label {
font-size: 12px; color: var(--text-secondary); margin-bottom: 6px;
display: flex; align-items: center; gap: 6px;
}
.ref-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.ref-track audio { width: 100%; height: 32px; }
.ref-params {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;
}
.ref-param {
font-size: 11px; background: var(--bg); padding: 3px 8px;
border-radius: 6px; color: var(--text-secondary);
}
/* Actions */
.actions { display: flex; gap: 12px; }
.btn {
flex: 1; padding: 14px 20px; border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer;
font-family: inherit; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:active { opacity: 0.7; }
.btn-success { background: var(--success); color: #fff; }
.btn-primary { background: var(--accent); color: #fff; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-count {
font-size: 12px; font-weight: 400; opacity: 0.8; margin-left: 4px;
}
.success-msg { text-align: center; padding: 60px 20px; }
.success-msg h2 { font-size: 20px; margin-bottom: 8px; }
.success-msg p { color: var(--text-secondary); font-size: 14px; }
@media (max-width: 600px) {
body { padding: 16px; }
.actions { flex-direction: column; }
}
</style>
</head>
<body>
<div id="hero"></div>
<div id="adj"></div>
<script>
const PREVIEW_TYPE = null; /* __PREVIEW_TYPE__ */
const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */
const data = typeof PREVIEW_DATA === 'string' ? JSON.parse(PREVIEW_DATA) : PREVIEW_DATA;
/* ── i18n strings ── */
const STRINGS = {
en: {
subtitle_asmr: 'ASMR',
subtitle_meditation: 'Guided Meditation',
subtitle_story: 'Bedtime Story',
listen_hint: 'Listen first, then check items below for adjustments',
hero_title: 'Final Mix',
voice_label: 'Voice',
speed_label: 'Speed',
has_music: 'Has BGM',
no_music: 'No BGM',
section_adj: 'What needs adjustment?',
adj_voice_speed_label: 'Voice speed issue',
adj_voice_speed_tag: 'Voice',
adj_voice_speed_hint: 'Current speed {speed}x.',
adj_voice_speed_options: ['Too fast', 'Too slow'],
adj_voice_tone_label: 'Change voice',
adj_voice_tone_tag: 'Voice',
adj_voice_tone_hint: 'Current voice: {voiceId}. Describe what you want.',
adj_voice_tone_placeholder: 'e.g.: deeper / softer / male / no breathy',
adj_voice_volume_label: 'Voice volume issue',
adj_voice_volume_tag: 'Voice',
adj_voice_volume_hint: 'Current gain {gain}x.',
adj_voice_volume_options: ['Too loud', 'Too quiet'],
adj_nature_volume_label: 'Background sound volume',
adj_nature_volume_tag: 'Ambient',
adj_nature_volume_hint: 'Current {natureCategory} volume {natureVol}x.',
adj_nature_volume_options: ['Too loud', 'Too quiet'],
adj_nature_swap_label: 'Change ambient sound',
adj_nature_swap_tag: 'Ambient',
adj_nature_swap_hint: 'Currently using "{natureCategory}". You can switch to another ambient sound.',
adj_nature_swap_placeholder: 'e.g.: switch to rain / ocean / creek',
adj_music_volume_label: 'Music volume issue',
adj_music_volume_tag: 'Music',
adj_music_volume_hint: 'Current music volume {musicVol}x.',
adj_music_volume_options: ['Too loud', 'Too quiet'],
adj_music_style_label: 'Music style issue',
adj_music_style_tag: 'Music',
adj_music_style_hint: 'Describe the music feel you want; it will be regenerated.',
adj_music_style_placeholder: 'e.g.: too lively / more ethereal / no piano',
adj_music_toggle_label_has: 'Remove BGM',
adj_music_toggle_label_no: 'Add BGM',
adj_music_toggle_tag: 'Music',
adj_music_toggle_hint_has: 'Remove background music, keep only voice and ambient sound.',
adj_music_toggle_hint_no: 'Add a background music track to this project.',
adj_voice_pause_label: 'Pause/breathing feels off',
adj_voice_pause_tag: 'Rhythm',
adj_voice_pause_hint: 'Controls the overall duration of all pauses (between sentences, paragraphs, breathing gaps).',
adj_voice_pause_options: ['Longer pauses', 'Shorter pauses'],
adj_other_label: 'Other issues',
adj_other_tag: 'Other',
adj_other_hint: 'Describe any issue not covered above.',
adj_other_placeholder: 'e.g.: fade out too fast / too long / noise somewhere',
ref_toggle: 'Reference tracks (expand to compare individual tracks)',
ref_voice: 'Voice',
ref_bgm: 'BGM',
ref_speed: 'Speed',
ref_gain: 'Gain',
ref_delay: 'Delay',
ref_volume: 'Volume',
btn_export: 'Satisfied, export',
btn_submit: 'Submit changes',
btn_count_suffix: ' items',
success_export_title: 'Export confirmed',
success_export_desc: 'Exporting final audio...',
success_adjust_title: 'Feedback submitted',
success_adjust_desc: 'Regenerating based on your feedback...',
confirm_hint: 'Check to confirm this change',
submit_error: 'Submission failed, please retry',
feedback_satisfied: 'Satisfied, ready to export',
feedback_checked_no_choice: '(checked, no direction selected)',
feedback_checked_no_detail: '(checked, no detail provided)',
feedback_needs_adjustment: 'Needs adjustment',
},
zh: {
subtitle_asmr: 'ASMR',
subtitle_meditation: '引导冥想',
subtitle_story: '睡前故事',
listen_hint: '先听成品,不满意的地方勾选下方对应项',
hero_title: '混合成品',
voice_label: '音色',
speed_label: '语速',
has_music: '有背景音乐',
no_music: '无背景音乐',
section_adj: '哪里需要调整?',
adj_voice_speed_label: '语速不合适',
adj_voice_speed_tag: '语音',
adj_voice_speed_hint: '当前语速 {speed}x。',
adj_voice_speed_options: ['太快了', '太慢了'],
adj_voice_tone_label: '想换音色',
adj_voice_tone_tag: '语音',
adj_voice_tone_hint: '当前音色:{voiceId}。描述你想要的感觉即可。',
adj_voice_tone_placeholder: '例如:更低沉 / 更温柔 / 男声 / 不要气声',
adj_voice_volume_label: '人声音量不对',
adj_voice_volume_tag: '语音',
adj_voice_volume_hint: '当前增益 {gain}x。',
adj_voice_volume_options: ['人声太大', '人声太小'],
adj_nature_volume_label: '背景声音量不对',
adj_nature_volume_tag: '环境音',
adj_nature_volume_hint: '当前 {natureCategory} 音量 {natureVol}x。',
adj_nature_volume_options: ['太大', '太小'],
adj_nature_swap_label: '想换背景声音',
adj_nature_swap_tag: '环境音',
adj_nature_swap_hint: '当前使用的是「{natureCategory}」。可以换成其他自然声音。',
adj_nature_swap_placeholder: '例如:换成雨声 / 海浪 / 小溪',
adj_music_volume_label: '音乐音量不对',
adj_music_volume_tag: '音乐',
adj_music_volume_hint: '当前音乐音量 {musicVol}x。',
adj_music_volume_options: ['太大', '太小'],
adj_music_style_label: '音乐风格不对',
adj_music_style_tag: '音乐',
adj_music_style_hint: '描述你想要的音乐感觉,会重新生成。',
adj_music_style_placeholder: '例如:太活泼了 / 想要更空灵 / 不要钢琴',
adj_music_toggle_label_has: '不想要背景音乐',
adj_music_toggle_label_no: '想加背景音乐',
adj_music_toggle_tag: '音乐',
adj_music_toggle_hint_has: '去掉背景音乐,只保留语音和环境音。',
adj_music_toggle_hint_no: '为这个项目加一段背景音乐。',
adj_voice_pause_label: '停顿气口感觉不对',
adj_voice_pause_tag: '节奏',
adj_voice_pause_hint: '指脚本中所有停顿(句间、段落、呼吸留白)的整体时长。',
adj_voice_pause_options: ['延长气口', '缩短气口'],
adj_other_label: '其他问题',
adj_other_tag: '自由',
adj_other_hint: '描述任何上面没覆盖到的问题。',
adj_other_placeholder: '例如:结尾淡出太快 / 整体太长了 / 某处有杂音',
ref_toggle: '分轨参考(可展开对比单独轨道)',
ref_voice: '语音',
ref_bgm: '背景音乐',
ref_speed: '语速',
ref_gain: '增益',
ref_delay: '延迟',
ref_volume: '音量',
btn_export: '满意,直接导出',
btn_submit: '提交调整',
btn_count_suffix: '项',
success_export_title: '已确认导出',
success_export_desc: '正在导出最终音频…',
success_adjust_title: '调整意见已提交',
success_adjust_desc: '正在根据你的反馈重新生成…',
confirm_hint: '勾选即表示确认此调整',
submit_error: '提交失败,请重试',
feedback_satisfied: '满意,可以导出',
feedback_checked_no_choice: '(已勾选,未选择方向)',
feedback_checked_no_detail: '(已勾选,未填写详情)',
feedback_needs_adjustment: '需要调整',
},
};
const locale = (data && data.locale) || 'en';
const S = STRINGS[locale];
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en';
/* Build ADJUSTMENTS from i18n strings */
function buildAdjustments() {
return [
{
id: 'voice_speed', label: S.adj_voice_speed_label, tag: S.adj_voice_speed_tag,
tagColor: '#d2a8ff',
hint: S.adj_voice_speed_hint,
type: 'choice', options: S.adj_voice_speed_options,
},
{
id: 'voice_tone', label: S.adj_voice_tone_label, tag: S.adj_voice_tone_tag,
tagColor: '#d2a8ff',
hint: S.adj_voice_tone_hint,
type: 'text', placeholder: S.adj_voice_tone_placeholder,
},
{
id: 'voice_volume', label: S.adj_voice_volume_label, tag: S.adj_voice_volume_tag,
tagColor: '#d2a8ff',
hint: S.adj_voice_volume_hint,
type: 'choice', options: S.adj_voice_volume_options,
},
{
id: 'nature_volume', label: S.adj_nature_volume_label, tag: S.adj_nature_volume_tag,
tagColor: '#3fb950',
hint: S.adj_nature_volume_hint,
type: 'choice', options: S.adj_nature_volume_options,
hidden: () => !data.nature,
},
{
id: 'nature_swap', label: S.adj_nature_swap_label, tag: S.adj_nature_swap_tag,
tagColor: '#3fb950',
hint: S.adj_nature_swap_hint,
type: 'text', placeholder: S.adj_nature_swap_placeholder,
hidden: () => !data.nature,
},
{
id: 'music_volume', label: S.adj_music_volume_label, tag: S.adj_music_volume_tag,
tagColor: '#f0883e',
hint: S.adj_music_volume_hint,
type: 'choice', options: S.adj_music_volume_options,
hidden: () => !data.music,
},
{
id: 'music_style', label: S.adj_music_style_label, tag: S.adj_music_style_tag,
tagColor: '#f0883e',
hint: S.adj_music_style_hint,
type: 'text', placeholder: S.adj_music_style_placeholder,
hidden: () => !data.music,
},
{
id: 'music_toggle',
label: data.music ? S.adj_music_toggle_label_has : S.adj_music_toggle_label_no,
tag: S.adj_music_toggle_tag,
tagColor: '#f0883e',
hint: data.music ? S.adj_music_toggle_hint_has : S.adj_music_toggle_hint_no,
type: 'confirm',
},
{
id: 'voice_pause', label: S.adj_voice_pause_label, tag: S.adj_voice_pause_tag,
tagColor: '#58a6ff',
hint: S.adj_voice_pause_hint,
type: 'choice', options: S.adj_voice_pause_options,
},
{
id: 'other', label: S.adj_other_label, tag: S.adj_other_tag,
tagColor: '#8b949e',
hint: S.adj_other_hint,
type: 'text', placeholder: S.adj_other_placeholder,
},
];
}
const ADJUSTMENTS = buildAdjustments();
const state = {
active: {}, // id -> true/false
values: {}, // id -> slider value or text value
refOpen: false,
};
/* Init values */
ADJUSTMENTS.forEach(a => {
state.active[a.id] = false;
if (a.type === 'choice') state.values[a.id] = null; // no option selected yet
else if (a.type === 'text') state.values[a.id] = '';
else if (a.type === 'confirm') state.values[a.id] = false;
});
function esc(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function fillHint(hint) {
return hint
.replace('{speed}', data.voice.speed)
.replace('{voiceId}', data.voice.voiceId)
.replace('{gain}', data.voice.gain)
.replace('{natureCategory}', data.nature ? data.nature.category : '')
.replace('{natureVol}', data.nature ? data.nature.volume : '')
.replace('{musicVol}', data.music ? data.music.volume : '')
.replace('{delay}', data.voiceDelay);
}
function activeCount() {
return Object.values(state.active).filter(Boolean).length;
}
function render() {
renderHero();
renderAdj();
}
function renderHero() {
const typeName = data.contentType === 'asmr' ? S.subtitle_asmr
: data.contentType === 'meditation' ? S.subtitle_meditation
: S.subtitle_story;
document.getElementById('hero').innerHTML = `
<h1>${esc(data.projectName)}</h1>
<p class="subtitle">${typeName} | ${S.listen_hint}</p>
<div class="hero">
<div class="hero-label">
<div class="hero-icon">&#9654;</div>
<span class="hero-title">${S.hero_title}</span>
</div>
<audio controls preload="metadata" src="${data.mixed.audioUrl}"></audio>
<div class="hero-meta">
<span>${S.voice_label} ${esc(data.voice.voiceId)}</span>
<span>${S.speed_label} ${data.voice.speed}x</span>
${data.nature ? `<span>${esc(data.nature.category)}</span>` : ''}
${data.music ? `<span>${S.has_music}</span>` : `<span>${S.no_music}</span>`}
</div>
</div>
`;
}
function renderAdj() {
const visibleAdj = ADJUSTMENTS.filter(a => !a.hidden || !a.hidden());
let html = `
<div class="section-title">${S.section_adj}</div>
<div class="adj-grid">
`;
visibleAdj.forEach(a => {
const isActive = state.active[a.id];
html += `
<div class="adj-card ${isActive ? 'active' : ''}" data-id="${a.id}">
<div class="adj-header" onclick="toggleAdj('${a.id}')">
<div class="adj-check">${isActive ? '✓' : ''}</div>
<span class="adj-label">${a.label}</span>
<span class="adj-tag" style="color:${a.tagColor};border:1px solid ${a.tagColor}33;background:${a.tagColor}11">${a.tag}</span>
</div>
<div class="adj-body">
<div class="adj-hint">${fillHint(a.hint)}</div>
`;
if (a.type === 'choice') {
html += `<div class="choice-row">`;
a.options.forEach(opt => {
const selected = state.values[a.id] === opt ? 'selected' : '';
html += `<div class="choice-pill ${selected}" onclick="onChoice('${a.id}','${opt}')">${opt}</div>`;
});
html += `</div>`;
} else if (a.type === 'text') {
html += `
<textarea placeholder="${a.placeholder || ''}"
oninput="state.values['${a.id}']=this.value">${esc(state.values[a.id])}</textarea>
`;
} else if (a.type === 'confirm') {
html += `<div class="adj-hint" style="color:var(--accent)">${S.confirm_hint}</div>`;
}
html += `</div></div>`;
});
html += `</div>`;
/* Reference tracks (collapsed) */
html += `
<div class="ref-section">
<div class="ref-toggle" onclick="toggleRef()">
<span class="ref-arrow ${state.refOpen ? 'open' : ''}">▶</span>
${S.ref_toggle}
</div>
<div class="ref-body ${state.refOpen ? 'open' : ''}">
<div class="ref-track">
<div class="ref-track-label"><div class="ref-dot" style="background:var(--voice-color)"></div>${S.ref_voice}</div>
<audio controls preload="none" src="${data.voice.audioUrl}"></audio>
<div class="ref-params">
<span class="ref-param">${esc(data.voice.voiceId)}</span>
<span class="ref-param">${S.ref_speed} ${data.voice.speed}x</span>
<span class="ref-param">${S.ref_gain} ${data.voice.gain}x</span>
<span class="ref-param">${S.ref_delay} ${data.voiceDelay}s</span>
</div>
</div>
`;
if (data.nature) {
html += `
<div class="ref-track">
<div class="ref-track-label"><div class="ref-dot" style="background:var(--nature-color)"></div>${esc(data.nature.category)}</div>
<audio controls preload="none" src="${data.nature.audioUrl}"></audio>
<div class="ref-params"><span class="ref-param">${S.ref_volume} ${data.nature.volume}x</span></div>
</div>
`;
}
if (data.music) {
html += `
<div class="ref-track">
<div class="ref-track-label"><div class="ref-dot" style="background:var(--music-color)"></div>${S.ref_bgm}</div>
<audio controls preload="none" src="${data.music.audioUrl}"></audio>
<div class="ref-params"><span class="ref-param">${S.ref_volume} ${data.music.volume}x</span></div>
</div>
`;
}
html += `</div></div>`;
/* Action buttons */
const n = activeCount();
html += `
<div class="actions">
<button class="btn btn-success" id="btn-lgtm" onclick="submitFeedback(true)">${S.btn_export}</button>
<button class="btn btn-primary" id="btn-submit" onclick="submitFeedback(false)" ${n === 0 ? 'disabled' : ''}>
${S.btn_submit}<span class="btn-count">${n > 0 ? '(' + n + S.btn_count_suffix + ')' : ''}</span>
</button>
</div>
`;
document.getElementById('adj').innerHTML = html;
}
function toggleAdj(id) {
state.active[id] = !state.active[id];
renderAdj();
}
function onChoice(id, opt) {
state.values[id] = state.values[id] === opt ? null : opt; // toggle
renderAdj();
}
function toggleRef() {
state.refOpen = !state.refOpen;
const arrow = document.querySelector('.ref-arrow');
const body = document.querySelector('.ref-body');
arrow.classList.toggle('open');
body.classList.toggle('open');
}
function collectFeedback(isLGTM) {
const lines = [];
const settings = { overall_satisfied: isLGTM };
if (isLGTM) {
return { feedback: S.feedback_satisfied, settings };
}
ADJUSTMENTS.forEach(a => {
if (!state.active[a.id]) return;
if (a.hidden && a.hidden()) return;
if (a.type === 'choice') {
const chosen = state.values[a.id];
settings[a.id] = chosen;
lines.push(`- **${a.label}**${chosen || S.feedback_checked_no_choice}`);
} else if (a.type === 'text') {
const text = (state.values[a.id] || '').trim();
settings[a.id] = text;
lines.push(`- **${a.label}**${text || S.feedback_checked_no_detail}`);
} else if (a.type === 'confirm') {
settings[a.id] = true;
lines.push(`- **${a.label}**`);
}
});
return {
feedback: lines.length > 0 ? lines.join('\n') : S.feedback_needs_adjustment,
settings,
};
}
async function submitFeedback(isLGTM) {
const btnLgtm = document.getElementById('btn-lgtm');
const btnSubmit = document.getElementById('btn-submit');
btnLgtm.disabled = true;
btnSubmit.disabled = true;
const { feedback, settings } = collectFeedback(isLGTM);
try {
const res = await fetch('/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback, settings })
});
const result = await res.json();
if (result.ok) {
document.getElementById('hero').innerHTML = `
<div class="success-msg">
<h2>${isLGTM ? S.success_export_title : S.success_adjust_title}</h2>
<p>${isLGTM ? S.success_export_desc : S.success_adjust_desc}</p>
</div>
`;
document.getElementById('adj').innerHTML = '';
}
} catch (e) {
btnLgtm.disabled = false;
btnSubmit.disabled = false;
alert(S.submit_error);
}
}
render();
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
助眠与放松音频创作工具,用于生成沉浸式睡眠场景音频内容,
覆盖三种内容类型:
引导冥想、睡前故事,以及 ASMR(耳语加白噪音)。
通过适合 ASMR 的声音进行旁白,结合大语言模型驱动的智能停顿插入、
自然环境音素材与 AI 背景音乐,最终混音输出完整音频作品。
触发词包括:sleep audio、relaxation、meditation、bedtime story、
ambient、soundscape、ASMR、white noise、guided meditation、
助眠、放松音频、冥想、睡前故事、氛围音、白噪音、引导冥想。
@@ -0,0 +1,112 @@
# Category-Music Prompt Mapping
Maps nature sound categories to background music prompts.
Music serves as a bed layer — slow tempo, low volume, simple melody, mood-matched.
## Indoor
### rain
```
soft gentle piano, lo-fi ambient, cozy rainy day, warm intimate atmosphere, very slow tempo, minimal, dreamy, reverb
```
### thunder
```
dark ambient pads, deep cello drone, cinematic tension, low rumbling bass, very slow, atmospheric, mysterious
```
### fireplace
```
warm acoustic guitar fingerpicking, soft cello, cozy winter evening, intimate, gentle, slow tempo, folk-inspired
```
### cafe
```
jazz piano trio, brushed drums, walking bass, warm, conversational tempo, bossa nova influence, relaxed
```
### clock
```
minimal piano, music box melody, gentle arpeggios, clockwork rhythm, delicate, whimsical, soft
```
### keyboard
```
lo-fi hip hop beats, soft piano chords, vinyl crackle texture, chill, study music, mellow, steady groove
```
### vinyl
```
vintage jazz, warm analog synth, retro lounge, soft saxophone, nostalgic, old-fashioned, gentle swing
```
## Nature
### ocean
```
ambient pad synths, gentle harp arpeggios, slow ethereal, spacious, meditative, new age, flowing
```
### river
```
acoustic guitar harmonics, soft marimba, gentle flute, nature-inspired, flowing tempo, peaceful, transparent
```
### forest
```
soft flute melody, gentle strings, peaceful morning, ambient, minimal, slow, nature-inspired, woodland
```
### bird
```
light acoustic guitar, gentle glockenspiel, pastoral, morning dew, airy, bright, delicate, hopeful
```
### cricket
```
warm ambient pads, soft guitar, summer night, lazy tempo, dreamy, nostalgic, gentle hum, twilight
```
### wind
```
ambient drone, soft piano chords, ethereal pads, spacious, contemplative, very slow, vast, solitary
```
### snow
```
celesta, soft strings, music box, winter silence, crystalline, delicate, cold beauty, sparse, Nordic
```
## Urban / Transport
### train
```
soft rhythmic guitar, gentle percussion matching train rhythm, journey, nostalgic, folk, storytelling, wanderlust
```
### city
```
smooth jazz, muted trumpet, urban night, sophisticated, laid-back groove, city lights, cool
```
### subway
```
electronic ambient, soft synth bass, underground pulse, minimal techno influence, muted, urban, hypnotic
```
## Special
### whitenoise
```
deep ambient drone, very slow evolving pad, binaural texture, meditative, theta waves, healing, minimal
```
### underwater
```
deep sub bass, reverse reverb piano, aquatic textures, slow motion, otherworldly, blue, submerged, echoing
```
### space
```
cosmic ambient, deep space synth pads, sci-fi atmosphere, vast emptiness, stars, ethereal choir, timeless, infinite
```
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""启动助眠音频混合预览服务器。
用法:
python3 render_mix_preview.py <preview_data.json> [--port PORT]
流程:
1. 读取 JSON 数据文件(包含各轨道路径、参数配置)
2. 注入 HTML 模板,启动本地 HTTP 服务器
3. 在浏览器中打开预览页面
4. 用户提交反馈后,写入 feedback.md + mix_settings.json,服务器自动退出
"""
import argparse
import base64
import json
import mimetypes
import os
import sys
import threading
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from socketserver import ThreadingMixIn
SKILL_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = SKILL_DIR / "html"
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class PreviewHandler(BaseHTTPRequestHandler):
html_content: str = ""
feedback_path: Path = Path("feedback.md")
settings_path: Path = Path("mix_settings.json")
server_ref: HTTPServer | None = None
def do_GET(self):
if self.path.startswith("/audio/"):
self._serve_audio()
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(self.html_content.encode("utf-8"))
def _serve_audio(self):
encoded = self.path[len("/audio/"):]
try:
audio_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
except Exception:
self.send_error(400, "invalid audio path")
return
if not audio_path.exists():
self.send_error(404, "audio not found")
return
mime = mimetypes.guess_type(str(audio_path))[0] or "audio/mpeg"
file_size = audio_path.stat().st_size
try:
self._send_audio(audio_path, mime, file_size)
except BrokenPipeError:
pass
def _send_audio(self, audio_path, mime, file_size):
range_header = self.headers.get("Range")
if range_header:
# Parse Range: bytes=start-end
range_spec = range_header.replace("bytes=", "")
parts = range_spec.split("-")
start = int(parts[0]) if parts[0] else 0
end = int(parts[1]) if parts[1] else file_size - 1
end = min(end, file_size - 1)
length = end - start + 1
self.send_response(206)
self.send_header("Content-Type", mime)
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
self.send_header("Content-Length", str(length))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "max-age=3600")
self.end_headers()
with open(audio_path, "rb") as f:
f.seek(start)
self.wfile.write(f.read(length))
else:
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(file_size))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "max-age=3600")
self.end_headers()
with open(audio_path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
self.wfile.write(chunk)
def do_POST(self):
if self.path == "/feedback":
self._handle_feedback()
else:
self.send_error(404)
def _handle_feedback(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
data = json.loads(body)
feedback = data.get("feedback", "").strip()
settings = data.get("settings", {})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
if feedback:
self.feedback_path.write_text(feedback, encoding="utf-8")
if settings:
self.settings_path.write_text(
json.dumps(settings, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self.wfile.write(json.dumps({"ok": True}).encode())
print(f"FEEDBACK_FILE={self.feedback_path}")
if settings:
print(f"SETTINGS_FILE={self.settings_path}")
threading.Timer(0.5, self._shutdown).start()
else:
self.wfile.write(json.dumps({"ok": False, "msg": "empty"}).encode())
def _shutdown(self):
if self.server_ref:
self.server_ref.shutdown()
def log_message(self, format, *args):
pass
def to_audio_url(abs_path: str) -> str:
"""将绝对路径转为 /audio/<base64> URL"""
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
return f"/audio/{encoded}"
def prepare_data(raw_data: dict) -> dict:
"""将各轨道的绝对路径转为可访问的 /audio/ URL。
数据结构:
- voice.path → voice.audioUrl
- nature.path → nature.audioUrl
- music.path → music.audioUrl (可选)
- mixed.path → mixed.audioUrl
"""
if raw_data.get("voice") and raw_data["voice"].get("path"):
raw_data["voice"]["audioUrl"] = to_audio_url(raw_data["voice"]["path"])
if raw_data.get("nature") and raw_data["nature"].get("path"):
raw_data["nature"]["audioUrl"] = to_audio_url(raw_data["nature"]["path"])
if raw_data.get("music") and raw_data["music"].get("path"):
raw_data["music"]["audioUrl"] = to_audio_url(raw_data["music"]["path"])
if raw_data.get("mixed") and raw_data["mixed"].get("path"):
raw_data["mixed"]["audioUrl"] = to_audio_url(raw_data["mixed"]["path"])
return raw_data
def main():
parser = argparse.ArgumentParser(description="启动助眠音频混合预览服务器")
parser.add_argument("data_file", help="JSON 数据文件路径")
parser.add_argument("--port", type=int, default=0, help="端口号,默认自动分配")
parsed = parser.parse_args()
data_path = Path(parsed.data_file).resolve()
if not data_path.exists():
print(f"ERROR: 数据文件不存在: {data_path}")
sys.exit(1)
raw_data = json.loads(data_path.read_text(encoding="utf-8"))
preview_data = prepare_data(raw_data)
template_path = TEMPLATE_DIR / "preview-mix_feedback.html"
if not template_path.exists():
print(f"ERROR: 模板不存在: {template_path}")
sys.exit(1)
template = template_path.read_text(encoding="utf-8")
injected = template.replace(
"const PREVIEW_TYPE = null; /* __PREVIEW_TYPE__ */",
'const PREVIEW_TYPE = "mix_feedback";',
).replace(
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
f"const PREVIEW_DATA = {json.dumps(json.dumps(preview_data, ensure_ascii=False))};",
)
feedback_dir = data_path.parent
server = ThreadingHTTPServer(("127.0.0.1", parsed.port), PreviewHandler)
port = server.server_address[1]
PreviewHandler.html_content = injected
PreviewHandler.feedback_path = feedback_dir / "feedback.md"
PreviewHandler.settings_path = feedback_dir / "mix_settings.json"
PreviewHandler.server_ref = server
url = f"http://127.0.0.1:{port}"
print(f"PREVIEW_URL={url}")
print("等待用户提交...")
webbrowser.open(url)
server.serve_forever()
print("服务器已停止")
if __name__ == "__main__":
main()
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
验证 nature/ 目录中的素材文件,输出类别识别结果和元数据。
用法:python validate_nature.py <nature_dir>
"""
import os, subprocess, sys, json
CATEGORY_KEYWORDS = {
"rain": ["rain", ""],
"thunder": ["thunder", ""],
"fireplace": ["fireplace", "fire", "壁炉", "篝火"],
"ocean": ["ocean", "wave", "sea", "海浪", ""],
"river": ["river", "stream", "creek", "brook", "waterfall", "溪流", "", "瀑布"],
"forest": ["forest", "森林"],
"bird": ["bird", ""],
"cricket": ["cricket", "cicada", "蟋蟀", ""],
"wind": ["wind", ""],
"snow": ["snow", ""],
"cafe": ["cafe", "coffee", "咖啡"],
"clock": ["clock", "tick", ""],
"keyboard": ["keyboard", "typing", "键盘"],
"vinyl": ["vinyl", "record", "黑胶"],
"train": ["train", "rail", "火车", "铁轨"],
"city": ["city", "traffic", "street", "城市", "街道"],
"subway": ["subway", "metro", "地铁"],
"whitenoise": ["whitenoise", "white_noise", "白噪音"],
"underwater": ["underwater", "bubble", "水下"],
"space": ["space", "cosmic", "太空"],
}
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac"}
def get_duration(path):
try:
r = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", path],
capture_output=True, text=True,
)
return float(r.stdout.strip())
except Exception:
return 0.0
def detect_category(filename):
name = filename.lower()
for cat, keywords in CATEGORY_KEYWORDS.items():
for kw in keywords:
if kw in name:
return cat
return None
def main():
nature_dir = sys.argv[1] if len(sys.argv) > 1 else "."
files = sorted([
f for f in os.listdir(nature_dir)
if os.path.splitext(f)[1].lower() in AUDIO_EXTS
])
if not files:
print(json.dumps({"error": "no audio files found", "dir": nature_dir}))
sys.exit(1)
results = []
for f in files:
path = os.path.join(nature_dir, f)
cat = detect_category(f)
dur = get_duration(path)
results.append({
"filename": f,
"category": cat,
"duration_s": round(dur, 1),
"size_mb": round(os.path.getsize(path) / 1024 / 1024, 1),
"needs_category": cat is None,
})
summary = {
"total": len(results),
"categorized": sum(1 for r in results if r["category"]),
"uncategorized": sum(1 for r in results if not r["category"]),
"files": results,
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()