Flatten skill category directory structure
This commit is contained in:
@@ -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">▶</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>
|
||||
@@ -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()
|
||||
Binary file not shown.
Reference in New Issue
Block a user